feat(runtime): add reasoning toggle for ollama

This commit is contained in:
Chummy 2026-02-19 16:51:25 +08:00
parent 8f13fee4a6
commit a5d7911923
10 changed files with 289 additions and 31 deletions

View file

@ -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 (<value>)`.
## `[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 |

View file

@ -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`

View file

@ -1191,13 +1191,21 @@ pub async fn run(
.or(config.default_model.as_deref())
.unwrap_or("anthropic/claude-sonnet-4");
let provider: Box<dyn Provider> = 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<dyn Provider> = 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<String> {
.default_model
.clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
let provider: Box<dyn Provider> = 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<dyn Provider> = 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<crate::rag::HardwareRag> = config

View file

@ -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<dyn Provider> = Arc::from(providers::create_resilient_provider_with_options(
&provider_name,

View file

@ -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<bool>,
}
#[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;

View file

@ -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

View file

@ -630,6 +630,7 @@ pub struct ProviderRuntimeOptions {
pub auth_profile_override: Option<String>,
pub zeroclaw_dir: Option<PathBuf>,
pub secrets_encrypt: bool,
pub reasoning_enabled: Option<bool>,
}
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<Box<dyn Provider>> {
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<Box<dyn Provider>> {
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<Box<dyn Provider>> {
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<Box<dyn Provider>> {
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 {

View file

@ -7,6 +7,7 @@ use std::collections::HashMap;
pub struct OllamaProvider {
base_url: String,
api_key: Option<String>,
reasoning_enabled: Option<bool>,
}
// ─── Request Structures ───────────────────────────────────────────────────────
@ -18,6 +19,8 @@ struct ChatRequest {
stream: bool,
options: Options,
#[serde(skip_serializing_if = "Option::is_none")]
think: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
}
@ -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<bool>,
) -> 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<Message>,
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<ApiChatResponse> {
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!"}}"#;

View file

@ -21,6 +21,8 @@ pub struct DelegateTool {
security: Arc<SecurityPolicy>,
/// Global credential fallback (from config.api_key)
fallback_credential: Option<String>,
/// 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<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
) -> Self {
Self::new_with_options(
agents,
fallback_credential,
security,
providers::ProviderRuntimeOptions::default(),
)
}
pub fn new_with_options(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
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<String>,
security: Arc<SecurityPolicy>,
depth: u32,
) -> Self {
Self::with_depth_and_options(
agents,
fallback_credential,
security,
depth,
providers::ProviderRuntimeOptions::default(),
)
}
pub fn with_depth_and_options(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
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<dyn Provider> =
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<dyn Provider> = 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() {

View file

@ -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,
},
)));
}