Skip to content

Commit 9e609c5

Browse files
authored
feat(exec): default headless runs to the turn-budget ceiling (#1049)
* feat(config): report whether the tool-turn budget was explicitly set ResolvedConfig.MaxTurns is ambiguous today: a configured 80 and the built-in default 80 are indistinguishable, so a caller that wants a different default cannot tell "user chose this" from "nobody set it". FileConfig now records at each merge gate (config files, provider command, ZERO_MAX_TURNS, CLI overrides) when a source supplies a positive maxTurns, and Resolve surfaces it as MaxTurnsSet. * feat(exec): default headless runs to the turn-budget ceiling The shared 80-turn default is sized for the TUI, where the user can say "continue" and the run resumes. Headless exec has nobody to do that, so benchmark evals truncated at 80 turns mid-implementation. An unset budget now resolves to MaxTurnsCeiling for exec only; any explicit source (flag, mode preset, ZERO_MAX_TURNS, config file) and exec-profile budgets keep their precedence. The TUI default is unchanged. * test(cli,config): cover the headless turn-budget default * refactor(cli): extract the headless turn-budget decision
1 parent 2f53117 commit 9e609c5

5 files changed

Lines changed: 103 additions & 4 deletions

File tree

‎internal/cli/exec.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ const (
4949
exitInterrupted = 130
5050
)
5151

52+
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
53+
// An interactive run can be continued past the shared interactive default by the
54+
// user; an exec run has nobody to unstick it, so evals and automation would
55+
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
56+
// still finite against a genuinely runaway loop, which the loop's empty-turn
57+
// guard also cuts independently of this budget.
58+
const defaultExecMaxTurns = config.MaxTurnsCeiling
59+
5260
type execOutputFormat string
5361
type execInputFormat string
5462

@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
320328
}
321329
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
322330
}
331+
resolved.MaxTurns = execTurnBudget(resolved)
323332
var displacedMaxTurns int
324333
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
325334
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
@@ -1222,6 +1231,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
12221231
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
12231232
}
12241233

1234+
// execTurnBudget applies the headless default to the resolved turn budget: an
1235+
// interactive run can be continued past the shared default by the user, but an
1236+
// exec run has nobody to unstick it, so an unconfigured budget starts at the
1237+
// documented ceiling instead of truncating mid-task. Explicit sources
1238+
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
1239+
// MaxTurnsSet and pass through; an exec-profile budget still wins via
1240+
// applyProfileTurnBudget afterward.
1241+
func execTurnBudget(resolved config.ResolvedConfig) int {
1242+
if resolved.MaxTurnsSet {
1243+
return resolved.MaxTurns
1244+
}
1245+
return defaultExecMaxTurns
1246+
}
1247+
12251248
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
12261249
// The profile displaces the RESOLVED value, not the flag: a pinned budget
12271250
// always wins and the profile backs off entirely, while an env/config budget

‎internal/cli/exec_test.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
19751975
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
19761976
}
19771977
}
1978+
1979+
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
1980+
cases := []struct {
1981+
name string
1982+
resolved config.ResolvedConfig
1983+
want int
1984+
}{
1985+
// A headless run has no user to continue past the shared interactive
1986+
// default, so an unconfigured budget starts at the ceiling.
1987+
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
1988+
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
1989+
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
1990+
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
1991+
}
1992+
for _, tc := range cases {
1993+
if got := execTurnBudget(tc.resolved); got != tc.want {
1994+
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
1995+
}
1996+
}
1997+
}

‎internal/config/resolver.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
166166
Providers: providers,
167167
Provider: active,
168168
MaxTurns: cfg.MaxTurns,
169+
MaxTurnsSet: cfg.maxTurnsSet,
169170
MCP: cfg.MCP,
170171
Sandbox: cfg.Sandbox,
171172
Notify: cfg.Notify,
@@ -234,6 +235,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
234235
}
235236
if src.MaxTurns > 0 {
236237
dst.MaxTurns = src.MaxTurns
238+
dst.maxTurnsSet = true
237239
}
238240
for _, provider := range src.Providers {
239241
mergeProvider(dst, provider)
@@ -294,6 +296,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
294296
}
295297
if src.MaxTurns > 0 {
296298
dst.MaxTurns = src.MaxTurns
299+
dst.maxTurnsSet = true
297300
}
298301
for _, provider := range src.Providers {
299302
candidate := providerMergeCandidate(*dst, provider)
@@ -606,6 +609,7 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
606609
n = MaxTurnsCeiling
607610
}
608611
cfg.MaxTurns = n
612+
cfg.maxTurnsSet = true
609613
}
610614
}
611615

@@ -726,6 +730,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
726730
}
727731
if overrides.MaxTurns > 0 {
728732
cfg.MaxTurns = overrides.MaxTurns
733+
cfg.maxTurnsSet = true
729734
}
730735
if overrides.Sandbox.Enabled != nil {
731736
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled

‎internal/config/resolver_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2252,3 +2252,45 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
22522252
t.Fatalf("error = %v", err)
22532253
}
22542254
}
2255+
2256+
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
2257+
const providerJSON = `{
2258+
"activeProvider": "p",
2259+
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
2260+
}`
2261+
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
2262+
2263+
cases := []struct {
2264+
name string
2265+
opts ResolveOptions
2266+
set bool
2267+
}{
2268+
{"built-in default only", ResolveOptions{
2269+
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
2270+
}, false},
2271+
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
2272+
{"project config", ResolveOptions{
2273+
UserConfigPath: writeConfig(t, providerJSON),
2274+
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
2275+
}, true},
2276+
{"env", ResolveOptions{
2277+
Env: map[string]string{MaxTurnsEnv: "120"},
2278+
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
2279+
}, true},
2280+
{"cli override", ResolveOptions{
2281+
Overrides: Overrides{
2282+
MaxTurns: 42,
2283+
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
2284+
},
2285+
}, true},
2286+
}
2287+
for _, tc := range cases {
2288+
resolved, err := Resolve(tc.opts)
2289+
if err != nil {
2290+
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
2291+
}
2292+
if resolved.MaxTurnsSet != tc.set {
2293+
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
2294+
}
2295+
}
2296+
}

‎internal/config/types.go‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,10 @@ type FileConfig struct {
364364
LocalControl LocalControlConfig `json:"localControl,omitempty"`
365365
STT STTConfig `json:"stt,omitempty"`
366366
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
367+
// maxTurnsSet records that some merge source supplied a positive maxTurns —
368+
// i.e. the value is configured, not the built-in default. Unexported like
369+
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
370+
maxTurnsSet bool
367371
// Extra preserves top-level fields written by newer Zero versions or
368372
// extensions so a read-modify-write through this version is non-destructive.
369373
Extra map[string]json.RawMessage `json:"-"`
@@ -461,10 +465,15 @@ type Overrides struct {
461465
}
462466

463467
type ResolvedConfig struct {
464-
ActiveProvider string
465-
Providers []ProviderProfile
466-
Provider ProviderProfile
467-
MaxTurns int
468+
ActiveProvider string
469+
Providers []ProviderProfile
470+
Provider ProviderProfile
471+
MaxTurns int
472+
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
473+
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
474+
// than the built-in default — callers that need a different default can
475+
// tell the two apart.
476+
MaxTurnsSet bool
468477
MCP MCPConfig
469478
Sandbox SandboxConfig
470479
Notify NotifyConfig

0 commit comments

Comments
 (0)