Chapter 4 of 7 · 20 min read
Hash the call, not the text, to know the agent is stuck
On this page
An agent that is stuck does not look stuck from inside its own loop. It emits a well-formed tool call, a harness dispatches it, a result comes back, and the loop turns again, exactly as it does on a run that is working. Nothing in that cycle is an error, so none of the machinery built in the previous chapters fires: a classifier that separates transport failures from semantic ones sees no failure at all, a circuit breaker that counts a dependency's consecutive failures counts none, and budgets on steps, tokens, wall clock and money see a run consuming its allowance at exactly the rate they were configured to permit. The run therefore ends when the allowance ends, which is late, and which is by construction the most expensive moment at which it could have ended.
Loop detection closes that gap. It is a small record of what a run has already done, consulted before each new action, so that a repeat can be recognised at the moment it repeats rather than at the moment the money runs out. The record is a set of keys, a key being a short string standing for one action, and the entire design question is which parts of an action the key is computed from. Getting that wrong does not produce an instrument that is merely inaccurate. It produces one whose reading is fixed before the run begins, and a fixed reading that happens to look like good news is worse to own than no instrument at all.
A run that spent its whole allowance on one question
An on-call triage agent has a 40-step budget and a $3.00 per-run cap, both of which are generous on purpose, because working out why checkout latency doubled at 02:00 legitimately takes a lot of looking around. Here is the middle of a run that used all forty:
step 04 get_logs {"service":"checkout","since":"15m","level":"error"} -> []
step 05 get_logs {"service": "checkout", "level": "error", "since": "15m"} -> []
step 06 get_logs {"since":"15m","service":"checkout","level":"error"} -> []
step 07 get_logs {"service":"checkout","since":"15m","level":"error"} -> []
...
step 38 get_logs {"service":"checkout","level":"error","since":"15m"} -> []
step 39 get_logs {"service":"checkout","since":"15m","level":"error"} -> []
step 40 budget_exhausted:stepsThe log service had been dropping error-level records for that namespace since a config change, so the honest answer was an empty list, and the model read an empty list as evidence that it had asked the wrong question rather than as evidence that there were no records. Thirty-six of the forty steps were the same call. The money budget from the previous chapter did its job and the step budget got there first, ending the run at step 40 having spent $2.68 on a state that had not changed since step 4. Detecting the repetition on the third occurrence would have ended it at step 6 for $0.31, which is a factor of eight, and it would have ended it with a diagnosis — "the agent asked the same question nine times and got the same answer" — rather than with a budget code that says only that the allowance ran out.
Two of those figures are worth taking apart, because between them they are the
whole argument for detecting repetition at all rather than leaving it to a
budget. The repeated span begins at step four, where the first get_logs went
out, and ends at step thirty-nine, where the last one did; step forty is the
budget's own entry in the trace and not a call. Counting inclusively from the
first of those steps to the last is what gives the count above, and it comes to
nine steps in every ten of the allowance spent asking one question that had
already been answered.
The cost does not scale the way a first reading suggests, and the direction it misses in is the useful one. Every step re-sends the whole transcript that precedes it, so the input side of each model call is larger than the input side of the one before, and a loop is therefore more expensive per step at its end than at its beginning. Work the two figures against each other and the asymmetry shows: the six steps that would have survived early detection are rather more than a seventh of the forty, while the money they cost is less than an eighth of the total. The share of steps saved and the share of money saved are different numbers, and the money is the larger saving. Stopping a loop early is worth more than the step counts imply, and the longer the loop the wider that gap grows.
The state that repeats is the call, not the conversation
A working agent is doing a search: it is in some state, it takes an action, it lands in a new state, and it repeats until it reaches a goal state. Every graph search algorithm you have written carries a visited set for exactly this reason, and a search without one will happily walk a cycle forever. The agent loop from topic 10.3 has no visited set, and adding one is the whole idea, so the only real question is what counts as a state.
That comparison to graph search is an analogy, and it is worth saying where it stops. In a graph search the states are enumerable and the visited set is exact: two states are the same state or they are not, membership is decidable, and termination can be proved. An agent's state is not enumerable — it takes in the model's context, the world the tools reach into, and whatever the model has concluded and not written down — so no key can be exact, and every key is a lossy summary chosen by whoever wrote it. The analogy is right that a search without a visited set can walk a cycle forever. It is wrong that adding one settles the matter. What it settles is the shape of the instrument; how well that instrument reads has to be argued separately.
The message list is the obvious candidate and the wrong one. It grows monotonically — every step appends the assistant turn and the results — so no two steps ever have the same conversation, and a visited set keyed on it would never register a hit. Hashing the model's text output is no better, because a model rephrases: "let me check the error logs" and "I'll look at the checkout errors" precede identical calls. What actually repeats, and what determines what happens next in the world, is the action: the tool name plus the arguments. Steps 4 through 7 above are four different JSON strings, four different assistant messages, and one action.
The first of those two rejected candidates fails in a way worth stating precisely, because the same shape turns up twice more before this topic is finished. The message list at any step contains the message list at the step before it and at least one message more. Two lists of different lengths cannot be equal. So a key computed from the conversation differs at every step — not usually, not in practice, but by construction, for every agent, on every task, for as long as the loop runs. A detector built on it reports no repeat on a healthy run and no repeat on the run above, which spent nine steps in ten asking one question. Its output is constant while the property it is meant to measure varies without limit.
The general form is what to carry away, because it recurs. A detector keyed on a quantity that cannot repeat certifies that the quantity did not repeat, and certifies nothing whatever about the run. It is worse than useless in a specific way: a hit count pinned at zero is exactly what a healthy fleet looks like, so the broken instrument and the working one produce the same reading, and there is nothing on the dashboard to notice. Hashing the model's prose fails from the other side of the same argument — the prose can repeat, but it is free to vary while the action stays fixed, so a difference between two prose keys is no evidence that anything about the run differs.
The sentence that closes the paragraph above is the one to hold on to: four different JSON strings, four different assistant messages, and one action. The three descriptions disagree because they are descriptions of three different things, and only one of them is a description of what will happen. Two are records of how the model phrased itself. The third names what the harness is about to do, and the world is only ever touched through a tool call, which is fully determined by its name and its arguments and by nothing else in the transcript. That is the whole case for keying on the action: it is the smallest thing a run does that the world can tell apart.
Turning that action into a stable key is a normalisation problem, and the hash is only the cheap container you put the result in.
import hashlib, json
VOLATILE_KEYS = {"request_id", "trace_id", "timestamp", "now", "nonce", "idempotency_key"}
def normalise(value):
if isinstance(value, dict):
return {k: normalise(v) for k, v in sorted(value.items())
if k not in VOLATILE_KEYS and v is not None}
if isinstance(value, list):
return [normalise(v) for v in value]
if isinstance(value, str):
return " ".join(value.split())
if isinstance(value, float) and value.is_integer():
return int(value)
return value
def call_key(name: str, args: dict) -> str:
payload = json.dumps({"tool": name, "args": normalise(args)},
sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode()).hexdigest()[:16]Key ordering is the one that fires most often in practice, because models emit JSON object keys in whatever order the sentence they were composing happened to produce, and steps 5 and 6 above differ from step 4 in nothing else. Whitespace normalisation catches "checkout " against "checkout". Dropping nulls catches the model that sometimes includes "level": null and sometimes omits the field, which is the same call twice.
Every one of those transformations is permitted for the same reason, and the
reason is worth stating once rather than four times. A tool receives its
arguments as a mapping from names to values, and a mapping has no order; JSON
writes one down in some order only because a file is a sequence of bytes, and
the tool parses it back into a mapping and cannot tell which order it arrived
in. Sorting the keys therefore discards something the tool never saw. The same
test licenses the rest. Trailing space in "checkout " never reaches a backend
that trims it. An absent field and a field explicitly set to null produce
identical behaviour in a tool whose parameter is optional. A float that happens
to be a whole number selects the same rows as the integer. So the question to
ask of a new normalisation is not whether it looks tidy. It is whether the tool
could have behaved differently on the two inputs, and if it could, collapsing
them is a lie the detector will later tell you.
What you exclude is a judgement about which arguments carry state and which are noise, and getting it backwards in either direction breaks the detector in a way that is hard to see from the outside.
| Argument kind | In the hash? | Consequence of the wrong choice |
|---|---|---|
| Request or trace ID, nonce, idempotency key | No | Every call is unique; the detector never fires |
| Wall-clock timestamp injected by your wrapper | No | Same as above, and it is your code creating the uniqueness |
A relative window the model wrote (since: "15m") | Yes | It is part of the question being asked |
| Pagination cursor or page number | Yes | Excluding it turns paging through 40 pages into 40 identical calls |
| Free-text query | Yes, after whitespace normalisation | Excluding it makes every search look like every other search |
| Sort order, limit, output format | Yes | Excluding them hides a model flipping between two renderings of one query |
| Case of an identifier | Preserve it | Lowercasing Acc_7741 merges two distinct records for some backends |
Read that table down the middle rather than across it and there are only two distinct failures in it, one at each end. The key is a function of the arguments you decided to keep, and two calls collide exactly when they agree on every kept argument. Keep an argument that is fresh on every call — a request ID, a nonce, a timestamp your own wrapper stamped on the way past — and no two calls can agree on it, so the function separates everything it is given and the hit count is zero on every run there has ever been. Drop an argument that carries the state being advanced — a cursor, a page number — and calls that differ only in that argument collapse onto one key, so the hit count saturates on every run, including the healthy paging in the fourth row. Each extreme reproduces the conversation key's defect with the sign reversed: one detector never fires and one fires always, and neither reading depends on how the run actually went. The question to ask of any candidate key is not whether it is precise. It is whether its value could have come out differently had the run gone differently.
The pagination row is the one worth dwelling on, because it is the first hint of the distinction the next chapter is built on. list_orders(cursor="p3") followed by list_orders(cursor="p4") is not a loop, and the reason is not that the arguments differ — it is that something advanced. Excluding the cursor would produce false positives on healthy paging; including it produces true negatives on an agent that pages forever through a result set that never ends. The hash is right about the first case for a reason that does not generalise to the second.
That reason is narrow enough to state exactly, and it sets the ceiling on everything this chapter can do. A hash supports one question: are these two keys equal. The question has two answers, and not equal is the answer it returns for a cursor stepping from one page to the next and equally for a search query being reworded a fourth time. One of those runs is advancing through a result set and the other is circling, and the instrument gives the same verdict on both, because it can see difference and cannot see direction. So call identity certifies repetition and never certifies progress, and every consequence of that asymmetry is what the chapter after this one is for.
Stop before the duplicate runs, and nudge once first
A visited set that only reports is not a control, so the check belongs between the model's response and the dispatch, where refusing to execute is still possible.
recent: deque[str] = deque(maxlen=12)
for call in calls:
key = call_key(call.name, call.input)
repeats = recent.count(key)
if repeats >= 2:
# Third occurrence: do not run it.
return terminal(reason="loop_detected", tool=call.name,
repeats=repeats + 1, budget=budget, messages=messages)
if repeats == 1:
results.append({
"type": "tool_result", "tool_use_id": call.id, "is_error": True,
"content": (f"This is the second identical call to {call.name} with "
f"the same arguments, and the result has not changed. "
f"Do not repeat it. Either use different arguments, use "
f"a different tool, or report what you have so far.")})
recent.append(key)
continue
recent.append(key)
results.append(dispatch(call))That placement is not a preference, and the argument for it is short. A tool call has already had its effect by the time a result exists, so anything checking results can report a repeat and can never prevent one. The model's response, by contrast, names the call before the harness has dispatched it, and in the interval between those two moments the harness is holding the only copy of an action that has not happened yet. An action that has not happened is the only kind that can be refused, so that interval is where a control has to sit. Anywhere earlier there is nothing to check; anywhere later there is nothing left to decide.
The two-stage shape matters more than the exact thresholds. Terminating on the second occurrence is too eager, because a legitimate re-read after a write — call the deploy tool, read the status back to confirm — is a duplicate by every measure this detector has. Terminating on the third without warning throws away the cases where the model simply needed to be told, and on replayed traces from this agent the nudge produced a different, useful call about 40% of the time, most often a widened time window or a switch to a different log level. The other 60% emitted the same call again and were terminated at step 6 or 7 with a reason code naming the tool.
Those two proportions are complementary and between them they price the extra stage. Two runs in every five recovered on being told; the remaining three in five repeated themselves once more and were stopped a step or two later. So the nudge costs one additional step on every stuck run, and on two runs in five it buys back the entire remainder of the allowance. Terminating on the second occurrence would win that one step back and forfeit those two runs in five, which is a losing trade at any setting where the rest of the allowance is worth more than a single step — and on the run at the top of this chapter, the rest of the allowance was worth more than thirty of them.
The window is bounded at twelve entries because an unbounded set makes long, legitimate runs progressively more likely to trip: an agent that reads the same config file at step 3 and again at step 31, with thirty steps of real work in between, is not looping, and a deque(maxlen=12) forgets the first read before the second one arrives. Twelve is roughly one and a half times this agent's p95 useful-work span; the general rule is that the window should be long enough to contain a tight cycle and short enough to forget an old visit.
That general rule is a pair of inequalities rather than a value, and writing it that way is what lets it survive being carried to another agent. A cycle in which the agent alternates between some number of distinct actions before coming back round is only visible if the window is at least as long as that cycle, because otherwise the first visit has been evicted by the time the second arrives. A legitimate revisit separated by some number of steps of real work is only tolerated if the window is shorter than that separation. Any length between the longest cycle you want to catch and the shortest legitimate gap you want to allow will therefore do. The case worth naming is the one where no length works, because the longest cycle exceeds the shortest gap. It does not announce itself as a tuning problem: it presents as a detector that is somehow both too eager and too blind, and no window length is the fix.
loop_detected is a distinct terminal reason from budget_exhausted, and keeping them separate is worth the extra enum value. The first says the agent was stuck and names the action it was stuck on, which points at a tool description, an empty result the model cannot interpret, or a dependency returning nothing. The second says the task was too big for the allowance. Collapsing them into "the run failed" throws away the only signal that distinguishes a bug in your tool surface from a task that genuinely needed more room.
The key earns its keep a second time as a per-run result cache for read-only tools. Hashing and comparing costs a few microseconds against a model round trip measured in seconds and cents, so the check is free in any accounting that matters, and once the key exists, serving the second identical get_logs from a dictionary rather than the log service is a small change with two effects. The dependency stops being asked the same question, and the repeat becomes visible in one place instead of being spread across the trace. Write tools stay out of that cache for the obvious reason, and read tools whose answers change on a timescale shorter than a run — anything asking about a build, a queue depth, or a live metric — have to stay out of it too, which is the same boundary the next chapter has to draw for a different purpose.
Those two exclusions look alike and they are not the same kind of rule, which
matters when somebody adds the next tool. Keeping write tools out of the cache
is a requirement: serving a second issue_refund from a dictionary would mean
the second refund silently did not happen, and the cache would then be lying
about the world rather than about a log query. Keeping fast-moving read tools
out is a design decision. A cached build status is correct for the instant it
was filled and wrong afterwards, and how wrong depends entirely on how quickly
that particular thing moves, which is a judgement about the tool and not a
property of caches. Requirements can be left to be inferred. Design decisions
have to be written down beside the tool, because the person adding the next one
has nothing to infer them from.
None of this addresses why the agent was stuck. Loop detection is a tripwire, and the run that tripped it was asking a reasonable question of a tool that had quietly stopped returning records, so the durable fix lives in the tool: an empty result that says no error records for checkout in the last 15m; this namespace last ingested at 01:12 gives the model something to reason about, and the model that received [] nine times had nothing to work with except the suspicion that it had phrased the query wrong. Every loop_detected termination is worth reading as a report about your tool surface, and on this agent the top three offenders over a month were all tools that returned an empty collection where a sentence would have ended the confusion in one step.
Difference is not direction
The false positive above has a pair of counterparts that run the other way, and they are the harder half, because in those the detector stays silent while the run is genuinely stuck.
There is also a category the hash never sees at all, whitelist or no whitelist. An agent that calls search_docs("refund policy"), then search_docs("refund policy for annual plans"), then search_docs("annual plan refund rules") produces three distinct keys and is asking one question three ways. An agent that alternates read_file and edit_file on the same file, undoing its own change each time, produces two keys alternating and is going backwards. The visited set is right that the states differ and wrong about what that means, because differing states are not the same thing as making progress.
The second of those is the more instructive, and it is worth walking. An agent that alternates a write with its own undo is doing work by every measure the harness holds: each call is dispatched, each returns cleanly, each changes a file on disk. The keys differ because the actions genuinely differ — one adds a line and one removes it — so the detector is answering its question correctly and its correct answer is no repeat. The net effect of a full cycle is nothing at all. A correct answer to the wrong question is the most expensive kind of wrong, because there is no bug to find and nothing to fix in the detector itself.
Both of those failures and the false positive above come out of one boundary. A key is computed entirely from the request, and nothing in it depends on the response, which is why a poll on a build that is advancing and a poll on a build that will never start produce identical keys, and why four differently worded searches that all return nothing produce four different ones. Anything you want to know about the response has to be measured on the response.
What the hash buys survives every one of those limits, and it is a great deal for the price: an exact answer to a narrow question, in microseconds, against model calls costing seconds and cents. It turns a run that would have ended with a budget code into one that ends naming the action it was stuck on, and it does so on the third occurrence rather than the thirty-sixth, which on the triage run above is most of the money.
Both the false positive and both false negatives have one shape. Call identity is a proxy, and the thing it is a proxy for is whether the run is getting anywhere. Measuring that directly is more work than a hash, and it is what makes it possible to keep the build poll and still bound it.
Error recovery — read the current version at https://daviddominguez.dev/learn/en/error-recovery/hash-the-call-not-the-text-to-know-the-agent-is-stuck