From a5d79119237afaaf4b66ed0de2a9c0bee309fad2 Mon Sep 17 00:00:00 2001 From: Chummy Date: Thu, 19 Feb 2026 16:51:25 +0800 Subject: [PATCH] feat(runtime): add reasoning toggle for ollama --- docs/config-reference.md | 12 ++++++ docs/providers-reference.md | 15 +++++++ src/agent/loop_.rs | 19 ++++++++- src/channels/mod.rs | 1 + src/config/schema.rs | 63 ++++++++++++++++++++++++++++++ src/gateway/mod.rs | 1 + src/providers/mod.rs | 55 +++++++++++++++++++++++--- src/providers/ollama.rs | 78 +++++++++++++++++++++++++++++++++---- src/tools/delegate.rs | 65 ++++++++++++++++++++++++------- src/tools/mod.rs | 11 +++++- 10 files changed, 289 insertions(+), 31 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index 973b567..2b8d87f 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -50,6 +50,18 @@ Notes: - Setting `max_tool_iterations = 0` falls back to safe default `10`. - If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations ()`. +## `[runtime]` + +| Key | Default | Purpose | +|---|---|---| +| `reasoning_enabled` | unset (`None`) | Global reasoning/thinking override for providers that support explicit controls | + +Notes: + +- `reasoning_enabled = false` explicitly disables provider-side reasoning for supported providers (currently `ollama`, via request field `think: false`). +- `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`). +- Unset keeps provider defaults. + ## `[gateway]` | Key | Default | Purpose | diff --git a/docs/providers-reference.md b/docs/providers-reference.md index 5b79b3d..790ee76 100644 --- a/docs/providers-reference.md +++ b/docs/providers-reference.md @@ -67,6 +67,21 @@ credential is not reused for fallback providers. - Cross-region inference profiles supported (e.g., `us.anthropic.claude-*`). - Model IDs use Bedrock format: `anthropic.claude-sonnet-4-6`, `anthropic.claude-opus-4-6-v1`, etc. +### Ollama Reasoning Toggle + +You can control Ollama reasoning/thinking behavior from `config.toml`: + +```toml +[runtime] +reasoning_enabled = false +``` + +Behavior: + +- `false`: sends `think: false` to Ollama `/api/chat` requests. +- `true`: sends `think: true`. +- Unset: omits `think` and keeps Ollama/model defaults. + ### Kimi Code Notes - Provider ID: `kimi-code` diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index caa7e53..99b0ce7 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -1191,13 +1191,21 @@ pub async fn run( .or(config.default_model.as_deref()) .unwrap_or("anthropic/claude-sonnet-4"); - let provider: Box = providers::create_routed_provider( + let provider_runtime_options = providers::ProviderRuntimeOptions { + auth_profile_override: None, + zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), + secrets_encrypt: config.secrets.encrypt, + reasoning_enabled: config.runtime.reasoning_enabled, + }; + + let provider: Box = providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, &config.model_routes, model_name, + &provider_runtime_options, )?; observer.record_event(&ObserverEvent::AgentStart { @@ -1632,13 +1640,20 @@ pub async fn process_message(config: Config, message: &str) -> Result { .default_model .clone() .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); - let provider: Box = providers::create_routed_provider( + let provider_runtime_options = providers::ProviderRuntimeOptions { + auth_profile_override: None, + zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), + secrets_encrypt: config.secrets.encrypt, + reasoning_enabled: config.runtime.reasoning_enabled, + }; + let provider: Box = providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, &config.model_routes, &model_name, + &provider_runtime_options, )?; let hardware_rag: Option = config diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 52303d0..023064a 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1672,6 +1672,7 @@ pub async fn start_channels(config: Config) -> Result<()> { auth_profile_override: None, zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), secrets_encrypt: config.secrets.encrypt, + reasoning_enabled: config.runtime.reasoning_enabled, }; let provider: Arc = Arc::from(providers::create_resilient_provider_with_options( &provider_name, diff --git a/src/config/schema.rs b/src/config/schema.rs index f304466..2016375 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1607,6 +1607,13 @@ pub struct RuntimeConfig { /// Docker runtime settings (used when `kind = "docker"`). #[serde(default)] pub docker: DockerRuntimeConfig, + + /// Global reasoning override for providers that expose explicit controls. + /// - `None`: provider default behavior + /// - `Some(true)`: request reasoning/thinking when supported + /// - `Some(false)`: disable reasoning/thinking when supported + #[serde(default)] + pub reasoning_enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -1679,6 +1686,7 @@ impl Default for RuntimeConfig { Self { kind: default_runtime_kind(), docker: DockerRuntimeConfig::default(), + reasoning_enabled: None, } } } @@ -2979,6 +2987,18 @@ impl Config { } } + // Reasoning override: ZEROCLAW_REASONING_ENABLED or REASONING_ENABLED + if let Ok(flag) = std::env::var("ZEROCLAW_REASONING_ENABLED") + .or_else(|_| std::env::var("REASONING_ENABLED")) + { + let normalized = flag.trim().to_ascii_lowercase(); + match normalized.as_str() { + "1" | "true" | "yes" | "on" => self.runtime.reasoning_enabled = Some(true), + "0" | "false" | "no" | "off" => self.runtime.reasoning_enabled = Some(false), + _ => {} + } + } + // Web search enabled: ZEROCLAW_WEB_SEARCH_ENABLED or WEB_SEARCH_ENABLED if let Ok(enabled) = std::env::var("ZEROCLAW_WEB_SEARCH_ENABLED") .or_else(|_| std::env::var("WEB_SEARCH_ENABLED")) @@ -3560,6 +3580,19 @@ connect_timeout_secs = 12 ); } + #[test] + fn runtime_reasoning_enabled_deserializes() { + let raw = r#" +default_temperature = 0.7 + +[runtime] +reasoning_enabled = false +"#; + + let parsed: Config = toml::from_str(raw).unwrap(); + assert_eq!(parsed.runtime.reasoning_enabled, Some(false)); + } + #[test] async fn agent_config_defaults() { let cfg = AgentConfig::default(); @@ -5001,6 +5034,36 @@ default_model = "legacy-model" std::env::remove_var("ZEROCLAW_TEMPERATURE"); } + #[test] + async fn env_override_reasoning_enabled() { + let _env_guard = env_override_lock().await; + let mut config = Config::default(); + assert_eq!(config.runtime.reasoning_enabled, None); + + std::env::set_var("ZEROCLAW_REASONING_ENABLED", "false"); + config.apply_env_overrides(); + assert_eq!(config.runtime.reasoning_enabled, Some(false)); + + std::env::set_var("ZEROCLAW_REASONING_ENABLED", "true"); + config.apply_env_overrides(); + assert_eq!(config.runtime.reasoning_enabled, Some(true)); + + std::env::remove_var("ZEROCLAW_REASONING_ENABLED"); + } + + #[test] + async fn env_override_reasoning_invalid_value_ignored() { + let _env_guard = env_override_lock().await; + let mut config = Config::default(); + config.runtime.reasoning_enabled = Some(false); + + std::env::set_var("ZEROCLAW_REASONING_ENABLED", "maybe"); + config.apply_env_overrides(); + assert_eq!(config.runtime.reasoning_enabled, Some(false)); + + std::env::remove_var("ZEROCLAW_REASONING_ENABLED"); + } + #[test] async fn env_override_invalid_port_ignored() { let _env_guard = env_override_lock().await; diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 94d405d..db55c00 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -313,6 +313,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { auth_profile_override: None, zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), secrets_encrypt: config.secrets.encrypt, + reasoning_enabled: config.runtime.reasoning_enabled, }, )?); let model = config diff --git a/src/providers/mod.rs b/src/providers/mod.rs index e3c3d26..107866c 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -630,6 +630,7 @@ pub struct ProviderRuntimeOptions { pub auth_profile_override: Option, pub zeroclaw_dir: Option, pub secrets_encrypt: bool, + pub reasoning_enabled: Option, } impl Default for ProviderRuntimeOptions { @@ -638,6 +639,7 @@ impl Default for ProviderRuntimeOptions { auth_profile_override: None, zeroclaw_dir: None, secrets_encrypt: true, + reasoning_enabled: None, } } } @@ -865,16 +867,26 @@ pub fn create_provider_with_options( "openai-codex" | "openai_codex" | "codex" => { Ok(Box::new(openai_codex::OpenAiCodexProvider::new(options))) } - _ => create_provider_with_url(name, api_key, None), + _ => create_provider_with_url_and_options(name, api_key, None, options), } } /// Factory: create the right provider from config with optional custom base URL -#[allow(clippy::too_many_lines)] pub fn create_provider_with_url( name: &str, api_key: Option<&str>, api_url: Option<&str>, +) -> anyhow::Result> { + create_provider_with_url_and_options(name, api_key, api_url, &ProviderRuntimeOptions::default()) +} + +/// Factory: create provider with optional base URL and runtime options. +#[allow(clippy::too_many_lines)] +fn create_provider_with_url_and_options( + name: &str, + api_key: Option<&str>, + api_url: Option<&str>, + options: &ProviderRuntimeOptions, ) -> anyhow::Result> { let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key)); @@ -895,7 +907,11 @@ pub fn create_provider_with_url( "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))), "openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))), // Ollama uses api_url for custom base URL (e.g. remote Ollama instance) - "ollama" => Ok(Box::new(ollama::OllamaProvider::new(api_url, key))), + "ollama" => Ok(Box::new(ollama::OllamaProvider::new_with_reasoning( + api_url, + key, + options.reasoning_enabled, + ))), "gemini" | "google" | "google-gemini" => { Ok(Box::new(gemini::GeminiProvider::new(key))) } @@ -1109,7 +1125,7 @@ pub fn create_resilient_provider_with_options( "openai-codex" | "openai_codex" | "codex" => { create_provider_with_options(primary_name, api_key, options)? } - _ => create_provider_with_url(primary_name, api_key, api_url)?, + _ => create_provider_with_url_and_options(primary_name, api_key, api_url, options)?, }; providers.push((primary_name.to_string(), primary_provider)); @@ -1159,9 +1175,36 @@ pub fn create_routed_provider( reliability: &crate::config::ReliabilityConfig, model_routes: &[crate::config::ModelRouteConfig], default_model: &str, +) -> anyhow::Result> { + create_routed_provider_with_options( + primary_name, + api_key, + api_url, + reliability, + model_routes, + default_model, + &ProviderRuntimeOptions::default(), + ) +} + +/// Create a routed provider using explicit runtime options. +pub fn create_routed_provider_with_options( + primary_name: &str, + api_key: Option<&str>, + api_url: Option<&str>, + reliability: &crate::config::ReliabilityConfig, + model_routes: &[crate::config::ModelRouteConfig], + default_model: &str, + options: &ProviderRuntimeOptions, ) -> anyhow::Result> { if model_routes.is_empty() { - return create_resilient_provider(primary_name, api_key, api_url, reliability); + return create_resilient_provider_with_options( + primary_name, + api_key, + api_url, + reliability, + options, + ); } // Collect unique provider names needed @@ -1187,7 +1230,7 @@ pub fn create_routed_provider( let key = routed_credential.or(api_key); // Only use api_url for the primary provider let url = if name == primary_name { api_url } else { None }; - match create_resilient_provider(name, key, url, reliability) { + match create_resilient_provider_with_options(name, key, url, reliability, options) { Ok(provider) => providers.push((name.clone(), provider)), Err(e) => { if name == primary_name { diff --git a/src/providers/ollama.rs b/src/providers/ollama.rs index 6a43ad2..91a45ad 100644 --- a/src/providers/ollama.rs +++ b/src/providers/ollama.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; pub struct OllamaProvider { base_url: String, api_key: Option, + reasoning_enabled: Option, } // ─── Request Structures ─────────────────────────────────────────────────────── @@ -18,6 +19,8 @@ struct ChatRequest { stream: bool, options: Options, #[serde(skip_serializing_if = "Option::is_none")] + think: Option, + #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, } @@ -85,6 +88,14 @@ struct OllamaFunction { impl OllamaProvider { pub fn new(base_url: Option<&str>, api_key: Option<&str>) -> Self { + Self::new_with_reasoning(base_url, api_key, None) + } + + pub fn new_with_reasoning( + base_url: Option<&str>, + api_key: Option<&str>, + reasoning_enabled: Option, + ) -> Self { let api_key = api_key.and_then(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) @@ -96,6 +107,7 @@ impl OllamaProvider { .trim_end_matches('/') .to_string(), api_key, + reasoning_enabled, } } @@ -137,6 +149,23 @@ impl OllamaProvider { serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({})) } + fn build_chat_request( + &self, + messages: Vec, + model: &str, + temperature: f64, + tools: Option<&[serde_json::Value]>, + ) -> ChatRequest { + ChatRequest { + model: model.to_string(), + messages, + stream: false, + options: Options { temperature }, + think: self.reasoning_enabled, + tools: tools.map(|t| t.to_vec()), + } + } + /// Convert internal chat history format to Ollama's native tool-call message schema. /// /// `run_tool_call_loop` stores native assistant/tool entries as JSON strings in @@ -235,22 +264,17 @@ impl OllamaProvider { should_auth: bool, tools: Option<&[serde_json::Value]>, ) -> anyhow::Result { - let request = ChatRequest { - model: model.to_string(), - messages, - stream: false, - options: Options { temperature }, - tools: tools.map(|t| t.to_vec()), - }; + let request = self.build_chat_request(messages, model, temperature, tools); let url = format!("{}/api/chat", self.base_url); tracing::debug!( - "Ollama request: url={} model={} message_count={} temperature={} tool_count={}", + "Ollama request: url={} model={} message_count={} temperature={} think={:?} tool_count={}", url, model, request.messages.len(), temperature, + request.think, request.tools.as_ref().map_or(0, |t| t.len()), ); @@ -645,6 +669,44 @@ mod tests { assert!(!should_auth); } + #[test] + fn request_omits_think_when_reasoning_not_configured() { + let provider = OllamaProvider::new(None, None); + let request = provider.build_chat_request( + vec![Message { + role: "user".to_string(), + content: Some("hello".to_string()), + tool_calls: None, + tool_name: None, + }], + "llama3", + 0.7, + None, + ); + + let json = serde_json::to_value(request).unwrap(); + assert!(json.get("think").is_none()); + } + + #[test] + fn request_includes_think_when_reasoning_configured() { + let provider = OllamaProvider::new_with_reasoning(None, None, Some(false)); + let request = provider.build_chat_request( + vec![Message { + role: "user".to_string(), + content: Some("hello".to_string()), + tool_calls: None, + tool_name: None, + }], + "llama3", + 0.7, + None, + ); + + let json = serde_json::to_value(request).unwrap(); + assert_eq!(json.get("think"), Some(&serde_json::json!(false))); + } + #[test] fn response_deserializes() { let json = r#"{"message":{"role":"assistant","content":"Hello from Ollama!"}}"#; diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index fabb99c..9fa20ee 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -21,6 +21,8 @@ pub struct DelegateTool { security: Arc, /// Global credential fallback (from config.api_key) fallback_credential: Option, + /// Provider runtime options inherited from root config. + provider_runtime_options: providers::ProviderRuntimeOptions, /// Depth at which this tool instance lives in the delegation chain. depth: u32, } @@ -30,11 +32,26 @@ impl DelegateTool { agents: HashMap, fallback_credential: Option, security: Arc, + ) -> Self { + Self::new_with_options( + agents, + fallback_credential, + security, + providers::ProviderRuntimeOptions::default(), + ) + } + + pub fn new_with_options( + agents: HashMap, + fallback_credential: Option, + security: Arc, + provider_runtime_options: providers::ProviderRuntimeOptions, ) -> Self { Self { agents: Arc::new(agents), security, fallback_credential, + provider_runtime_options, depth: 0, } } @@ -47,11 +64,28 @@ impl DelegateTool { fallback_credential: Option, security: Arc, depth: u32, + ) -> Self { + Self::with_depth_and_options( + agents, + fallback_credential, + security, + depth, + providers::ProviderRuntimeOptions::default(), + ) + } + + pub fn with_depth_and_options( + agents: HashMap, + fallback_credential: Option, + security: Arc, + depth: u32, + provider_runtime_options: providers::ProviderRuntimeOptions, ) -> Self { Self { agents: Arc::new(agents), security, fallback_credential, + provider_runtime_options, depth, } } @@ -190,20 +224,23 @@ impl Tool for DelegateTool { #[allow(clippy::option_as_ref_deref)] let provider_credential = provider_credential_owned.as_ref().map(String::as_str); - let provider: Box = - match providers::create_provider(&agent_config.provider, provider_credential) { - Ok(p) => p, - Err(e) => { - return Ok(ToolResult { - success: false, - output: String::new(), - error: Some(format!( - "Failed to create provider '{}' for agent '{agent_name}': {e}", - agent_config.provider - )), - }); - } - }; + let provider: Box = match providers::create_provider_with_options( + &agent_config.provider, + provider_credential, + &self.provider_runtime_options, + ) { + Ok(p) => p, + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Failed to create provider '{}' for agent '{agent_name}': {e}", + agent_config.provider + )), + }); + } + }; // Build the message let full_prompt = if context.is_empty() { diff --git a/src/tools/mod.rs b/src/tools/mod.rs index a472afc..f8a2de9 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -227,10 +227,19 @@ pub fn all_tools_with_runtime( let trimmed_value = value.trim(); (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned()) }); - tools.push(Box::new(DelegateTool::new( + tools.push(Box::new(DelegateTool::new_with_options( delegate_agents, delegate_fallback_credential, security.clone(), + crate::providers::ProviderRuntimeOptions { + auth_profile_override: None, + zeroclaw_dir: root_config + .config_path + .parent() + .map(std::path::PathBuf::from), + secrets_encrypt: root_config.secrets.encrypt, + reasoning_enabled: root_config.runtime.reasoning_enabled, + }, ))); }