DEV Community

Zhengxin
Zhengxin

Posted on

Claude Code Agent Loop Deep Dive (4): From a Finished Answer to the Seven Meanings of stop_reason

The first three articles explained the mechanics of a loop iteration: whether a tool may run, how hooks intervene, and how multiple calls are scheduled. This article looks at the other end of the loop: when does it stop?

The simplified shape from the opening article looks like this:

while True:
    response = call_llm(messages)
    if response.has_tool_use:
        execute_tools_and_append_results()
    else:
        break
Enter fullscreen mode Exit fullscreen mode

“Break when there is no tool use” is a useful sketch, but not the full story. Every model response also has a stop_reason: did the model finish, request a tool, hit an output limit, refuse, or exceed its context window? Each outcome may require a different response from the harness.

This post examines the seven stop reasons, the signal Claude Code actually trusts, recovery paths that keep the loop alive, and the safeguards that prevent it from running forever.

The complete stop_reason list

An Anthropic response can include one of the following stop reasons:

stop_reason Meaning
end_turn The model considers its response complete and yields the turn to the user.
tool_use The model emitted a tool_use request.
max_tokens The server stopped generation at the output-token limit.
stop_sequence Generation matched a caller-provided stop sequence.
refusal The model declined the request for safety reasons.
pause_turn The model asks for the current turn to be paused and resumed later.
model_context_window_exceeded The input exceeded the model's context-window limit.

There are seven values, but Claude Code does not treat them symmetrically.

The counterintuitive part: completion is not determined by stop_reason

The intuitive loop might look like this:

if stop_reason == 'end_turn':
    break
elif stop_reason == 'tool_use':
    execute_tools()
Enter fullscreen mode Exit fullscreen mode

Claude Code does not make the dispatch decision that way. Instead, it inspects the assistant message itself:

if response.content contains a tool_use block:
    execute_tools()
else:
    break
Enter fullscreen mode Exit fullscreen mode

Why? In practice, stop_reason === 'tool_use' is not a dependable indicator of the message structure. A response can contain a real tool-use block while reporting end_turn, or report tool_use without an actual tool_use block in its content.

The content is the source of truth. Claude Code scans the content array: a tool_use block means continue; no such block means the normal completion path. stop_reason is chiefly used to drive error and recovery UX—for example, explaining that output was truncated or that the model refused—not as the sole scheduling signal.

How each stop reason is handled

end_turn: normal completion

When the model finishes normally, the content contains no tool use. The loop takes its completed path and returns control to the user.

tool_use: look at the content, not the label

Claude Code does not depend on this label alone. If the response contains tool-use blocks, it executes them, appends matching results, and begins another model call.

max_tokens: recover before giving up

Reaching the output limit is treated as an error state with a recovery path:

  1. First, try increasing the max_tokens limit and retry.
  2. If output still hits the limit after escalation, append a user message such as [Output token limit hit, continue] so the model explicitly continues the interrupted work.
  3. Allow at most three recovery attempts (MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3).
  4. If recovery still fails, surface the problem to the user.

From the outer loop's perspective, this is not completion. It is unfinished work that should continue if the system can recover it.

model_context_window_exceeded: compact and retry

When the context is too large, Claude Code starts its reactive-compaction path: reduce the history, then retry. The result is another continuation branch rather than an immediate end state.

refusal: report, then stop

A refusal is different. Claude Code constructs an error message—often suggesting that the user try a different model through /model—but does not automatically retry. A model's deliberate refusal is not likely to be resolved by issuing the same request again, so the loop ends.

stop_sequence: normally falls through

Claude Code makes little use of this outcome because it does not normally configure custom stop sequences. It falls through to the content inspection described above; ordinarily there is no tool use, so the turn completes.

pause_turn: an unhandled gap in this version

In the v2.1.220 code examined for this series, the SDK recognizes pause_turn, but the Claude Code loop has no dedicated branch for it. That is a genuine implementation gap rather than a special recovery flow.

pause_turn is intended for exceptionally long or complex turns. Claude Code's primary loop usually consists of only a handful of calls per user request, which likely explains why it has not needed explicit handling. If long-running reasoning workflows are introduced, this branch will need an intentional policy.

Errors are recovery triggers, not just termination signals

The max_tokens and context-window paths reveal a broader design principle:

An error should trigger recovery whenever recovery is possible.

  • max_tokens triggers token-limit escalation, an explicit continuation prompt, and retry.
  • Context overflow triggers compaction and retry.
  • Overload or rate-limit conditions can trigger model fallback and retry.
  • Network failures can use exponential-backoff retry.

The objective is to repair what the system can repair and expose only genuinely unrecoverable failures to the user. At the state-machine level, each recovery is simply a different transition out of the current loop iteration.

maxTurns: the hard circuit breaker

With all those recovery routes, could the loop run forever? In theory, yes:

  • The model can repeatedly request tools without making progress.
  • Output-limit recovery can be triggered again after each increase.
  • Compaction can reduce history only for the model to overflow the context again immediately.

Claude Code has a hard safeguard: maxTurns. It counts model calls within one user-initiated loop. Once the count exceeds the configured limit, the system forcibly exits.

When this happens:

  • It appends [max_turns_reached] to the message history.
  • The SDK returns an { subtype: 'error_max_turns' } result.
  • The user receives a clear indication that the loop was stopped by its safety limit.

This matters because agent execution can proceed for many iterations without an intervening human decision. maxTurns ensures that the absence of supervision cannot become an infinite loop.

The Stop hook gets the final veto

Even after the loop sees no tool-use block and would otherwise complete, one last gate remains: the Stop hook.

As covered in Part 2: Hooks as Programmable Intervention Points, a Stop hook runs when the loop is about to finish. If it returns decision: block, the loop is not allowed to end; it must run another iteration.

This supports policies such as:

  • Do not finish until tests have been run.
  • Do not finish while relevant changes remain uncommitted.

A true completion therefore needs to clear three gates:

  1. The content contains no tool_use block.
  2. The stop reason does not require a recovery path.
  3. No Stop hook blocks completion.

Only then does the loop become completed and hand control back to the user.

The complete end-of-turn decision

Together, the branches can be summarized as follows:

After each model call:

if content contains a tool_use block:
    execute tools, append tool_results, and continue
elif stop_reason == 'max_tokens':
    recover output limit (escalate → inject continue → retry)
    give up after at most three recoveries
elif stop_reason == 'model_context_window_exceeded':
    run reactive compaction, then retry
elif stop_reason == 'refusal':
    create an error message and end the loop
elif turn_count > maxTurns:
    force an end via the max-turns safeguard
elif Stop hook returns block:
    prevent completion and run another iteration
else:
    complete the loop
Enter fullscreen mode Exit fullscreen mode

Seven API values therefore become five meaningful handling patterns. stop_sequence normally falls through to content inspection, while pause_turn has no dedicated loop-level treatment in the version analyzed here. The real decision is a combination of content, exceptional stop reasons, turn count, and hooks.

Takeaways

  • The practical completion criterion is whether the response content contains tool_use; stop_reason alone is not reliable enough for dispatch.
  • Anthropic exposes seven stop reasons: end_turn, tool_use, max_tokens, stop_sequence, refusal, pause_turn, and model_context_window_exceeded.
  • max_tokens and context overflow are recovery signals, not immediate termination.
  • A refusal ends the loop without automatic retry.
  • pause_turn has no dedicated handling in the Claude Code version analyzed here.
  • maxTurns is a hard limit on model calls within a single agent run.
  • A Stop hook can veto apparent completion and force another loop iteration.

The next article unifies the mechanisms from the first four posts—approval, hooks, parallel scheduling, stop-reason handling, and recovery—into one view of the QueryEngine main-loop state machine.


References

Primary source locations (v2.1.220):

  • src/services/api/claude.ts: stop_reason handling in the message_delta branch.
  • src/services/api/errors.ts: refusal detection through getErrorMessageIfRefusal.
  • src/query.ts: queryLoop, turn counting, maxTurns, and recovery branches.
  • src/query/stopHooks.ts: Stop-hook blocking logic.
  • src/QueryEngine.ts: the SDK's error_max_turns result type.

Related reading:

Top comments (0)