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
“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()
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
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:
- First, try increasing the
max_tokenslimit and retry. - 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. - Allow at most three recovery attempts (
MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3). - 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_tokenstriggers 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:
- The content contains no
tool_useblock. - The stop reason does not require a recovery path.
- 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
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_reasonalone is not reliable enough for dispatch. - Anthropic exposes seven stop reasons:
end_turn,tool_use,max_tokens,stop_sequence,refusal,pause_turn, andmodel_context_window_exceeded. -
max_tokensand context overflow are recovery signals, not immediate termination. - A refusal ends the loop without automatic retry.
-
pause_turnhas no dedicated handling in the Claude Code version analyzed here. -
maxTurnsis 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_reasonhandling in themessage_deltabranch. -
src/services/api/errors.ts: refusal detection throughgetErrorMessageIfRefusal. -
src/query.ts:queryLoop, turn counting,maxTurns, and recovery branches. -
src/query/stopHooks.ts: Stop-hook blocking logic. -
src/QueryEngine.ts: the SDK'serror_max_turnsresult type.
Related reading:
Top comments (0)