Skip to content

Commit b4dd5de

Browse files
committed
disable thinking via yaml with thinking_budget: 0 or none
Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent da741b0 commit b4dd5de

File tree

6 files changed

+207
-4
lines changed

6 files changed

+207
-4
lines changed

cagent-schema.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,26 +459,29 @@
459459
"description": "Whether to track usage"
460460
},
461461
"thinking_budget": {
462-
"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.",
462+
"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.",
463463
"oneOf": [
464464
{
465465
"type": "string",
466466
"enum": [
467+
"none",
467468
"minimal",
468469
"low",
469470
"medium",
470471
"high"
471472
],
472-
"description": "Reasoning effort level (OpenAI, Gemini 3)"
473+
"description": "Reasoning effort level (OpenAI, Gemini 3). Use 'none' to disable thinking."
473474
},
474475
{
475476
"type": "integer",
476477
"minimum": -1,
477478
"maximum": 32768,
478-
"description": "Token budget for extended thinking (Anthropic, Bedrock Claude, Gemini 2.5)"
479+
"description": "Token budget for extended thinking (Anthropic, Bedrock Claude, Gemini 2.5). Use 0 to disable thinking."
479480
}
480481
],
481482
"examples": [
483+
"none",
484+
0,
482485
"minimal",
483486
"low",
484487
"medium",

cmd/root/run.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,17 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes
310310

311311
slog.Debug("Loaded existing session", "session_id", f.sessionID, "agent", f.agentName)
312312
} else {
313+
thinking := true
314+
if tb := agent.Model().BaseConfig().ModelConfig.ThinkingBudget; tb != nil {
315+
if tb.Effort == "none" || (tb.Tokens == 0 && tb.Effort == "") {
316+
thinking = false
317+
}
318+
}
313319
sess = session.New(
314320
session.WithMaxIterations(agent.MaxIterations()),
315321
session.WithToolsApproved(f.autoApprove),
316322
session.WithHideToolResults(f.hideToolResults),
323+
session.WithThinking(thinking),
317324
)
318325
// Session is stored lazily on first UpdateSession call (when content is added)
319326
// This avoids creating empty sessions in the database

docs/USAGE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,32 @@ models:
284284
285285
Determine how much the model should think by setting the `thinking_budget`
286286

287+
- **Disable thinking**: Use `none` (string) or `0` (integer) to explicitly disable thinking for any provider
287288
- **OpenAI**: use effort levels — `minimal`, `low`, `medium`, `high`. Default: `medium`
288289
- **Anthropic**: set an integer token budget. Range is 1024–32768; must be strictly less than `max_tokens`. Default: `8192` with `interleaved_thinking: true`
289290
- **Google (Gemini 2.5)**: set an integer token budget. `0` -> disable thinking, `-1` -> dynamic thinking (model decides). Default: `-1` (dynamic)
290291
- **Google (Gemini 3)**: use effort levels — `minimal` (Flash only), `low`, `medium`, `high`. Default: `high` for Pro, `medium` for Flash
291292
- **Amazon Bedrock (Claude models)**: set an integer token budget, same as Anthropic. Default: `8192` with `interleaved_thinking: true`
292293

294+
**Disabling thinking:**
295+
296+
```yaml
297+
models:
298+
# Using string "none"
299+
gpt-no-thinking:
300+
provider: openai
301+
model: gpt-5
302+
thinking_budget: none # or 0
303+
304+
# Using integer 0
305+
claude-no-thinking:
306+
provider: anthropic
307+
model: claude-sonnet-4-5
308+
thinking_budget: 0 # or none
309+
```
310+
311+
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.
312+
293313
Examples (OpenAI):
294314

295315
```yaml

pkg/model/provider/override_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,120 @@ func TestApplyOverrides_DoesNotModifyOriginal(t *testing.T) {
341341
// Result should have changes
342342
assert.Nil(t, result.ThinkingBudget, "Result ThinkingBudget should be nil")
343343
}
344+
345+
// TestApplyOverrides_EnableFromDisabled tests that explicitly enabling thinking when
346+
// the config has it disabled (Tokens=0 or Effort="none") restores provider defaults.
347+
// This is the key behavior that makes /think work when YAML starts with thinking_budget: 0/none.
348+
func TestApplyOverrides_EnableFromDisabled(t *testing.T) {
349+
t.Parallel()
350+
351+
tests := []struct {
352+
name string
353+
config *latest.ModelConfig
354+
expectThinkingBudget *latest.ThinkingBudget
355+
}{
356+
{
357+
name: "Anthropic: enable from Tokens=0 restores default 8192",
358+
config: &latest.ModelConfig{
359+
Provider: "anthropic",
360+
Model: "claude-sonnet-4-0",
361+
ThinkingBudget: &latest.ThinkingBudget{Tokens: 0},
362+
},
363+
expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192},
364+
},
365+
{
366+
name: "Anthropic: enable from Effort=none restores default 8192",
367+
config: &latest.ModelConfig{
368+
Provider: "anthropic",
369+
Model: "claude-sonnet-4-0",
370+
ThinkingBudget: &latest.ThinkingBudget{Effort: "none"},
371+
},
372+
expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192},
373+
},
374+
{
375+
name: "OpenAI: enable from Tokens=0 restores default medium",
376+
config: &latest.ModelConfig{
377+
Provider: "openai",
378+
Model: "gpt-4o",
379+
ThinkingBudget: &latest.ThinkingBudget{Tokens: 0},
380+
},
381+
expectThinkingBudget: &latest.ThinkingBudget{Effort: "medium"},
382+
},
383+
{
384+
name: "OpenAI: enable from Effort=none restores default medium",
385+
config: &latest.ModelConfig{
386+
Provider: "openai",
387+
Model: "gpt-4o",
388+
ThinkingBudget: &latest.ThinkingBudget{Effort: "none"},
389+
},
390+
expectThinkingBudget: &latest.ThinkingBudget{Effort: "medium"},
391+
},
392+
{
393+
name: "Gemini 2.5: enable from Tokens=0 restores default -1 (dynamic)",
394+
config: &latest.ModelConfig{
395+
Provider: "google",
396+
Model: "gemini-2.5-flash",
397+
ThinkingBudget: &latest.ThinkingBudget{Tokens: 0},
398+
},
399+
expectThinkingBudget: &latest.ThinkingBudget{Tokens: -1},
400+
},
401+
{
402+
name: "Bedrock Claude: enable from Tokens=0 restores default 8192",
403+
config: &latest.ModelConfig{
404+
Provider: "amazon-bedrock",
405+
Model: "anthropic.claude-3-sonnet",
406+
ThinkingBudget: &latest.ThinkingBudget{Tokens: 0},
407+
},
408+
expectThinkingBudget: &latest.ThinkingBudget{Tokens: 8192},
409+
},
410+
}
411+
412+
for _, tt := range tests {
413+
t.Run(tt.name, func(t *testing.T) {
414+
t.Parallel()
415+
416+
// Step 1: Apply provider defaults (simulating createDirectProvider flow)
417+
result := applyProviderDefaults(tt.config, nil)
418+
419+
// Verify thinking is still disabled after defaults (because explicit 0/none is preserved)
420+
require.NotNil(t, result.ThinkingBudget, "ThinkingBudget should be set")
421+
assert.True(t, result.ThinkingBudget.Tokens == 0 || result.ThinkingBudget.Effort == "none",
422+
"ThinkingBudget should still be disabled after defaults")
423+
424+
// Step 2: Apply override with thinking explicitly enabled (simulates /think toggle)
425+
mo := options.ModelOptions{}
426+
options.WithThinking(true)(&mo)
427+
result = applyOverrides(result, &mo)
428+
429+
// Verify defaults were restored
430+
require.NotNil(t, result.ThinkingBudget, "ThinkingBudget should be set after enable override")
431+
assert.Equal(t, tt.expectThinkingBudget.Tokens, result.ThinkingBudget.Tokens, "Tokens should match default")
432+
assert.Equal(t, tt.expectThinkingBudget.Effort, result.ThinkingBudget.Effort, "Effort should match default")
433+
})
434+
}
435+
}
436+
437+
// TestIsThinkingBudgetDisabled tests the helper function.
438+
func TestIsThinkingBudgetDisabled(t *testing.T) {
439+
t.Parallel()
440+
441+
tests := []struct {
442+
name string
443+
budget *latest.ThinkingBudget
444+
expected bool
445+
}{
446+
{"nil budget", nil, false},
447+
{"Tokens=0", &latest.ThinkingBudget{Tokens: 0}, true},
448+
{"Effort=none", &latest.ThinkingBudget{Effort: "none"}, true},
449+
{"Tokens=8192", &latest.ThinkingBudget{Tokens: 8192}, false},
450+
{"Effort=medium", &latest.ThinkingBudget{Effort: "medium"}, false},
451+
{"Tokens=-1 (dynamic)", &latest.ThinkingBudget{Tokens: -1}, false},
452+
}
453+
454+
for _, tt := range tests {
455+
t.Run(tt.name, func(t *testing.T) {
456+
t.Parallel()
457+
assert.Equal(t, tt.expected, isThinkingBudgetDisabled(tt.budget))
458+
})
459+
}
460+
}

pkg/model/provider/provider.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,13 @@ func applyOverrides(cfg *latest.ModelConfig, opts *options.ModelOptions) *latest
351351
// Create a copy to avoid modifying the original
352352
enhancedCfg := *cfg
353353

354+
t := opts.Thinking()
355+
if t == nil {
356+
return &enhancedCfg
357+
}
358+
354359
// If thinking is explicitly disabled (e.g., via /think command), clear thinking configuration
355-
if t := opts.Thinking(); t != nil && !*t {
360+
if !*t {
356361
enhancedCfg.ThinkingBudget = nil
357362
if enhancedCfg.ProviderOpts != nil {
358363
delete(enhancedCfg.ProviderOpts, "interleaved_thinking")
@@ -361,11 +366,40 @@ func applyOverrides(cfg *latest.ModelConfig, opts *options.ModelOptions) *latest
361366
"provider", cfg.Provider,
362367
"model", cfg.Model,
363368
)
369+
return &enhancedCfg
370+
}
371+
372+
// If thinking is explicitly enabled (e.g., via /think command) but the config has it
373+
// disabled (Tokens == 0 or Effort == "none"), clear ThinkingBudget and re-apply defaults
374+
// so the provider gets its standard thinking configuration.
375+
if enhancedCfg.ThinkingBudget != nil && isThinkingBudgetDisabled(enhancedCfg.ThinkingBudget) {
376+
enhancedCfg.ThinkingBudget = nil
377+
applyModelDefaults(&enhancedCfg)
378+
slog.Debug("Override: thinking enabled - restored default thinking configuration",
379+
"provider", cfg.Provider,
380+
"model", cfg.Model,
381+
)
364382
}
365383

366384
return &enhancedCfg
367385
}
368386

387+
// isThinkingBudgetDisabled returns true if the thinking budget is explicitly disabled.
388+
// NOT disabled when:
389+
// - Tokens > 0 or Tokens == -1 (explicit token budget)
390+
// - Effort is set to something other than "none" (e.g., "medium", "high")
391+
func isThinkingBudgetDisabled(tb *latest.ThinkingBudget) bool {
392+
if tb == nil {
393+
return false
394+
}
395+
if tb.Effort == "none" {
396+
return true
397+
}
398+
// Tokens == 0 with no Effort means explicitly disabled (thinking_budget: 0)
399+
// Tokens == 0 with Effort set (e.g., "medium") means Effort-based config, not disabled
400+
return tb.Tokens == 0 && tb.Effort == ""
401+
}
402+
369403
// applyModelDefaults applies provider-specific default values for model configuration.
370404
// These defaults are applied only if the user hasn't explicitly set the values.
371405
//

pkg/runtime/runtime.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,22 @@ func getAgentModelID(a *agent.Agent) string {
500500
return ""
501501
}
502502

503+
// isModelThinkingDisabled checks if the model's thinking configuration is explicitly disabled
504+
// (thinking_budget: 0 or thinking_budget: none).
505+
func isModelThinkingDisabled(model provider.Provider) bool {
506+
if model == nil {
507+
return false
508+
}
509+
tb := model.BaseConfig().ModelConfig.ThinkingBudget
510+
if tb == nil {
511+
return false
512+
}
513+
if tb.Effort == "none" {
514+
return true
515+
}
516+
return tb.Tokens == 0 && tb.Effort == ""
517+
}
518+
503519
// agentDetailsFromTeam converts team agent info to AgentDetails for events
504520
func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails {
505521
agentsInfo := r.team.AgentsInfo()
@@ -754,6 +770,12 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
754770
if !sess.Thinking {
755771
model = provider.CloneWithOptions(ctx, model, options.WithThinking(false))
756772
slog.Debug("Cloned provider with thinking disabled", "agent", a.Name(), "model", model.ID())
773+
} else if isModelThinkingDisabled(model) {
774+
// If thinking is enabled for this session but the model config has it disabled
775+
// (e.g., thinking_budget: 0 or thinking_budget: none), clone with explicit enable
776+
// so that applyOverrides restores provider defaults.
777+
model = provider.CloneWithOptions(ctx, model, options.WithThinking(true))
778+
slog.Debug("Cloned provider with thinking enabled (restoring defaults)", "agent", a.Name(), "model", model.ID())
757779
}
758780

759781
modelID := model.ID()

0 commit comments

Comments
 (0)