fix(agents): the turn cap is a runaway guard derived from the wall clock, not a budget a good run hits — coding gets 270 turns in 45 minutes, and the write-up says why it stopped - #1020
Conversation
…ock, not a budget a good run hits — coding gets 270 turns in 45 minutes, and the write-up says why it stopped Every preset hand-set a `maxTurns` a busy run reached long before its minutes: at 20–40 s a turn, 60 turns is 20–40 minutes inside a 45-minute budget, and a coding run that had finished its work was cut off with "Hit the 60-turn budget". One rule now derives the cap from the wall clock — `RUNAWAY_TURNS_PER_MINUTE = 6`, `runawayTurnCap(maxMinutes)` — so a turn every ten seconds sustained for the whole budget, a loop, is what ends a run early, and the forced write-up says so: "Stopped after N model turns in M minutes — that pace looks like a loop". Ship keeps its structural 1. The `turn_budget_exhausted` kind, both loop conditions and the proxy's refusal are unchanged; the proxy and the friction proposer name the guard instead of a budget to raise. Specs, the how-to pages and the proposed record 0032 follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM: Clean, coherent change: maxTurns becomes a wall-clock-derived runaway guard with honest write-up wording; code, tests, specs and docs all move together, test-guard shows only spec-licensed renames.
- [nit] F1 src/config/profile.ts:68 — A budget-clipped run keeps the preset's full-clock maxTurns, so the 6-turns/min invariant holds only at preset defaults
Verdict: approve — a well-executed reframing of maxTurns from a hand-set backstop into a derived runaway guard, with code, tests, specs and docs moving in lockstep.
What I checked
- Full 26-file diff read (matches GitHub's +219/−86);
specs:coverage --test-guardrun: every changed source path has a covering spec, and all guard lines arecheck:renames explicitlyallowed byspec changes in the same diff — verification is intact and actually strengthened (the new registry tests pin the derivation rule for every preset, the runner tests pin the new label wording and empty-write-up fallback, the proxy test now asserts the response body too). runawayTurnCap/loopBudgetinsrc/agents/registry.tsare correct and every loop-running preset uses them;shipkeeps its structural 1,descriptionTurn's ad-hoc 8/5 is not anAGENTSpreset so the invariant test rightly doesn't cover it.- Runner finale (
src/runner.ts:739-770):elapsedMs = maxMinutes*60_000 - (deadline - now())is consistent with how the deadline is computed, including the resume path (elapsed across the run's whole budget) and clipped budgets (the runner reads thebudgetedAgentcopy, so minutes match the deadline basis).elapsedMinutesrounding matches the tests (2×40 s → "1 minute"). - Model proxy note/body wording, the dispatcher-test grant update (8→30), and the friction proposal's "never raise the cap" fix are all consistent with the updated
run-loop.mditem 1 /model-proxy.mditem 5. No spec contradictions — every touched spec was updated in the same diff.
Findings
- F1 (nit)
src/config/profile.ts:68—budgetedAgentreplacesmaxMinuteswith the effective (boundary- orbudget:-clipped) minutes but keeps the preset'smaxTurns, so a coding run clipped to 10 minutes still carries a 270-turn guard (~27 turns/min of its effective clock). Harmless — the wall clock ends the run first and the guard only fires later than the 6/min rule would suggest — and the spec phrases the invariant at the preset level, so this is an observation, not a contradiction. If you ever want the guard to track the effective clock,budgetedAgentis the one-line place.
No correctness bugs found; the change is safe to merge.
|
Review round 1 at dc41327 — LGTM, one finding. F1 (nit) — |
A coding run that had finished all three of its asks was cut off with "Hit the 60-turn budget before finishing" while twenty minutes of its wall clock remained. Turns are no longer a budget a working run can hit: the wall clock is the budget, the turn cap is a runaway guard derived from it (six turns a minute — coding gets 270 in 45 minutes), and when the guard does fire the write-up says it stopped because the pace looked like a loop.
What & why
The run-loop spec already said "wall clock is the real budget; turns are a backstop", but the registry hand-tuned each preset's
maxTurnsto a number a busy run reaches long before its minutes: at 20–40 s per turn (a model think plus a tool call), 60 turns is 20–40 minutes of work inside a 45-minute budget. The owner's report of 2026-09-14 was a coding run that had done everything it was asked and was still told it hit a budget — "these make people not want to use it".This PR makes the turn cap what the spec claimed it was. One rule in
src/agents/registry.ts:RUNAWAY_TURNS_PER_MINUTE = 6andrunawayTurnCap(maxMinutes) = maxMinutes × 6; every loop-running preset takesmaxTurnsfrom it throughloopBudget(minutes), so the cap follows the wall clock and is never set by hand again. A turn every ten seconds sustained for the whole budget is a retry loop, not work — so a run that reaches the cap first was looping, and the forced write-up now says so instead of "budget".maxTurnsbeforemaxTurnsaftercodingreviewresearchgeneralexploreconductorshipEverything downstream keeps its vocabulary: the loop's two conditions stay (the guard still ends the loop; the
× 2iteration cap still bounds bookkeeping turns), therun_notekind staysturn_budget_exhausted(the friction analyzer and the model proxy share it), the proxy still refuses model calls past the grant'smaxTurns— it now says "past the run's 270-turn guard". The friction proposer stops suggesting "raisemaxTurns" and points at the retry loop instead.Part of the orchestration program (#821); amends the
proposedrecord 0032 where it describedmaxTurnsas a budget (the trace's mechanism is unchanged: the proxy still refuses past the cap).Tour
1. The rule — six turns a minute, and the cap every preset derives from it
RUNAWAY_TURNS_PER_MINUTEnames the pace that marks a run as looping;runawayTurnCapmultiplies it by the wall clock;loopBudgethands a preset both numbers as one fact so the two can never drift apart.Look for: the comment's pacing argument — 20–40 s a turn is the observation the whole change rests on.
switchboard/src/agents/registry.ts
Lines 45 to 64 in dc41327
2.
maxTurnson the definition — a guard, not a budgetThe field's doc now tells a reader adding a preset what the number is and where it comes from.
switchboard/src/agents/registry.ts
Lines 72 to 76 in dc41327
3. The presets take their cap from the rule
Every loop-running preset spreads
loopBudget(minutes)in place of a hand-setmaxTurns/maxMinutespair; the per-preset tuning comments ("scoping is capped at ~5 calls…", "12 bound at ~4 min in practice") go with them. Coding is the one that mattered:switchboard/src/agents/registry.ts
Lines 432 to 442 in dc41327
4. Ship keeps its structural 1
The ship def never runs the loop —
agent:shipforks into the pipeline whose rounds run the coding and review defs — so itsmaxTurnsstays a placeholder, with the comment that already said so.switchboard/src/agents/registry.ts
Lines 464 to 483 in dc41327
5. The runner's finale says why it stopped
Both loop conditions are unchanged. When the loop ends without the deadline having passed, the guard fired: the note, the finale instruction to the model and the user-facing label all name the turns counted and the minutes elapsed and say the pace looked like a loop. Time exhaustion keeps its wording.
Look for:
elapsedMsis computed from the deadline, not a start stamp, so a resumed run reports the time spent across generations.switchboard/src/runner.ts
Lines 739 to 763 in dc41327
6. The two labels, and the minutes helper
The
⚠️ Hit the N-minute budget…sibling is byte-identical; the guard's label is⚠️ Stopped after N model turns in M minutes — that pace looks like a loop; findings so far:, and its empty-write-up fallback says the same reason.switchboard/src/runner.ts
Lines 764 to 780 in dc41327
7. The proxy names the guard
The
turn_budget_exhaustedkind and the 403 stay; the note and the response body read "past the run's N-turn guard" instead of "N-turn budget".switchboard/src/channels/modelProxy.ts
Lines 516 to 531 in dc41327
8. The friction proposer stops saying "raise maxTurns"
budget_hit:turnsnow says the run outpaced the runaway guard, quotes the rate from the constant, and sends the reader to the retry loop or to batching. The time-budget suggestion is unchanged.switchboard/src/core/frictionProposals.ts
Lines 623 to 626 in dc41327
9. Tests: the rule is pinned, not the numbers
Every preset but
shipmust satisfymaxTurns === runawayTurnCap(maxMinutes); the derived values are asserted once so a wall-clock change shows up in the diff. The three per-preset tests drop their turn counts and keep the minutes.switchboard/src/agents/registry.test.ts
Lines 96 to 121 in dc41327
10. Tests: the write-up's wording, with a clock that advances
The finale test drives a 40 s-per-call clock so the label counts real minutes, asserts the exact prefix, that the word "budget" is absent, and that the finale instruction names the guard and the pace; a second test pins the empty-write-up fallback.
switchboard/src/runner.test.ts
Lines 235 to 266 in dc41327
11. Tests: the proposer and the proxy
The proposer's suggestion must quote the rate, name the runaway guard, mention batching and the retry loop, and never say "raise
maxTurns"; the proxy's note and body carry the new wording.switchboard/src/core/frictionProposals.test.ts
Lines 441 to 463 in dc41327
switchboard/src/channels/modelProxy.test.ts
Lines 601 to 621 in dc41327
12. The run-loop spec: item 1 is the rule, item 3 the wording
Item 1 states the rule, the number, the derived caps and why; item 3 quotes both labels and says the note kinds are unchanged; item 5 lists wall clocks only. New proof rows bind the registry rule test and the two runner tests.
switchboard/docs/reference/specs/run-loop.md
Lines 11 to 13 in dc41327
13. The model-proxy spec, item 5
The example note and the count read "past the run's 270-turn guard"; the kind stays.
switchboard/docs/reference/specs/model-proxy.md
Line 15 in dc41327
14. Record 0032, amended
The
proposedrecord describedmaxTurnsas a budget in its facts table, the proxy paragraph and the presets row; each now says it is the derived guard.decisions:checkpasses (an accepted record's body is frozen; a proposed one may be amended).switchboard/docs/decisions/0032-pi-is-the-harness-the-native-loop-retires.md
Line 26 in dc41327
15. Remaining changes
docs/explanation/agents-and-toolsets.md— the Budget column lists wall clocks; a paragraph explains the derived cap and the label a user seesdocs/reference/specs/agent-coding.md,agent-review.md,agent-general.md,agent-explore.md,agent-conductor.md,agent-ship.md,github-tools.md— Budgets lines quote the wall clock and the derived guard; proof rows follow the renamed registry testsdocs/reference/specs/self-improvement.md— thebudget_hit:turnssuggestion summarydocs/reference/specs/README.md,docs/reference/code-map.md— "turn budget" → "turn guard" in the model-proxy rowsdocs/how-to/add-an-agent.md— the example preset usesloopBudget(10); a sentence says the cap is never set by handdocs/how-to/operate-production.md— the probe's403 turn_budget_exhaustedline names the guardsrc/core/descriptionTurn.ts— a comment that quoted "60 turns / 45 min" no longer does (the 8-turn clip stays: a bounded bookkeeping turn, not the budget)src/runner.tsheader and loop comments — "turn budget" → "turn guard"src/core/dispatcher.test.ts— the run bearer test pins the general preset's grant to its derived guard (30) instead of 8Decisions
maxTurnscarried a comment justifying its number, and every number was wrong for a busy run. One rule tied to the wall clock cannot drift from it, and a test pins the rule rather than the values.turn_budget_exhaustedis shared by the runner, the model proxy (docs/reference/specs/model-proxy.mditem 5), the friction analyzer'sbudget_hitcategory and the ledger's records; renaming it would touch every consumer for a word. The wording a person reads changed; the type an analyzer reads did not.runAgent; the pipeline's rounds run the coding and review defs with their own derived caps, clipped to the pipeline's remaining wall clock.Validation
shiphasmaxTurns === runawayTurnCap(maxMinutes); the constant is 6; the derived caps are 270 / 150 / 48 / 30 / 720 / 720;shipkeeps 1[unit]src/agents/registry.test.ts::the turn cap is a runaway guard derived from the wall clock (docs/reference/specs/run-loop.md item 1)::*(3)RUNAWAY_TURNS_PER_MINUTEundefined,coding.maxTurns60 ≠ 270); green after⚠️ _Stopped after 2 model turns in 1 minute — that pace looks like a loop; findings so far:_, no "budget" in the answer, the finale instruction names the guard and the pace, no tools offered; the empty write-up still says the reason[unit]src/runner.test.ts::forces a write-up that says the turn guard fired…turns run out,::the turn guard's empty write-up still says why the run stopped2-turn budgetlabel; green after[unit]src/runner.test.ts::labels the write-up with the minute budget…,::emits time_budget_exhausted when the wall clock ran outrun_notekinds are unchanged:turn_budget_exhaustedafter the guard,time_budget_exhaustedafter the deadline[unit]src/runner.test.ts::emits a run_note for the wrap-up warning and for turn-budget exhaustion;src/core/runFriction.test.ts::budget_hit: time- and turn-budget exhaustion notes are high-severity budget hitsmaxTurnswith the kind unchanged and the note / body naming the guard[unit]src/channels/modelProxy.test.ts::the turn guard — a refusal is a typed run event::*budget_hit:turnsfix quotes6 turns a minute, names the runaway guard, batching and the retry loop, never "raisemaxTurns"[unit]src/core/frictionProposals.test.ts::…::budget_hit:turns names the runaway guard's pace…maxTurns"); green after[unit]src/core/descriptionTurn.test.ts(unchanged, green)npm run specs:check—44 spec(s), 2733 proof reference(s) checked;npm run specs:coverage -- --changed --test-guard—every changed source path has a covering spec,test-guard okconsistencylegnpm run decisions:check—45 record(s) carry a valid status … accepted bodies unchanged against origin/mainconsistencylegnpm run verify— exit 0 locally at dc41327 (bot 373 files / 6817 tests passed, every workspace green)npm run check:pr-title— okNo visual change.
🤖 Generated with Claude Code