Tool Call Loops in Agents: How We Stop the Death Spiral
Agents that call the same tool twelve times in a row aren't reasoning — they're panicking. Here's how we detect, prevent, and recover from tool call loops in production LLM agents.

The worst agent bug we ever shipped ran a search_orders tool 47 times in 90 seconds before a rate limiter finally killed it. The user's question was answerable on call two. The other 45 were the model convincing itself it hadn't tried hard enough.
If you've built anything with tool calling — Claude's tool use, OpenAI's function calling, Gemini's function declarations — you've seen some version of this. The agent gets stuck. It calls the same tool with slightly different arguments, or the exact same arguments, or two tools that ping-pong forever. This post is about why that happens and the specific guardrails we now put in every agent we ship.
Why agents loop in the first place
Tool call loops aren't random. They come from a small set of predictable failure modes, and once you name them you can defend against each one.
The most common cause is ambiguous tool results. The model asks get_user(id=123) and gets back {"status": "not_found"}. Instead of telling the user, it tries get_user(id="123"), then search_users(query="123"), then get_user(id=123, include_deleted=true). Each result is ambiguous enough that the model thinks a different phrasing might work.
The second cause is missing terminal states. If your tool descriptions don't make it clear what "done" looks like, the model keeps going. We had a research agent that would call web_search fourteen times because nothing in the prompt said "three searches is enough, synthesise what you have."
Third: conflicting instructions. A system prompt that says "be thorough" plus a tool that returns partial data plus no budget constraint equals infinite tool calls. The model is doing exactly what you asked.
And fourth, the one people miss: context poisoning. Once a failed tool call is in the conversation history, the model sometimes fixates on it. It sees error: timeout from ten turns ago and keeps trying variations of that call even after other tools succeeded.
The Claude vs GPT vs Gemini angle
In our experience, the three major model families loop differently. Claude tends to loop on retries when a tool returns an error — it will politely try again with small variations. GPT-4 class models are more likely to loop on decomposition, breaking one question into six sub-tool-calls when one would do. Gemini has gotten much better here, but earlier versions would loop on verification: calling a tool, then calling a second tool to verify the first, then a third to verify the second.
None of these are bugs in the models. They're artefacts of how each was trained on tool use. But your loop-detection logic needs to catch all three shapes.
Detecting the death spiral
Before you can stop a loop, you have to see it. We track four signals on every agent turn.
Signal one: exact-match repetition. Hash the tool name plus normalised arguments. If the same hash appears twice in the last N turns, that's a hard stop candidate. Normalisation matters — {"id": 123} and {"id":123} should hash the same.
Signal two: near-match repetition. Same tool, arguments that differ only in whitespace, casing, or trivial reformatting. This catches the id=123 vs id="123" case.
Signal three: tool call velocity. Number of tool calls per user turn. If a single user message triggers more than, say, 8 tool calls, something is off. The threshold depends on your agent, but there is a threshold.
Signal four: no-progress detection. This is the hardest. The idea: if the model has made three tool calls and the last assistant message doesn't reference any new information from those calls, it's spinning. We implement this crudely by checking whether the assistant's reasoning text mentions any values that appeared in recent tool results.
Here's the skeleton of the loop detector we run in front of every tool-calling loop:
from collections import Counter
from hashlib import sha1
import json
def tool_call_signature(name: str, args: dict) -> str:
normalised = json.dumps(args, sort_keys=True, separators=(",", ":"))
return sha1(f"{name}:{normalised}".encode()).hexdigest()[:16]
class LoopGuard:
def __init__(self, max_repeats=2, max_calls_per_turn=8, window=6):
self.max_repeats = max_repeats
self.max_calls_per_turn = max_calls_per_turn
self.window = window
self.recent = []
self.turn_count = 0
def check(self, name: str, args: dict) -> tuple[bool, str]:
self.turn_count += 1
if self.turn_count > self.max_calls_per_turn:
return False, "tool_budget_exceeded"
sig = tool_call_signature(name, args)
self.recent.append(sig)
self.recent = self.recent[-self.window:]
counts = Counter(self.recent)
if counts[sig] > self.max_repeats:
return False, f"repeated_call:{name}"
return True, "ok"
def reset_turn(self):
self.turn_count = 0
This runs before every tool dispatch. If it returns False, we don't execute the tool — we inject a synthetic tool result telling the model what happened.
Recovery: what to do when you catch a loop
Catching the loop is only half the job. The interesting question is what you tell the model when you block a call.
The worst thing you can do is return a generic error. The model will treat it like any other tool failure and try again. What works better is a directive tool result that names the problem and forces a change of strategy.
Here's what we inject when the guard trips:
{
"tool_call_blocked": true,
"reason": "You have called search_orders 3 times with similar arguments. Stop calling tools. Summarise what you have learned from previous results and respond to the user directly. If you truly cannot answer, say so."
}
That direct instruction — "stop calling tools, respond to the user" — is the single most effective thing we've added to our agents this year. Claude and GPT both follow it reliably. The key is that it's phrased as a tool result, not a system message, so it lands in the exact place the model is looking.
The escape hatch pattern
For longer-running agents, we also add an explicit give_up or escalate_to_human tool. Counterintuitively, giving the model permission to fail reduces looping. Without it, the model treats every turn as "I must find the answer." With it, the model has a legitimate way out and takes it when appropriate.
We define it in the tool schema exactly like any other tool. Anthropic and OpenAI both document this pattern in their tool use guides, and it works across vendors.
Prevention at the prompt layer
Detection and recovery are the safety net. The real fix is upstream: write prompts and tool descriptions that don't invite loops in the first place.
Three rules we've settled on:
Rule one: every tool description states its terminal condition. Not just what the tool does, but when to stop calling it. "Call this at most once per user question" or "if this returns empty, do not retry — report to the user."
Rule two: tool results include a next_action_hint field. When a tool returns data, it also returns a short string telling the model what to do next. "next_action_hint": "respond_to_user" on a successful lookup. "next_action_hint": "try_alternate_tool" when appropriate. This is more reliable than hoping the model infers the right next step.
Rule three: budget declared in the system prompt. "You have a budget of 5 tool calls for this task. Track your usage. When you have called 4 tools, plan to respond to the user on the next turn." Models are surprisingly good at respecting stated budgets when you make the budget visible.
Evals for loop behaviour
None of this matters if you can't measure it. We run a small eval suite specifically targeting loop-prone scenarios:
- Queries where the answer is genuinely unknowable (does the agent give up cleanly?)
- Queries with ambiguous IDs (does it try three formats or ask the user?)
- Multi-step queries where one step fails (does it recover or spiral?)
- Queries designed to trigger verification loops (does it stop after confirming once?)
For each eval case we track: total tool calls, whether the loop guard fired, whether the final response was reasonable, and cost. A regression in any of these gets a red build. If you want a longer take on eval harnesses that catch this class of bug, we wrote one at /blog.
What we'd do on a new agent project
If you're starting fresh next week, do these four things in order. First, add a LoopGuard in front of your tool dispatcher before you write a second tool. It's ten lines and it will save you hours of debugging. Second, write every tool description with an explicit terminal condition. Third, add a give_up tool from day one. Fourth, put three loop-prone cases in your eval set before you ship.
Agents that know how to stop are worth more than agents that know how to try harder. Build for the stop.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Structured Outputs vs Tool Calls: Which One Should Your LLM Actually Use?
Structured outputs and tool calls look interchangeable until you ship them. Here's how we decide which one to reach for, based on latency, reliability, and how much the model needs to think.

Reranking in RAG: When a Cross-Encoder Actually Pays for Itself
Rerankers are the most oversold and undermeasured piece of the RAG stack. Here's when adding one earns its keep, when it's dead weight, and how to prove it with a small eval.
Semantic Cache Hits Are Lying to You: Building a Similarity Layer That Actually Works
Semantic caching promises free hits at 95% similarity. In practice it hands users wrong answers with confidence. Here's how to build a similarity layer you can actually trust in production.
