There is a version of the agent loop that works in the demo and fails in production, and the difference is one line. The demo loop says while resp.stop_reason == "tool_use". It runs the tools, gets a final answer, exits — beautiful. Then it meets a real task, the model's answer gets truncated at the token ceiling, stop_reason comes back max_tokens, the while condition is false, and your loop cheerfully returns a half-finished response as if the work were done.
stop_reason is the field your agentic loop hinges on, and Claude returns six values for it. Most loops handle two. The other four are exactly the ones that surface only under load — long outputs, long-running tools, safety edges — which is why they slip through every happy-path test and then page you.
The two you already handle#
tool_use and end_turn are the spine. tool_use means the model wants tools: run them, append the results, loop again. end_turn means it's finished and produced its final answer: break, return the text. If those were the only two possibilities, the demo loop would be right. They aren't.
The four that bite#
max_tokens — the output was cut off. This is the dangerous one, because it looks like content. Generation hit the max_tokens ceiling and stopped mid-sentence — or worse, mid-tool_use. A truncated tool call has an incomplete input object: the JSON never closed, so it won't parse, and you cannot construct a valid tool_result for it. Executing it or sending it back is how you get a cascade of downstream errors that look like a tool bug but are really a truncation bug. Detect the stop explicitly, then either re-issue with a higher max_tokens or ask the model to continue — and only then execute the now-complete call. Never let a max_tokens turn fall through as "done."
stop_sequence — your own boundary fired. If you passed custom stop_sequences, this tells you one of them was generated, and the response names which. Sometimes that's a legitimate end. Sometimes it's a delimiter you inserted to segment output, and you need to strip it, act on the segment, and continue past it. Either way, decide deliberately — don't conflate "my stop string appeared" with "the model is finished."
A loop that tests only != "end_turn" can spin forever, because pause_turn and refusal are neither end_turn nor tool_use. Branch on the specific value, with a default that fails loudly.
pause_turn — a long turn was suspended. When a server-side tool such as web search runs long enough to hit an internal iteration limit, Claude pauses the turn and returns pause_turn rather than dropping the work. You resume by sending a new request whose messages include the paused assistant response exactly as returned — the model then continues the same turn from where it stopped. This is a close cousin of resuming a dropped agent stream: the state you need to continue is in the response you already hold, so don't discard it and restart from scratch.
refusal — the model declined. On safety grounds the model can stop with refusal, and the content may be empty or partial. The instinct — retry the same request — is the wrong move: an identical prompt refuses again and bills you for the privilege. Treat it as a terminal decision. Log it, surface it to the caller, and adjust the scope, soften the ask, or route to a human. Refusals are a signal about the request, not a transient fault to paper over with retries.
The shape that survives#
The fix is not clever; it's just complete. Replace the boolean while with an explicit branch and a loud default:
if resp.stop_reason == "tool_use": run_tools_and_continue(resp)
elif resp.stop_reason == "end_turn": return final_text(resp)
elif resp.stop_reason == "max_tokens": continue_or_raise_limit(resp)
elif resp.stop_reason == "stop_sequence": handle_boundary(resp)
elif resp.stop_reason == "pause_turn": resume_with(resp) # re-send incl. this response
elif resp.stop_reason == "refusal": surface_refusal(resp)
else: raise RuntimeError(f"unhandled: {resp.stop_reason}")
The else matters as much as the branches. A new stop reason, or one you forgot, should crash your test suite — not vanish into a silent loop exit that you discover in production three weeks later. This is the difference between a loop you hand-rolled to learn and one you'd trust to run unattended; if wiring all six by hand starts to feel like plumbing, that pain is exactly the signal to weigh a runner that handles them for you. Either way, the count to remember is six, not two.



