AI Agent Infinite Loops: How to Detect, Stop, and Prevent Runaway Tool Calls
An agent that loops does not crash. It keeps working, politely and expensively, calling the same tool with the same arguments until a budget, a rate limit, or a human stops it. This post shows the shapes runaway loops take, what they cost, and how to catch them: a small guard class that detects repeats, ping-pong cycles, error streaks, and no-progress runs, a loop that stops gracefully instead of dying, and the tool and prompt design that stops most loops from starting.
The six shapes of a runaway loop
Not every loop is the same call repeated forever. Naming the shape tells you which detector catches it and which fix prevents it.
| Shape | What it looks like | Typical cause |
|---|---|---|
| Exact repeat | Same tool, same arguments, same result, again and again | The result does not answer the question and the model does not know what else to try |
| Drifting repeat | Same tool, arguments changing slightly (a search reworded, a limit nudged), results near-identical | The tool returns no useful signal for any variation |
| Ping-pong | A, B, A, B, such as read, edit, read, edit, with the same outcome each time | Two steps undo each other, or a check keeps failing after each fix |
| Error retry | A tool fails and the model retries it unchanged | The error message does not say what to change |
| Endless exploration | Every call is different but nothing converges: more searches, more pages, more files | No definition of "enough," or tools that always return more |
| Fan-out | An agent spawns subagents that spawn more, or one turn issues dozens of parallel calls | Per-agent limits with no shared budget |
What a loop costs
Loops get expensive faster than intuition suggests, because every turn re-sends the whole conversation. If each turn adds about 2,000 tokens of history, the input tokens you pay for grow with the square of the turn count.
| Turns | Total input tokens | Input cost at $3 per million |
|---|---|---|
| 10 | 110,000 | $0.33 |
| 25 | 650,000 | $1.95 |
| 50 | 2,550,000 | $7.65 |
| 100 | 10,100,000 | $30.30 |
| 200 | 40,200,000 | $120.60 |
These are illustrative numbers for input tokens only, with no caching and no output tokens. Prompt caching cuts the cost of the repeated prefix substantially, but history still grows, and a single stuck run can be 20 times the cost of a normal one. Multiply by every user whose agent gets stuck on the same bad tool and you have an incident. Our cost optimization playbook covers the caching side of this.
Why agents loop
- Errors that do not teach. "Request failed" gives the model nothing to change, so it tries the same thing again.
- Results that look like success. A write that changed nothing, or a search that returned an empty list with no final marker, reads as "try again."
- No definition of done. If the prompt never says when to stop, the model keeps gathering.
- Prompts that forbid giving up. "Keep trying until it works" is an instruction to loop.
- Forced tool use. Setting the tool choice to "required" or "any" on every turn means the model must call a tool every turn, so it cannot finish.
- Truncated tool output. If a result is cut off, the model asks again, getting the same truncated result.
- Lost memory. After context compaction, the model forgets which approaches already failed and tries them again.
The defense: five layers
- Hard budgets on turns, tool calls, cost, and wall-clock time. These are your guaranteed ceiling.
- Per-tool caps for expensive or dangerous tools such as web search, code execution, and anything that writes.
- Pattern detectors that catch a loop in a handful of calls instead of at the budget.
- Warn, then stop. A nudge in the tool result often breaks a loop. If it does not, stop the run.
- Graceful wrap-up. When stopping, give the model one tool-free turn to report what it found, and return that as a partial result.
All five run in your code, outside the model. A prompt can ask the agent to behave. Only code can guarantee it.
Step 1: The LoopGuard
The guard fingerprints every tool call and result with a hash that ignores volatile fields such as timestamps and request IDs, then watches for the shapes above. It does not know or care which provider is in use.
import hashlib, json, time
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Optional
VOLATILE = {"timestamp", "request_id", "trace_id", "nonce", "created_at"}
def canon(obj: Any) -> str:
def scrub(o):
if isinstance(o, dict):
return {k: scrub(v) for k, v in sorted(o.items()) if k not in VOLATILE}
if isinstance(o, list):
return [scrub(v) for v in o]
if isinstance(o, str):
return " ".join(o.split()).lower() # for detection only, never for execution
return o
return json.dumps(scrub(obj), sort_keys=True, default=str)
def digest(obj: Any) -> str:
return hashlib.sha1(canon(obj).encode()).hexdigest()[:12]
@dataclass
class Limits:
max_turns: int = 25
max_tool_calls: int = 60
max_cost_usd: float = 2.00
max_seconds: float = 300.0
per_tool: dict = field(default_factory=dict) # {"web_search": 8}
exempt_repeat: set = field(default_factory=set) # legitimate pollers
repeat_warn: int = 3
repeat_stop: int = 5
streak_warn: int = 2
streak_stop: int = 4
cycle_stop: int = 3
stale_stop: int = 6
@dataclass
class Verdict:
action: str = "ok" # ok | warn | stop
reason: str = ""
detail: str = ""
@dataclass
class Call:
name: str
args_h: str
result_h: str
ok: bool
novel: bool
def trailing_reps(sig: list, p: int) -> int:
if len(sig) < p:
return 0
block = sig[-p:]
if len(set(block)) < 2: # a single repeated call is the repeat detector's job
return 0
reps, i = 1, len(sig) - 2 * p
while i >= 0 and sig[i:i + p] == block:
reps += 1
i -= p
return reps
class LoopGuard:
def __init__(self, limits: Optional[Limits] = None, price_in=3.0, price_out=15.0):
self.lim = limits or Limits()
self.price = (price_in, price_out) # USD per million tokens, from config
self.calls: list[Call] = []
self.per_tool: Counter = Counter()
self.seen: set = set()
self.turns, self.cost = 0, 0.0
self.t0 = time.monotonic()
def record_turn(self, in_tok: int, out_tok: int) -> None:
self.turns += 1
self.cost += in_tok / 1e6 * self.price[0] + out_tok / 1e6 * self.price[1]
def before_call(self, name: str) -> bool:
return self.per_tool[name] < self.lim.per_tool.get(name, 10**9)
def record_call(self, name: str, args: Any, result: Any, ok: bool) -> None:
r = digest(result)
self.calls.append(Call(name, digest(args), r, ok, r not in self.seen))
self.seen.add(r)
self.per_tool[name] += 1
def check(self) -> Verdict:
L, calls = self.lim, self.calls
if self.turns >= L.max_turns:
return Verdict("stop", "turn_budget", f"{self.turns} turns")
if len(calls) >= L.max_tool_calls:
return Verdict("stop", "call_budget", f"{len(calls)} tool calls")
if self.cost >= L.max_cost_usd:
return Verdict("stop", "cost_budget", f"${self.cost:.2f}")
if time.monotonic() - self.t0 >= L.max_seconds:
return Verdict("stop", "time_budget", f"{L.max_seconds:.0f}s")
if not calls:
return Verdict()
warn = Verdict()
last = calls[-1]
recent = calls[-20:]
if last.name not in L.exempt_repeat:
same = sum(1 for c in recent if (c.name, c.args_h) == (last.name, last.args_h))
if same >= L.repeat_stop:
return Verdict("stop", "repeat", f"{last.name} called {same} times with the same arguments")
if same >= L.repeat_warn:
warn = Verdict("warn", "repeat", last.name)
streak = 0
for c in reversed(calls):
if c.ok or c.name != last.name:
break
streak += 1
if streak >= L.streak_stop:
return Verdict("stop", "error_streak", f"{last.name} failed {streak} times in a row")
if streak >= L.streak_warn and warn.action == "ok":
warn = Verdict("warn", "error_streak", last.name)
sig = [(c.name, c.args_h) for c in calls]
reps = max(trailing_reps(sig, p) for p in (2, 3, 4))
if reps >= L.cycle_stop:
return Verdict("stop", "cycle", f"same call pattern repeated {reps} times")
if reps == 2 and warn.action == "ok":
warn = Verdict("warn", "cycle", "")
stale = 0
for c in reversed(calls):
if c.novel:
break
stale += 1
if stale >= L.stale_stop:
return Verdict("stop", "stale", f"{stale} calls with nothing new")
if stale >= L.stale_stop // 2 and warn.action == "ok":
warn = Verdict("warn", "stale", "")
return warn
Four design choices are worth understanding:
- Novelty beats equality. The stale detector asks whether a result has ever been seen before in this run. That catches drifting repeats and endless exploration, where arguments differ but nothing new arrives.
- Repeats are counted in a window of recent calls, not the whole run, so a legitimate second read of a file an hour later does not trip the alarm.
- Pollers are exempt from the repeat check through
exempt_repeat, but they still count against every budget. - Stop beats warn. When several detectors fire, the most severe verdict wins.
Step 2: Wire it into the agent loop
The loop below is provider-neutral: model is any function that returns a normalized turn, as in our provider-agnostic client. On a warning, it appends a nudge to the last tool result. On a stop, it still answers every pending tool call, then gives the model one final turn with tools disabled.
from dataclasses import dataclass
NUDGE = {
"repeat": "You have made this exact call several times with the same result. "
"Change your approach, or explain what is blocking you.",
"error_streak": "This tool has failed repeatedly. Read the error, change the arguments, "
"use a different tool, or report the problem.",
"cycle": "You are alternating between the same calls without gaining new information. "
"Step back and decide what you actually need.",
"stale": "The last several calls returned nothing new. Decide whether you already have "
"enough to answer.",
}
WRAPUP = ("Stop here. Do not call any more tools. Summarize what you did, what you found, "
"and what is blocking you, so the work can continue later.")
@dataclass
class ToolCall:
id: str
name: str
args: dict
@dataclass
class Turn:
text: str
calls: list
in_tok: int
out_tok: int
@dataclass
class Result:
call_id: str
ok: bool
content: str
@dataclass
class Outcome:
status: str # done | stopped
text: str
reason: str = ""
def safe_call(tools: dict, call: ToolCall):
fn = tools.get(call.name)
if fn is None:
return False, f"Unknown tool {call.name!r}. Available: {sorted(tools)}"
try:
return True, str(fn(**call.args))
except Exception as e: # errors go back to the model as data
return False, f"{type(e).__name__}: {e}"
def run_agent(model, tools: dict, messages: list, guard: LoopGuard) -> Outcome:
# model(messages, allow_tools) -> Turn
while True:
turn = model(messages, allow_tools=True)
guard.record_turn(turn.in_tok, turn.out_tok)
messages.append({"role": "assistant", "text": turn.text, "calls": turn.calls})
if not turn.calls:
return Outcome("done", turn.text)
results, verdict = [], Verdict()
for call in turn.calls:
blocked = verdict.action == "stop" or not guard.before_call(call.name)
if blocked:
if verdict.action != "stop":
verdict = Verdict("stop", "tool_budget", call.name)
# every tool call must still get a result, or the next request is invalid
results.append(Result(call.id, False, "Not executed: run budget reached."))
continue
ok, out = safe_call(tools, call)
guard.record_call(call.name, call.args, out, ok)
results.append(Result(call.id, ok, out))
v = guard.check()
if v.action == "stop" or (v.action == "warn" and verdict.action == "ok"):
verdict = v
if verdict.action == "warn":
results[-1].content += "\n\n[notice] " + NUDGE[verdict.reason]
messages.append({"role": "tool", "results": results})
if verdict.action == "stop":
messages.append({"role": "user", "text": WRAPUP})
final = model(messages, allow_tools=False) # tools disabled for the wrap-up
return Outcome("stopped", final.text, verdict.reason)
Two details prevent subtle bugs. First, every tool call the model made must receive a result, even if you did not run it. Claude, GPT, and Gemini all reject or misbehave on a conversation where a tool call has no matching result, so a blocked call gets a "Not executed" error rather than silence. Second, the wrap-up turn disables tools through the provider's own setting, which is tool_choice: {"type": "none"} on Claude, tool_choice: "none" on OpenAI, and a function-calling mode of NONE on Gemini. Do not rely on the prompt alone to stop a model that has been calling tools for 30 turns.
When the final text is returned, label the result as stopped with the reason, so your UI and logs never present a cut-off run as a success.
Step 3: Make tool results teach the model
Most loops are born in the tool, not the model. The model repeats what it was not told to stop doing. Return structured results that say what happened, whether retrying can help, and what to change:
// Bad: the model cannot tell what to do next
{"error": "failed"}
// Good: says what happened, whether retrying helps, and what to change
{
"ok": false,
"error": "file_not_found",
"message": "No file at /data/report.csv",
"retryable": false,
"suggestion": "Call list_files to see which paths exist."
}
// Empty results must say they are final
{
"ok": true,
"results": [],
"total": 0,
"has_more": false,
"note": "No matches. Broaden the query or stop searching."
}
// A write that changed nothing must not look like progress
{
"ok": true,
"changed": false,
"note": "The file already contains this content. Nothing was written."
}
Three more tool-side habits help. Cap result size and say when something was truncated, with a clear way to fetch the next page. Make empty results explicit and final. And retry transient failures such as timeouts and rate limits inside the tool wrapper with backoff, then report one definitive result to the model, so the model is never in charge of your retry policy.
Step 4: Prevent loops in the prompt and the request
- Define done. State what a finished answer looks like and tell the agent to stop and answer once it has it.
- Give it a budget it can see. "You have up to 8 tool calls. If you cannot finish in that many, stop and report what is blocking you." The model plans better when it knows the limit.
- Ask for a reason before a retry. Instructing the agent to say what it will change before retrying a failed call cuts blind repeats noticeably.
- Permit failure. Replace "keep trying until it works" with "if two different approaches fail, stop and explain."
- Do not force tool use every turn. Reserve forced tool choice for a single known step, never for a loop.
- Limit parallelism where order matters. Disabling parallel tool calls makes behavior easier to reason about, and the detectors easier to tune.
Provider controls you should know about
Providers differ in who owns the loop. Check current documentation, since these settings evolve.
| Claude | GPT | Gemini | |
|---|---|---|---|
| Who runs the loop | You, with the Messages API. The Agent SDK has its own maximum-turns option | You, with chat completions. The Responses API can run built-in tools server-side | You, or the SDK's automatic function calling |
| Built-in cap | max_uses on the server-side web search tool | max_tool_calls in the Responses API for built-in tools | maximum_remote_calls in the Python SDK's automatic function calling (default 10) |
| Disable tools for wrap-up | tool_choice type none | tool_choice "none" | Function-calling mode NONE |
| Forced tool use (avoid in loops) | tool_choice type any or tool | "required" or a named function | Mode ANY |
| Disable parallel calls | disable_parallel_tool_use in tool_choice | parallel_tool_calls: false | Handle calls sequentially in your loop |
These provider caps are a useful backstop, but they limit calls, not cost, and they cannot see patterns. Keep your own guard as the source of truth. For protocol-level problems with tools themselves, see the MCP protocol errors guide.
Step 5: Test it with scripted loops
You do not need a real model to test loop protection. A scripted fake model that emits the exact loop you fear, plus fake tools, proves the guard stops it, and at which turn. These tests run in well under a second.
import itertools
def scripted(turns):
it = iter(turns)
def model(messages, allow_tools):
if not allow_tools:
return Turn("Summary: stopped early.", [], 100, 20)
return next(it)
return model
def call_turn(i, name="search", args=None, in_tok=1000):
return Turn("", [ToolCall(f"c{i}", name, args or {"q": "x"})], in_tok, 50)
def test_healthy_run_finishes():
tools = {"search": lambda **k: "found it"}
model = scripted([call_turn(1), Turn("All done.", [], 1000, 30)])
out = run_agent(model, tools, [], LoopGuard())
assert out.status == "done"
def test_exact_repeat_is_stopped():
tools = {"search": lambda **k: "no new info"}
guard = LoopGuard()
out = run_agent(scripted([call_turn(i) for i in range(20)]), tools, [], guard)
assert (out.status, out.reason) == ("stopped", "repeat")
assert guard.turns == 5
def test_ping_pong_is_stopped():
counter = itertools.count()
tools = {"read": lambda **k: next(counter), "write": lambda **k: next(counter)}
turns = [call_turn(i, "read" if i % 2 == 0 else "write") for i in range(20)]
guard = LoopGuard()
out = run_agent(scripted(turns), tools, [], guard)
assert out.reason == "cycle" and guard.turns == 6
def test_error_streak_is_stopped():
def broken(**k):
raise ValueError("bad input")
turns = [call_turn(i, "parse", {"n": i}) for i in range(20)]
out = run_agent(scripted(turns), {"parse": broken}, [], LoopGuard())
assert out.reason == "error_streak"
def test_cost_budget_is_stopped():
tools = {"search": lambda **k: "ok"}
model = scripted([call_turn(1, in_tok=1_000_000)] + [call_turn(2, args={"q": "y"})] * 5)
out = run_agent(model, tools, [], LoopGuard(Limits(max_cost_usd=2.0)))
assert out.reason == "cost_budget"
def test_per_tool_limit_blocks_the_call():
counter = itertools.count()
tools = {"search": lambda **k: next(counter)}
turns = [call_turn(i, args={"q": i}) for i in range(10)]
guard = LoopGuard(Limits(per_tool={"search": 2}))
out = run_agent(scripted(turns), tools, [], guard)
assert out.reason == "tool_budget" and guard.per_tool["search"] == 2
Every real incident should become one more test: take the trace of the runaway run, replay its tool calls as a script, and assert that the guard stops it before the damage point.
Run it in production
- One guard per run, with a run ID. Never share guard state between users or runs.
- Log every warn and stop with the run ID, reason, tool name, and the last ten call fingerprints. Alert on the stop rate, and on any single run whose cost exceeds a multiple of your median.
- Add account-level spend caps on top of per-run budgets. They protect you from a bug that creates thousands of well-behaved runs.
- Build a kill switch that can disable one tool, one tenant, or one agent without a deploy.
- Share the budget across subagents. Pass a single guard, or a parent budget, down the tree, and limit nesting depth and children per agent.
- Require confirmation for side effects. A loop that sends emails or writes records is an incident, not a bill. Rate-limit and gate those tools separately.
- Track the right metrics: turns per run, tool calls per run, stop reasons, and cost per successful task, by agent and by tool.
If a loop is running right now
- Stop the bleeding. Use the kill switch to disable the offending tool or tenant, or revoke the tool's credentials if there is no switch. Cancel in-flight runs by ID.
- Check for side effects. Look for duplicate emails, records, charges, or files written by the looping runs.
- Pull a trace and classify the loop using the six shapes above.
- Fix the cause, which is usually a tool result or an error message, not the model.
- Add a regression test and tighten the matching detector threshold.
Symptom-to-cause cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| Same tool call, same result, many times | Result does not answer the question | Repeat detector, nudge, clearer tool output |
| A, B, A, B pattern | Steps undo each other or a check never passes | Cycle detector, a verification step that explains failure |
| Tool fails and is retried unchanged | Error says nothing actionable | Structured errors with retryable and suggestion |
| Endless searches, each slightly different | No definition of enough, empty results not marked final | Stale detector, per-tool cap, explicit done criteria |
| Never produces a final answer | Forced tool choice every turn | Use automatic tool choice in the loop |
| Retries things that already failed after a long run | Context compaction dropped the failures | Keep a tried-and-failed list in the summary |
| Cost spike with normal traffic | A few runs looping, with growing history | Cost budget per run, alert on outlier runs |
| Duplicate side effects | Loop on a write tool | Gate writes, add idempotency keys, per-tool cap |
Pitfalls to avoid
- Relying on the prompt alone. A model told to stop looping will still loop. Enforce in code.
- Stopping without a wrap-up. An abrupt cut throws away the work already done and leaves users with an error.
- Leaving a tool call without a result. The next request will be rejected or confuse the model.
- Counting only identical arguments. Drifting repeats and endless exploration slip through unless you track novelty of results.
- Hiding the stop. Present stopped runs as stopped, with a reason, never as quiet successes.
- Setting thresholds once. Revisit limits when you change models, prompts, or tools, since each changes how many calls a healthy run needs.
Related failure modes in the request path, such as stalled or truncated streams in the middle of an agent turn, are covered in our post on debugging streaming failures.
Frequently asked questions
How many tool calls is too many?
There is no universal number. Measure how many calls your successful runs use, take the 95th percentile, and set the hard limit at roughly one and a half to two times that. Set the warning threshold well below it. A support agent that normally needs 4 calls and a coding agent that normally needs 40 should have very different budgets.
Is a maximum-turns setting enough?
It is necessary but not sufficient. A turn cap only fires after you have already paid for every wasted turn, and it usually ends the run abruptly with no useful output. Pair it with pattern detectors that catch a loop in three to five calls, and with a wrap-up step that returns what the agent found so far.
Should I tell the model about its budget?
Yes. Telling the agent how many attempts it has, and that it should stop and report what is blocking it when it runs out, reduces blind retries. But the instruction is advice, not enforcement. Always enforce the same limit in code.
Is polling a status endpoint a loop?
It can be a legitimate repeat. Exempt specific polling tools from the repeat detector, give them their own cap, and add a wait inside the tool so the model cannot poll faster than the job can change. Better still, make the tool block until the job finishes or a timeout passes.
Who should retry transient errors, the model or the tool?
The tool wrapper. Retry timeouts, 429s, and 503s inside the tool with backoff, then return one final result to the model that says whether the failure is retryable. Letting the model drive transient retries burns tokens and invites loops.
How do I detect loops after the context is compacted or summarized?
Keep the guard's state outside the conversation. Counters and call fingerprints live in your code, so compaction cannot erase them. Also make sure summaries carry a short list of approaches already tried and failed, otherwise the model will cheerfully try them again.
Do parallel tool calls break loop detection?
Process each call in order and record it individually. Identical calls within the same turn count toward the repeat threshold, which is what you want, since a model that emits the same call five times in parallel is looping in width instead of depth.
What should the user see when a run is stopped?
A clear status, the partial result, a short list of what was tried, and what is blocking progress. Do not present a stopped run as a success, and do not discard the work. Where possible, let the user resume with extra guidance.
How do I stop runaway subagents and fan-out?
Share one budget across the whole tree of agents, limit nesting depth, and cap the number of children a single agent may spawn. A per-agent limit alone does not help when each of a hundred children stays just under it.
How do I test loop protection without paying for real runs?
Use a scripted fake model that emits the loop you want to catch, and fake tools that return fixed or changing results. The tests at the end of this post cover repeat, ping-pong, error streak, cost, and per-tool limits in under a second. Add every real incident trace as a new regression test.