Skip to content

fix(agent-core): bound the agent loop with a maxTurns cap - #176

Closed
jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-turn-cap
Closed

jasonkneen wants to merge 1 commit into
stepfun-ai:mainfrom
jasonkneen:fix/harness-turn-cap

Conversation

@jasonkneen

Copy link
Copy Markdown

Problem

runLoop in packages/agent-core/src/agent-loop.ts (~156-298) was a bare while (true) with no turn limit: a model stuck calling tools in a loop ran (and billed) forever. packages/coding-agent/src/step/stdio-host.ts accepted a maxTurns query 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_ATTEMPTS in step/permissions.ts); a single run could still spin unbounded.

Fix

agent-core

  • Added maxTurns?: number to AgentLoopConfig (types.ts) and to Agent/AgentOptions (agent.ts), forwarded through createLoopConfig. Undefined or 0 means 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 and turn_end/shouldStopAfterTurn have run, but before the steering queue is polled for the next turn. This guarantees:
    • the last completed turn's tool results are always in the transcript (no dangling tool call), and
    • a message already queued for steering/follow-up when the cap is hit stays queued instead of being silently drained and dropped - 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 own maxTurns budget.
  • agent_end gained an optional reason?: "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 as max_turns).
  • Propagated reason through packages/coding-agent's AgentSessionEvent/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

  • New setting maxTurnsPerPrompt (core/settings-manager.ts, DEFAULT_MAX_TURNS_PER_PROMPT = 200, 0 disables), following the existing httpIdleTimeoutMs getter/setter/validation pattern. Applied in core/sdk.ts where the Agent is constructed.
  • features/step.ts: on agent_end with reason === "max_turns", surfaces a warning notice ("Stopped: reached the maxTurnsPerPrompt limit for this prompt.") via the existing ctx.ui.notify channel.
  • step/permissions.ts: StepAutoResumeController.handleAgentEnd now treats a max_turns stop 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 another maxTurns turns.

stdio-host

  • #queryStart no longer warns that maxTurns is unenforced. It's applied to the Pi Agent for the duration of that query only: #installQueryBridges snapshots agent.maxTurns, overrides it from options.maxTurns when provided, and restores the previous value in its cleanup (mirroring the existing beforeToolCall/afterToolCall/tools snapshot-restore pattern), so a later query without maxTurns isn't silently capped by an earlier one.
  • #onSessionEvent now flags the active query when it sees agent_end with reason: "max_turns". #finishQuery reports 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: new describe("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".
    • Unlimited when maxTurns is unset (6 turns run to natural completion).
    • reason is omitted when the model stops on its own exactly on the capped turn.
    • Steering-queue poll count is pinned at 5 for a 3-turn capped run (1 initial + start/end of turns 1-2 + start of turn 3), proving there is no 6th poll after the last permitted turn that would have drained (and dropped) a newly-queued message.
    • Before the fix: running the "stops after exactly maxTurns" test against the unmodified loop hung indefinitely (vitest -t maxTurns had to be killed after timeout) because the always-tool-call model never let the bare while (true) exit.
  • packages/coding-agent/test/settings-manager.test.ts: new describe("maxTurnsPerPrompt", ...) - defaults to 200, global/project merge, setter, and invalid-value rejection. Modeled directly on the existing httpIdleTimeoutMs block.
  • packages/coding-agent/test/step-permissions.test.ts: new case asserting StepAutoResumeController does not schedule a continuation for a max_turns agent_end even when the last assistant message also happened to carry stopReason: "error".
    • Before the fix: expect(setTimer).not.toHaveBeenCalled() failed - setTimer was called once with 5000 (the controller scheduled a resume).
  • packages/coding-agent/test/step-stdio-host.test.ts: three new cases -
    • query.start options.maxTurns sets it on the Pi agent (no more "unenforced" warning) and it's restored to undefined once the query finishes via query.input_end.
      • Before the fix: expect(agent.maxTurns).toBe(5) failed with expected undefined to be 5.
    • A max_turns agent_end produces a result message with subtype: "error_max_turns", is_error: true (never "success").

Risk / behaviour change

  • Default behavior for existing callers of agentLoop/Agent is unchanged: maxTurns defaults to undefined at the agent-core layer (unlimited). coding-agent now applies a default cap of 200 turns per prompt/continue run via maxTurnsPerPrompt - 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.
  • A run that hits the cap returns before draining either the steering or follow-up queue. A message a user typed while the capped run was still going stays queued and is picked up by the next run (started via AgentSession._handlePostAgentRun's hasQueuedMessages() check), which gets its own fresh maxTurns budget - it is not lost, but it also does not extend the current run's budget.
  • stdio-host query result events can now report subtype: "error_max_turns" in addition to the existing "success"/"error_during_execution"; SDK clients that only branch on is_error are unaffected (is_error: true for this case too), but anything switching on subtype should treat this as a new value, not an unknown one.
  • step/permissions.ts::StepAutoResumeController.handleAgentEnd and agent-session.ts::_willRetryAfterAgentEnd were 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). _willRetryAfterAgentEnd was left unchanged - it already only retries on stopReason === "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 current main; whichever merges later needs a small rebase.

https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9

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".
Copilot AI lite review requested due to automatic review settings September 23, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

Open (2)
What changed in this PR

Adds configurable agent-loop turn limits across agent-core, coding-agent, and stdio interfaces.

Changes:

  • Adds maxTurns enforcement and max_turns event propagation.
  • Adds the maxTurnsPerPrompt setting 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 } : {}),
@ZouR-Ma ZouR-Ma closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants