From b4dd5de0d72666a4ec3659b19594c3c0870e6b69 Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Sun, 18 Jan 2026 01:51:48 +0100 Subject: [PATCH] disable thinking via yaml with thinking_budget: 0 or none Signed-off-by: Christopher Petito --- cagent-schema.json | 9 ++- cmd/root/run.go | 7 ++ docs/USAGE.md | 20 +++++ pkg/model/provider/override_test.go | 117 ++++++++++++++++++++++++++++ pkg/model/provider/provider.go | 36 ++++++++- pkg/runtime/runtime.go | 22 ++++++ 6 files changed, 207 insertions(+), 4 deletions(-) diff --git a/cagent-schema.json b/cagent-schema.json index 3167ed747..4714b690d 100644 --- a/cagent-schema.json +++ b/cagent-schema.json @@ -459,26 +459,29 @@ "description": "Whether to track usage" }, "thinking_budget": { - "description": "Controls reasoning effort/budget. OpenAI: string levels ('minimal','low','medium','high'), default 'medium'. Anthropic: integer token budget (1024-32768), default 8192. Amazon Bedrock (Claude): same as Anthropic. Google Gemini 2.5: integer token budget (-1 for dynamic, 0 to disable, 24576 max), default -1. Google Gemini 3: string levels ('minimal' Flash only,'low','medium','high'), default 'high' for Pro, 'medium' for Flash.", + "description": "Controls reasoning effort/budget. Use 'none' or 0 to disable thinking. OpenAI: string levels ('minimal','low','medium','high'), default 'medium'. Anthropic: integer token budget (1024-32768), default 8192. Amazon Bedrock (Claude): same as Anthropic. Google Gemini 2.5: integer token budget (-1 for dynamic, 0 to disable, 24576 max), default -1. Google Gemini 3: string levels ('minimal' Flash only,'low','medium','high'), default 'high' for Pro, 'medium' for Flash.", "oneOf": [ { "type": "string", "enum": [ + "none", "minimal", "low", "medium", "high" ], - "description": "Reasoning effort level (OpenAI, Gemini 3)" + "description": "Reasoning effort level (OpenAI, Gemini 3). Use 'none' to disable thinking." }, { "type": "integer", "minimum": -1, "maximum": 32768, - "description": "Token budget for extended thinking (Anthropic, Bedrock Claude, Gemini 2.5)" + "description": "Token budget for extended thinking (Anthropic, Bedrock Claude, Gemini 2.5). Use 0 to disable thinking." } ], "examples": [ + "none", + 0, "minimal", "low", "medium", diff --git a/cmd/root/run.go b/cmd/root/run.go index a42194003..ca50f7d89 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -310,10 +310,17 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes slog.Debug("Loaded existing session", "session_id", f.sessionID, "agent", f.agentName) } else { + thinking := true + if tb := agent.Model().BaseConfig().ModelConfig.ThinkingBudget; tb != nil { + if tb.Effort == "none" || (tb.Tokens == 0 && tb.Effort == "") { + thinking = false + } + } sess = session.New( session.WithMaxIterations(agent.MaxIterations()), session.WithToolsApproved(f.autoApprove), session.WithHideToolResults(f.hideToolResults), + session.WithThinking(thinking), ) // Session is stored lazily on first UpdateSession call (when content is added) // This avoids creating empty sessions in the database diff --git a/docs/USAGE.md b/docs/USAGE.md index 0c40e2d5e..6f2f0a79d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -284,12 +284,32 @@ models: Determine how much the model should think by setting the `thinking_budget` +- **Disable thinking**: Use `none` (string) or `0` (integer) to explicitly disable thinking for any provider - **OpenAI**: use effort levels — `minimal`, `low`, `medium`, `high`. Default: `medium` - **Anthropic**: set an integer token budget. Range is 1024–32768; must be strictly less than `max_tokens`. Default: `8192` with `interleaved_thinking: true` - **Google (Gemini 2.5)**: set an integer token budget. `0` -> disable thinking, `-1` -> dynamic thinking (model decides). Default: `-1` (dynamic) - **Google (Gemini 3)**: use effort levels — `minimal` (Flash only), `low`, `medium`, `high`. Default: `high` for Pro, `medium` for Flash - **Amazon Bedrock (Claude models)**: set an integer token budget, same as Anthropic. Default: `8192` with `interleaved_thinking: true` +**Disabling thinking:** + +```yaml +models: + # Using string "none" + gpt-no-thinking: + provider: openai + model: gpt-5 + thinking_budget: none # or 0 + + # Using integer 0 + claude-no-thinking: + provider: anthropic + model: claude-sonnet-4-5 + thinking_budget: 0 # or none +``` + +Note: When thinking is disabled via config, it can still be enabled during a session using the `/think` command, which will restore the provider's default thinking configuration. + Examples (OpenAI): ```yaml diff --git a/pkg/model/provider/override_test.go b/pkg/model/provider/override_test.go index 5903a85ac..aa1f28362 100644 --- a/pkg/model/provider/override_test.go +++ b/pkg/model/provider/override_test.go @@ -341,3 +341,120 @@ func TestApplyOverrides_DoesNotModifyOriginal(t *testing.T) { // Result should have changes assert.Nil(t, result.ThinkingBudget, "Result ThinkingBudget should be nil") } + +// TestApplyOverrides_EnableFromDisabled tests that explicitly enabling thinking when +// the config has it disabled (Tokens=0 or Effort="none") restores provider defaults. +// This is the key behavior that makes /think work when YAML starts with thinking_budget: 0/none. +func TestApplyOverrides_EnableFromDisabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *latest.ModelConfig + expectThinkingBudget *latest.ThinkingBudget + }{ + { + name: "Anthropic: enable from Tokens=0 restores default 8192", + config: &latest.ModelConfig{ + Provider: "anthropic", + Model: "claude-sonnet-4-0", + ThinkingBudget: &latest.ThinkingBudget{Tokens: 0}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192}, + }, + { + name: "Anthropic: enable from Effort=none restores default 8192", + config: &latest.ModelConfig{ + Provider: "anthropic", + Model: "claude-sonnet-4-0", + ThinkingBudget: &latest.ThinkingBudget{Effort: "none"}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192}, + }, + { + name: "OpenAI: enable from Tokens=0 restores default medium", + config: &latest.ModelConfig{ + Provider: "openai", + Model: "gpt-4o", + ThinkingBudget: &latest.ThinkingBudget{Tokens: 0}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Effort: "medium"}, + }, + { + name: "OpenAI: enable from Effort=none restores default medium", + config: &latest.ModelConfig{ + Provider: "openai", + Model: "gpt-4o", + ThinkingBudget: &latest.ThinkingBudget{Effort: "none"}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Effort: "medium"}, + }, + { + name: "Gemini 2.5: enable from Tokens=0 restores default -1 (dynamic)", + config: &latest.ModelConfig{ + Provider: "google", + Model: "gemini-2.5-flash", + ThinkingBudget: &latest.ThinkingBudget{Tokens: 0}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Tokens: -1}, + }, + { + name: "Bedrock Claude: enable from Tokens=0 restores default 8192", + config: &latest.ModelConfig{ + Provider: "amazon-bedrock", + Model: "anthropic.claude-3-sonnet", + ThinkingBudget: &latest.ThinkingBudget{Tokens: 0}, + }, + expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Step 1: Apply provider defaults (simulating createDirectProvider flow) + result := applyProviderDefaults(tt.config, nil) + + // Verify thinking is still disabled after defaults (because explicit 0/none is preserved) + require.NotNil(t, result.ThinkingBudget, "ThinkingBudget should be set") + assert.True(t, result.ThinkingBudget.Tokens == 0 || result.ThinkingBudget.Effort == "none", + "ThinkingBudget should still be disabled after defaults") + + // Step 2: Apply override with thinking explicitly enabled (simulates /think toggle) + mo := options.ModelOptions{} + options.WithThinking(true)(&mo) + result = applyOverrides(result, &mo) + + // Verify defaults were restored + require.NotNil(t, result.ThinkingBudget, "ThinkingBudget should be set after enable override") + assert.Equal(t, tt.expectThinkingBudget.Tokens, result.ThinkingBudget.Tokens, "Tokens should match default") + assert.Equal(t, tt.expectThinkingBudget.Effort, result.ThinkingBudget.Effort, "Effort should match default") + }) + } +} + +// TestIsThinkingBudgetDisabled tests the helper function. +func TestIsThinkingBudgetDisabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + budget *latest.ThinkingBudget + expected bool + }{ + {"nil budget", nil, false}, + {"Tokens=0", &latest.ThinkingBudget{Tokens: 0}, true}, + {"Effort=none", &latest.ThinkingBudget{Effort: "none"}, true}, + {"Tokens=8192", &latest.ThinkingBudget{Tokens: 8192}, false}, + {"Effort=medium", &latest.ThinkingBudget{Effort: "medium"}, false}, + {"Tokens=-1 (dynamic)", &latest.ThinkingBudget{Tokens: -1}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, isThinkingBudgetDisabled(tt.budget)) + }) + } +} diff --git a/pkg/model/provider/provider.go b/pkg/model/provider/provider.go index 168fcaf71..65ba0457a 100644 --- a/pkg/model/provider/provider.go +++ b/pkg/model/provider/provider.go @@ -351,8 +351,13 @@ func applyOverrides(cfg *latest.ModelConfig, opts *options.ModelOptions) *latest // Create a copy to avoid modifying the original enhancedCfg := *cfg + t := opts.Thinking() + if t == nil { + return &enhancedCfg + } + // If thinking is explicitly disabled (e.g., via /think command), clear thinking configuration - if t := opts.Thinking(); t != nil && !*t { + if !*t { enhancedCfg.ThinkingBudget = nil if enhancedCfg.ProviderOpts != nil { delete(enhancedCfg.ProviderOpts, "interleaved_thinking") @@ -361,11 +366,40 @@ func applyOverrides(cfg *latest.ModelConfig, opts *options.ModelOptions) *latest "provider", cfg.Provider, "model", cfg.Model, ) + return &enhancedCfg + } + + // If thinking is explicitly enabled (e.g., via /think command) but the config has it + // disabled (Tokens == 0 or Effort == "none"), clear ThinkingBudget and re-apply defaults + // so the provider gets its standard thinking configuration. + if enhancedCfg.ThinkingBudget != nil && isThinkingBudgetDisabled(enhancedCfg.ThinkingBudget) { + enhancedCfg.ThinkingBudget = nil + applyModelDefaults(&enhancedCfg) + slog.Debug("Override: thinking enabled - restored default thinking configuration", + "provider", cfg.Provider, + "model", cfg.Model, + ) } return &enhancedCfg } +// isThinkingBudgetDisabled returns true if the thinking budget is explicitly disabled. +// NOT disabled when: +// - Tokens > 0 or Tokens == -1 (explicit token budget) +// - Effort is set to something other than "none" (e.g., "medium", "high") +func isThinkingBudgetDisabled(tb *latest.ThinkingBudget) bool { + if tb == nil { + return false + } + if tb.Effort == "none" { + return true + } + // Tokens == 0 with no Effort means explicitly disabled (thinking_budget: 0) + // Tokens == 0 with Effort set (e.g., "medium") means Effort-based config, not disabled + return tb.Tokens == 0 && tb.Effort == "" +} + // applyModelDefaults applies provider-specific default values for model configuration. // These defaults are applied only if the user hasn't explicitly set the values. // diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 034e70954..55c6f2cbb 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -500,6 +500,22 @@ func getAgentModelID(a *agent.Agent) string { return "" } +// isModelThinkingDisabled checks if the model's thinking configuration is explicitly disabled +// (thinking_budget: 0 or thinking_budget: none). +func isModelThinkingDisabled(model provider.Provider) bool { + if model == nil { + return false + } + tb := model.BaseConfig().ModelConfig.ThinkingBudget + if tb == nil { + return false + } + if tb.Effort == "none" { + return true + } + return tb.Tokens == 0 && tb.Effort == "" +} + // agentDetailsFromTeam converts team agent info to AgentDetails for events func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails { agentsInfo := r.team.AgentsInfo() @@ -754,6 +770,12 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c if !sess.Thinking { model = provider.CloneWithOptions(ctx, model, options.WithThinking(false)) slog.Debug("Cloned provider with thinking disabled", "agent", a.Name(), "model", model.ID()) + } else if isModelThinkingDisabled(model) { + // If thinking is enabled for this session but the model config has it disabled + // (e.g., thinking_budget: 0 or thinking_budget: none), clone with explicit enable + // so that applyOverrides restores provider defaults. + model = provider.CloneWithOptions(ctx, model, options.WithThinking(true)) + slog.Debug("Cloned provider with thinking enabled (restoring defaults)", "agent", a.Name(), "model", model.ID()) } modelID := model.ID()