skip to content

Why does a Claude agent loop with tool_choice any never terminate?

level: seniorimportance: should knowfreq 38%

answer

  1. A per-request parameter, not a session one
  2. Prose answers are forbidden
  3. The exit condition can never fire
  4. Force the first turn, then relax
  5. Close the loop with none

basics

~20 s

Because tool_choice applies to every request you send, and any forbids a plain-text answer. Each turn is therefore obliged to return a tool_use block, so a loop that exits on stop_reason leaving tool_use never exits. Force only the first turn.

solid answer

~40 s

`tool_choice` is a per-request parameter, not a session setting, and an agent loop rebuilds the request on every iteration. If your loop passes `{"type": "any"}` (or a forced named tool) from a constant, every single turn is forbidden from answering in prose, so every response comes back with a `tool_use` block and `stop_reason: "tool_use"` — the exact condition your loop uses to keep going. The model is not misbehaving; it has been told it may not stop. The fixes are to force only the first turn and switch to `{"type": "auto"}` afterwards, or to send a closing turn with `{"type": "none"}` so the model must produce prose. Independently, always cap iterations, because a broken tool can spin a loop even under `auto`.

go deeper

for a junior

Know that tool_choice is sent on every request and that any forbids a plain-text reply, so a forced loop can never reach its exit condition.

for a middle

Explain the interaction between the forced mode and a stop_reason-based loop condition, and describe forcing only the first turn before switching to auto.

for a senior

Diagnose it from a transcript — repeated calls with no progress — and show the graceful close with tool_choice none plus an unconditional iteration and token budget.

for a principal

Own the loop scaffolding as policy: where forcing is permitted at all, what the termination contract is, and the budget controls that stop a runaway agent before it shows up on the bill.

## The bug The loop looks correct. You build a request, check `stop_reason`, append results, and repeat while the model keeps calling tools. In testing with `tool_choice` omitted it terminates cleanly. Then someone adds `tool_choice={"type": "any"}` — often to fix a separate complaint that the model was answering from memory instead of calling the tool — and the loop runs until it hits an iteration cap, a token budget, or a bill. ## Why Two facts collide. First, `tool_choice` is a parameter of a single `POST /v1/messages` call. There is no notion of applying it "for the conversation": the API is stateless, so whatever you pass this time is what governs this response. A loop that builds its request from a constant re-applies the constraint on every iteration. Second, `{"type": "any"}` means Claude *must* emit at least one `tool_use` block; it is not permitted to answer with text alone. `{"type": "tool", "name": ...}` is the same constraint narrowed to one tool. So on the turn where the model has everything it needs and would naturally write the answer, it cannot. It must call something. It typically re-calls the tool it just called, perhaps with a slightly different argument, and returns `stop_reason: "tool_use"` again. Your loop condition sees a tool call, feeds the result back, and asks again. Nothing in the exchange can ever break the cycle, because the cycle is exactly what you configured. ## The tell in the transcript The symptom that identifies this rather than a genuinely confused model is *repetition without progress*: the same tool called with the same or trivially varied arguments, results that the model plainly already has, and no text block explaining a plan. A model that is genuinely stuck under `auto` usually varies its approach or emits explanatory text; a model under a forced choice looks like it is going through the motions, because it is. ## Fixes **Force the first turn only.** Pass the forced value on iteration zero and `{"type": "auto"}` from then on. This is the common pattern when you want to guarantee the agent starts by gathering data rather than guessing, but want it to stop on its own once it has enough. **Close with `none`.** When the loop hits its budget, send one more request with `{"type": "none"}` and the same accumulated `messages`. The definitions stay in the prompt, so the cached prefix is untouched, but the model is now forbidden from calling and must produce the final prose answer. This converts a hard cut-off into a graceful summary instead of an empty response. **Fix the real complaint properly.** Forcing is usually a workaround for a tool that was not being called when it should have been. The durable fix is a description that states trigger conditions explicitly — "Call this whenever the question concerns current stock, price or availability" — rather than removing the model's ability to decline. Forcing across several declared tools can also push the model into calling the wrong one when the honest answer was none. **Cap iterations regardless.** A counter alongside the `stop_reason` check is non-negotiable. Even under `auto`, a tool that returns an unhelpful error can produce a long retry spiral, and each iteration re-sends the entire conversation — so the cost of a runaway loop grows faster than the turn count. ## The cost profile of the failure This bug is expensive out of proportion to its subtlety. Every iteration resends the full history plus all accumulated tool results, so input tokens grow with each pass and the marginal cost of turn twenty far exceeds turn two. A forced loop left running against a fast tool can burn a large budget in minutes with no output to show for it. That is why the iteration cap and a token or wall-clock budget belong in the loop scaffolding from the first version, not added after the first incident.

  • How does closing with tool_choice none differ from just breaking out of the loop?
    Breaking out leaves you with whatever the last turn produced — usually a tool call, not an answer, so the user gets nothing usable. Sending one more request with `{"type": "none"}` and the same accumulated messages forbids further calls and makes the model summarise what it has learned into prose. The tool definitions stay in the prompt, so the cached prefix is unchanged and the extra turn is cheap.
  • Someone forced tool_choice because Claude kept answering from memory instead of calling the tool. What is the better fix?
    Rewrite the tool description to state trigger conditions, not just purpose — "Call this whenever the user asks about current stock, price or availability" rather than "looks up stock". Selection and the decision to call at all come almost entirely from that prose. Forcing removes the model's ability to decline but does not improve its judgement about which tool fits, so with several tools declared it can start calling the wrong one.
  • Why is an iteration cap still necessary once you switch back to auto?
    Because a tool that keeps returning an unhelpful error can send the model into a retry spiral on its own. Each iteration resends the entire conversation plus every accumulated tool result, so cost climbs faster than the turn count. A counter beside the stop_reason check, plus a token or wall-clock budget, bounds the damage regardless of why the model is not converging.

saying these in an interview costs you the question

  • Thinks tool_choice persists for the conversation once set
  • Blames the model rather than the forced parameter
  • Breaks the loop without producing a final answer
  • Removes the tools array to end the loop, invalidating the cached prefix
  • Ships an agent loop with no iteration cap

context