An agent can look busy for a long time and still be stuck. The model isn't always confused. Sometimes it's looping: same tool, same arguments, same dead end, same polite filler.
A longer system prompt rarely fixes that. What helps is treating stuckness as a control-plane concern, sitting outside the model's own reasoning, that watches whether steps still change the state of the world.
What "stuck" actually looks like
Three shapes cover most of it:
- Exact repeats: same tool, same arguments, twice in a row. No new information can come back.
- No-op results: the arguments change but the tool's output doesn't (a search that returns the same five links, a read that hits the same file). Motion without progress.
- Oscillation: A, then B, then A again, with the agent visibly re-deciding something it already decided two steps ago.
None of these need the model to say "I'm stuck." They're detectable from the outside, from the shape of the transcript alone.
A cheap detector
Hash each tool call as (tool_name, normalized_args) and keep a short ring buffer of the last N call-signatures plus a rolling hash of their results:
REPEAT_LIMIT of 2–3 catches real loops without flagging legitimate re-checks (polling a job status is supposed to repeat, so exclude those tools explicitly rather than tuning the threshold around them).
What to do once you catch it
Detection is only useful if something acts on it. Three escalating responses work well together:
- Nudge: inject a short system note ("you've called X three times with no new result; try a different approach or explain the blocker") and let the model self-correct once.
- Force a branch cut: disable the looping tool for the rest of the turn.
- End the turn: if the nudge doesn't land, stop calling tools and make the model answer with what it has, explicitly flagging the gap rather than hiding it.
Rate limits already get hard gates. Tool loops deserve the same honesty: a budget that runs out on purpose beats one that quietly bleeds to zero.