fix(agent-core): bound the agent loop with a maxTurns cap - #176
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: agent-loop turns must be bounded so a model stuck in a tool loop cannot run (and bill) forever; hitting the cap is a distinct terminal status, never "completed". Cause: runLoop was a bare `while (true)` with no turn limit, and stdio-host accepted a `maxTurns` query option but only warned that it was unenforced. Only autopilot resumes were capped separately. Fix: add optional maxTurns to AgentLoopConfig/AgentOptions/Agent (agent-core), stop the loop after the last completed turn's tool results are appended but before draining the steering/follow-up queues (so nothing dangles and nothing queued is silently dropped), and emit agent_end with reason: "max_turns" when the model still had work pending. Add a coding-agent maxTurnsPerPrompt setting (default 200), apply it where the Agent is constructed, surface a notice on cap, and stop autopilot auto-resume from treating a capped run as a transient failure. stdio-host now honors maxTurns for the active query only (scoped, restored on finish) and reports a max_turns stop as subtype "error_max_turns" instead of "success".
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved findings cover uncounted retry calls, invalid values bypassing cap validation, and missing settings documentation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Adds configurable agent-loop turn limits across agent-core, coding-agent, and stdio interfaces.
Changes:
- Adds
maxTurnsenforcement andmax_turnsevent propagation. - Adds the
maxTurnsPerPromptsetting with a default of 200. - Updates stdio results, notifications, auto-resume behavior, and tests.
| File | Reviewed changes |
|---|---|
packages/contracts/src/in-process/session-handle.ts |
Extends the host-facing session contract with the max-turn reason. |
packages/coding-agent/test/step-stdio-host.test.ts |
Tests query caps, restoration, and capped results. |
packages/coding-agent/test/step-permissions.test.ts |
Tests that capped runs do not auto-resume. |
packages/coding-agent/test/settings-manager.test.ts |
Tests max-turn setting behavior. |
packages/coding-agent/src/step/stdio-host.ts |
Applies query-scoped caps and reports error_max_turns. |
packages/coding-agent/src/step/permissions.ts |
Handles capped runs as clean stops. |
packages/coding-agent/src/features/step.ts |
Notifies users when the cap is reached. |
packages/coding-agent/src/core/settings-manager.ts |
Adds max-turn configuration and accessors. |
packages/coding-agent/src/core/sdk.ts |
Applies the configured cap to the agent. |
packages/coding-agent/src/core/extensions/types.ts |
Extends extension event typing. |
packages/coding-agent/src/core/agent-session.ts |
Propagates max-turn events through sessions. |
packages/agent-core/test/agent-loop.test.ts |
Tests capped, unlimited, and queued-turn behavior. |
packages/agent-core/src/types.ts |
Adds max-turn configuration and event fields. |
packages/agent-core/src/agent.ts |
Exposes and forwards maxTurns. |
packages/agent-core/src/agent-loop.ts |
Enforces turn limits and emits max-turn reasons. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+933
to
+935
| getMaxTurnsPerPrompt(): number { | ||
| return this.settings.maxTurnsPerPrompt ?? DEFAULT_MAX_TURNS_PER_PROMPT; | ||
| } |
| : {}), | ||
| ...(typeof options.model === "string" ? { model: options.model } : {}), | ||
| ...(typeof options.maxThinkingTokens === "number" ? { maxThinkingTokens: options.maxThinkingTokens } : {}), | ||
| ...(typeof options.maxTurns === "number" ? { maxTurns: options.maxTurns } : {}), |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
runLoopinpackages/agent-core/src/agent-loop.ts(~156-298) was a barewhile (true)with no turn limit: a model stuck calling tools in a loop ran (and billed) forever.packages/coding-agent/src/step/stdio-host.tsaccepted amaxTurnsquery option but only warned"maxTurns is not enforced by this adapter"(previously ~line 1435-1436) and never used it. Only autopilot resumes were capped (MAX_AUTO_RESUME_ATTEMPTSinstep/permissions.ts); a single run could still spin unbounded.Fix
agent-core
maxTurns?: numbertoAgentLoopConfig(types.ts) and toAgent/AgentOptions(agent.ts), forwarded throughcreateLoopConfig. Undefined or0means unlimited (library default unchanged).runLoop(agent-loop.ts) now counts assistant turns (LLM calls) started in the run (a "run" spans the outer follow-up-message loop, not just one prompt/continue call). The check sits after a turn's tool results are appended andturn_end/shouldStopAfterTurnhave run, but before the steering queue is polled for the next turn. This guarantees:AgentSession._handlePostAgentRun(agent-session.ts) already treats a non-empty queue as "needs a continuation", so a queued message gets picked up by a fresh run with its ownmaxTurnsbudget.agent_endgained an optionalreason?: "max_turns"field, set only when the model still had a pending tool call on the capped turn (a natural stop landing exactly on the boundary is a normal completion, not reported asmax_turns).reasonthroughpackages/coding-agent'sAgentSessionEvent/AgentEndEvent(agent-session.ts,core/extensions/types.ts) and the host-facing wire contract (packages/contracts/src/in-process/session-handle.ts), all as additive optional fields.coding-agent
maxTurnsPerPrompt(core/settings-manager.ts,DEFAULT_MAX_TURNS_PER_PROMPT = 200,0disables), following the existinghttpIdleTimeoutMsgetter/setter/validation pattern. Applied incore/sdk.tswhere theAgentis constructed.features/step.ts: onagent_endwithreason === "max_turns", surfaces a warning notice ("Stopped: reached the maxTurnsPerPrompt limit for this prompt.") via the existingctx.ui.notifychannel.step/permissions.ts:StepAutoResumeController.handleAgentEndnow treats amax_turnsstop as a clean stop (resets state, never schedules an autopilot continuation) instead of a transient model/transport failure - resuming would just re-run the same stuck loop for anothermaxTurnsturns.stdio-host
#queryStartno longer warns thatmaxTurnsis unenforced. It's applied to the PiAgentfor the duration of that query only:#installQueryBridgessnapshotsagent.maxTurns, overrides it fromoptions.maxTurnswhen provided, and restores the previous value in its cleanup (mirroring the existingbeforeToolCall/afterToolCall/tools snapshot-restore pattern), so a later query withoutmaxTurnsisn't silently capped by an earlier one.#onSessionEventnow flags the active query when it seesagent_endwithreason: "max_turns".#finishQueryreports that case as a distinct terminal result -subtype: "error_max_turns",is_error: true- never"success".Test
packages/agent-core/test/agent-loop.test.ts: newdescribe("maxTurns", ...)block with a faux model that always returns a tool call.maxTurns: 3→ exactly 3 assistant messages, every tool call has a matching tool result (none dangling),agent_end.reason === "max_turns".maxTurnsis unset (6 turns run to natural completion).reasonis omitted when the model stops on its own exactly on the capped turn.vitest -t maxTurnshad to be killed after timeout) because the always-tool-call model never let the barewhile (true)exit.packages/coding-agent/test/settings-manager.test.ts: newdescribe("maxTurnsPerPrompt", ...)- defaults to 200, global/project merge, setter, and invalid-value rejection. Modeled directly on the existinghttpIdleTimeoutMsblock.packages/coding-agent/test/step-permissions.test.ts: new case assertingStepAutoResumeControllerdoes not schedule a continuation for amax_turnsagent_end even when the last assistant message also happened to carrystopReason: "error".expect(setTimer).not.toHaveBeenCalled()failed -setTimerwas called once with5000(the controller scheduled a resume).packages/coding-agent/test/step-stdio-host.test.ts: three new cases -query.start options.maxTurnssets it on the Pi agent (no more "unenforced" warning) and it's restored toundefinedonce the query finishes viaquery.input_end.expect(agent.maxTurns).toBe(5)failed withexpected undefined to be 5.agent_endproduces aresultmessage withsubtype: "error_max_turns",is_error: true(never"success").Risk / behaviour change
agentLoop/Agentis unchanged:maxTurnsdefaults toundefinedat theagent-corelayer (unlimited).coding-agentnow applies a default cap of 200 turns per prompt/continue run viamaxTurnsPerPrompt- a very long-running single prompt that previously ran unbounded will now stop at 200 assistant turns and surface a warning notice; it can be raised or disabled (0) via settings.AgentSession._handlePostAgentRun'shasQueuedMessages()check), which gets its own freshmaxTurnsbudget - it is not lost, but it also does not extend the current run's budget.stdio-hostqueryresultevents can now reportsubtype: "error_max_turns"in addition to the existing"success"/"error_during_execution"; SDK clients that only branch onis_errorare unaffected (is_error: truefor this case too), but anything switching onsubtypeshould treat this as a new value, not an unknown one.step/permissions.ts::StepAutoResumeController.handleAgentEndandagent-session.ts::_willRetryAfterAgentEndwere the two candidate "treat as transient failure" call sites; only the former needed an explicit guard (its failure detection is message-content-based, not stopReason-gated)._willRetryAfterAgentEndwas left unchanged - it already only retries onstopReason === "error", which a max_turns stop cannot itself carry (an error-terminated turn returns from the loop immediately, before the turn counter is ever consulted), so it was safe by construction; not touched, per scope discipline.Merge note
This is one of five independent PRs that edit
packages/agent-core/src/agent-loop.ts(tool-result cap, strict tool args, leak-retry transcript, turn cap, agentLoop rejection). Each is based on currentmain; whichever merges later needs a small rebase.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9