Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions cagent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions pkg/model/provider/override_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}
36 changes: 35 additions & 1 deletion pkg/model/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
//
Expand Down
22 changes: 22 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down