Subagent `maxTurns` frontmatter is not honored (agent runs past the declared cap)
Environment: Claude Code CLI 2.1.214, Windows Server 2025, headless claude -p (also expected in interactive per shared agent loader).
Description: An agent defined in .claude/agents/*.md with maxTurns: 2 in frontmatter runs well past the cap. In our reproducible canary, a test agent instructed to execute 4 sequential echo commands completed all 4 (6 assistant turns total) with no truncation marker, despite maxTurns: 2.
Repro:
- Create
.claude/agents/turn-cap-test.mdwith frontmattermaxTurns: 2, tools Bash, instructions "run these 4 echo commands one per turn". - Invoke via Agent tool (or headless driver) and count assistant turns in the transcript.
- Expected: run stops after 2 turns (or reports the cap). Actual: 6 assistant turns, all 4 commands executed, exit normal.
Why it matters: maxTurns is the documented lever for bounding subagent cost; when it is silently ignored, cost-control policies built on it are no-ops. Silent non-enforcement is worse than absence — it reads as "covered" while enforcing nothing.
Note: on the same build, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE/window env overrides in headless also appear to be no-ops (0 compactions observed at ~125k context with WINDOW=25k, PCT=50) — possibly a related class of config-not-plumbed-through issues; happy to split into a separate issue if preferred.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
The
maxTurnssilence is a real problem because it turns a documented cost-control lever into a false guarantee. If the cap is ignored silently, any budget ceiling built on top of it is meaningless.A few things worth adding to the repro:
The frontmatter parser is case-sensitive on field names in some versions. Try both
maxTurnsandmax_turnsin your test agent and check whether either is actually read. If neither works, that confirms the field is parsed but not wired to the turn counter.The other thing to check: the turn count in the transcript versus what Claude Code is counting internally. The agent may be counting "assistant turns where it called a tool" versus "total assistant turns" differently. Six assistant turns on a 4-echo task suggests it may be counting tool result reads as separate turns.
The underlying ask -- that
maxTurnsactually gates the turn counter and rejects further turns with a clear signal rather than silently overrunning -- seems like the right fix. A hard cap that the agent cannot talk its way past is the only reliable cost control at the subagent level.The
maxTurnssilent-ignore is a real problem for cost-control. The whole point of that cap is that you set it, trust it, and do not babysit the session. When it silently runs past the declared limit you get a false sense of containment -- the config key exists but enforces nothing, which is worse than the key not existing.A few observations from running headless subagent workflows:
The silent non-enforcement pattern shows up in a few related places.
CLAUDE_AUTOCOMPACT_PCT_OVERRIDEnot taking effect in headless mode is the same class of issue -- a knob that signals nothing when you turn it. In a headless/unattended context these are effectively invisible until you look at the bill.On the specific
maxTurnscase: one thing worth checking in your repro is whether the count is tracking assistant turns vs. full round-trips. A few tools I have seen count them differently and the off-by-one compounds. But 2 declared vs. 6 actual is well beyond counting ambiguity -- that is non-enforcement.For what it is worth, the cost-control gaps in headless/subagent mode are the ones I hit most often in production. Rate limits, turn caps, and compaction triggers all behave differently unattended than they do interactively, and the difference tends to be invisible until you get the usage report. Agreed this one deserves the regression label.
Update from the reporter, with a repro on a newer build — the enforcement half of this looks fixed; the silent half does not.
Setup: CLI 2.1.217 (originally filed on 2.1.214), Windows Server 2025, headless
claude -pinvoking the agent via the Agent tool. Three agent definitions identical except for the cap field, each instructed to run four marker commands, one per turn. MeasuredtotalToolUseCountfrom the Task result payload; subagents resolved toclaude-sonnet-5.| frontmatter | runs | tool calls per run | completed the 4-step task? |
|---|---|---|---|
| (no cap — control) | 2 | 4, 4 | yes, 2/2 |
|
maxTurns: 2| 3 | 2, 2, 2 | never ||
max_turns: 2| 3 | 1, 4, 1 | yes, 1/3 |@kcarriedo — on your discriminator:
maxTurns(camelCase) is wired on 2.1.217. It stopped at exactly 2 in 3/3 runs while the identical uncapped control ran to 4 in 2/2.max_turns(snake_case) is not read — one run went the full 4. Since 2.1.214 gave me 6 turns againstmaxTurns: 2, something changed in between; happy to be told which release, and to close this if it was intentional.What is still broken — and is arguably the worse half of the original report: when the cap fires, it fires silently. The Task result comes back as
status: "completed"with partial work (content: [{"type":"text","text":"Turn 1 complete."}],totalToolUseCount: 2). The payload exposesstatus, prompt, agentId, agentType, content, resolvedModel, totalDurationMs, totalTokens, totalToolUseCount, usage, toolStats— and nothing that flags the cap as the reason for stopping.So the caller cannot distinguish a finished agent from a truncated one. For an unattended orchestrator that is the same failure mode as the original bug, just relocated: you set a bound, the bound now holds, and the truncated result is handed back to you labelled as success. It is also newly hazardous for anyone who set caps while they were no-ops — those caps are live now, and will quietly truncate.
Narrowed ask: surface cap-hit in the Task result —
status: "max_turns_exceeded", or a boolean next tototalToolUseCount. Enforcement without a signal still leaves downstream budget logic guessing.Also worth an explicit decision on whether
max_turnsshould be an accepted alias or rejected loudly at load time — silently ignoring an unrecognised frontmatter key is how this confusion started.(
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: not re-tested on 2.1.217. I will file that separately rather than keep it bundled here.)