Give a capable model a set of tools and a hard problem, and most of the time it does something impressive. But every so often it gets stuck in the least glamorous failure mode there is: it calls search("q3 revenue"), reads the result, decides it is not quite enough, and calls search("q3 revenue") again. And again. The tool returns the same thing every time, because the world did not change between calls - but the model keeps hoping it will.
Left alone, that is not a bug that fails loudly. It is a bug that runs. A loop calling a paid API is a way to turn a stalled thought into an invoice, and the first sign of trouble is often the billing alert, not an exception. So the interesting engineering question is not “why did the model repeat itself” - models will always sometimes repeat themselves - but “what in the harness noticed, and stopped it?”
Why loops happen at all
The agent loop is deliberately simple: call the model, run the tool it asked for, feed the result back, repeat. Nothing in that skeleton knows the difference between making progress and spinning. Each turn looks locally reasonable. The model reasons, picks a tool, gets an observation - textbook ReAct. The problem is only visible across turns, in the shape of the transcript, and the loop by itself has no memory of shape.
Three things tend to produce a cycle, and it is worth naming them because each wants a different response:
A stale repeat is the easy case and the most common one. The other two are why a step budget alone is not enough: a bounded loop stops the bleeding, but it does not tell you why the agent bled, and it wastes every turn up to the limit.
The signature trick
The cheapest reliable defence costs about ten lines. Before the loop runs a tool, it hashes the call down to a signature - the tool name plus its normalised arguments - and remembers how many times it has seen each one. An identical signature is, by definition, a call whose observation the model has already seen. Seeing it a third time in a row is not persistence; it is a cycle.
(tool, args) signature and counts repeats. A healthy agent produces
fresh signatures and finishes. A stuck one repeats the same call - and once
the counter hits the threshold, the guard breaks the loop and forces
the model to change approach instead of burning turns.Switch the figure to the healthy run and every call produces a fresh signature: search, then read_file, then calc, then a final answer. The counter never climbs. Switch to stuck and the same signature repeats; the moment its count hits the threshold, the guard trips and the loop breaks before a fourth wasted call ever fires. The model was not punished for thinking - it was stopped from re-thinking the identical thought.
Here is the whole guard. It slots into the loop you already have, right before the tool runs:
from collections import Counter
class LoopGuard:
def __init__(self, threshold: int = 3):
self.threshold = threshold
self.seen: Counter[str] = Counter()
def signature(self, tool: str, args: dict) -> str:
# Normalise so trivially-different calls still collide:
# sort keys, strip whitespace, lowercase string values.
norm = {k: str(v).strip().lower() for k, v in sorted(args.items())}
return f"{tool}::{norm}"
def check(self, tool: str, args: dict) -> str | None:
sig = self.signature(tool, args)
self.seen[sig] += 1
if self.seen[sig] >= self.threshold:
return (
f"Loop guard: you have called {tool} with these "
f"arguments {self.seen[sig]} times and the result has not "
f"changed. Do not repeat it. Either use a different tool, "
f"change the arguments, or give your best answer now."
)
return None
The important detail is the last branch. When the guard trips, it does not raise. It returns a message that goes back into the transcript as the tool’s observation. The model reads “you already did this, the answer will not change, do something else” - in band, in the same channel every other observation arrives on - and gets one clean chance to change course before any harder limit kicks in.
Break the loop in band, not with an exception. Crashing on a repeat throws away the model’s ability to recover. Feeding the repeat back as an observation keeps the agent alive and gives it the one piece of information it was missing: that it is stuck. Most of the time, told plainly, it picks a different move.
Give the agent a plan to execute against
The signature guard is reactive - it catches a loop after the model has already started spinning. A planner attacks the problem from the other side. Make the model commit to an ordered plan before it touches a single tool, then have the executor walk that plan top-down. Now progress is not a vague sense of “are we getting anywhere” - it is a concrete count of steps checked off. That count is the loop’s exit condition, and it is what a bare ReAct loop never had.
The shift is subtle but it changes the failure mode entirely. When work is defined as “complete the plan,” a repeat is not just wasteful, it is obviously wrong: the step it wants to redo is already marked done. The planner can refuse it without any threshold or heuristic, because it has ground truth about what remains.
Run the steps and the plan fills in, one checkmark at a time, until nothing is left and the loop exits on its own. Then press Retry a done step - the model’s attempt to re-run finished work is refused outright, and it is redirected to the next open step. There is no counter to trip here; the plan itself is the authority on what has and has not happened.
In code, the planner is a thin wrapper that owns the cursor and hands out one step at a time:
from dataclasses import dataclass, field
@dataclass
class Planner:
steps: list[str] # committed once, up front
done: list[bool] = field(default_factory=list)
def __post_init__(self):
self.done = [False] * len(self.steps)
@property
def complete(self) -> bool:
return all(self.done)
def next_step(self) -> str | None:
for i, finished in enumerate(self.done):
if not finished:
return self.steps[i]
return None # plan exhausted -> loop exits
def mark(self, step: str) -> str:
i = self.steps.index(step)
if self.done[i]:
# The model asked to redo finished work. Refuse and redirect.
nxt = self.next_step()
return f"Planner: '{step}' is already done. Do '{nxt}' instead."
self.done[i] = True
return f"Planner: '{step}' complete. {sum(self.done)}/{len(self.steps)} done."
Two properties fall out of this for free. First, the loop terminates by construction: once every step is marked, next_step() returns None and there is simply nothing to hand the model, so the loop ends whether or not the model ever thinks to say “done.” Second, a repeat is a category error the planner can name, not a statistical anomaly you have to detect. mark knows the step is finished and says so, in band, exactly as the signature guard does - but it knew it on the first repeat, not the third.
A plan turns 'are we looping?' into 'is the plan done?' The hardest part of loop detection is defining progress. A committed plan defines it for you: progress is steps completed. Everything else - termination, repeat rejection, even a status bar you can show a user - follows from that one decision.
The planner is not a replacement for the signature guard; it is the layer above it. Plans go stale - the model discovers the CSV has a different schema than it assumed, and step two no longer makes sense. When that happens the agent has to re-plan, and a sloppy re-plan is exactly where a fresh loop can sneak back in. So the signature guard stays underneath as the catch-all, and the planner sits on top to keep the common case from ever getting there.
Why not just tell the model “don’t loop”?
Because the prompt is not where the loop lives. You can ask a model to avoid redundant calls, and it will mostly comply, but “mostly” is not a guarantee you can put in front of a customer or a billing account. The model is a stateless next-token function; it does not reliably remember that the call it is about to make is the same one it made ninety seconds and four thousand tokens ago. The guard does remember, deterministically, because remembering is a job for code, not for a probability distribution.
This is the same division of labour that runs through every robust agent. The model proposes; the harness disposes. Anything you need to be true - a spend ceiling, a tool that never gets unsafe input, a loop that always terminates - belongs in the deterministic shell around the model, not in the instructions you hope it follows.
Prompt-only "please don't loop"
- Depends on the model complying every time
- No record of what actually repeated
- Fails silently and keeps spending
- Nothing to alert or log on
Deterministic loop guard
- Trips on the same signature every time
- Counts and names the repeated call
- Breaks the loop and nudges the model
- Emits a signal you can meter and alert on
Layers, not a single switch
The signature guard catches the stale-repeat case cleanly, but a production harness stacks a few cheap defences so that no single failure mode slips through. Each one is a backstop for the layer above it:
- A hard step budget. The outermost rail. Even a novel, non-repeating plan that never terminates hits
max_stepsand stops. This is the one that turns “endless” into “expensive but finite,” and it is not optional. - The signature guard. Catches the common identical-repeat cycle early, long before the step budget, and - crucially - explains itself to the model so it can recover instead of just being cut off.
- A no-progress check. Watches whether the transcript is still gaining facts. If several turns pass with new calls but no new information written to state, the harness treats it as a soft cycle and intervenes, even though no single signature repeated.
- An escalation path. When the guards fire and the model still cannot move, the honest move is to stop and hand back to a human with the transcript attached - not to keep paying for turns that are not converging.
The order matters. You want the gentlest intervention that works to fire first: the signature nudge gives the model a chance to self-correct, the no-progress check catches the subtler stalls, and the step budget is the last line that guarantees termination no matter what. By the time you reach escalation, you have a clean record of exactly what looped and why - which is the difference between a debuggable incident and a mysterious bill.
The takeaway
An agent that cannot loop forever is not a smarter agent - it is a better-engineered one. The model contributes the reasoning; the loop guard contributes the memory that this exact reasoning has already been tried. Keep that machinery small, deterministic, and legible, and the failure mode that used to arrive as a billing alert instead arrives as a single line in your logs: loop guard tripped, model redirected, task continued. That line is what “keeping a human in the loop without slowing the loop down” actually looks like in code.