- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A turn cap is the standard advice and it is necessary. It is also a blunt
instrument: by the time it fires you have already paid for every turn up to
the limit, and the user gets a partial answer with no explanation of why the
agent gave up.
Loops have shapes. Each shape has a cause, and detecting it as it forms lets
you break it with information rather than with a limit, which usually
salvages the run instead of ending it.
Shape one: the identical repeat
The same tool, the same arguments, over and over.
search_docs { query: "refund policy" } → 0 results
search_docs { query: "refund policy" } → 0 results
search_docs { query: "refund policy" } → 0 results
Almost always caused by an empty or unhelpful result. The model reads "no
results" as a transient failure and retries, exactly as a person might.
Detection is a hash of name plus arguments:
const sig = (b: ToolUseBlock) =>
`${b.name}:${createHash("sha1")
.update(JSON.stringify(b.input, Object.keys(b.input as object).sort()))
.digest("hex").slice(0, 12)}`;
export class LoopDetector {
private seen = new Map<string, number>();
observe(b: ToolUseBlock): Signal | null {
const k = sig(b);
const n = (this.seen.get(k) ?? 0) + 1;
this.seen.set(k, n);
if (n >= 3) return { kind: "identical", tool: b.name, count: n };
return null;
}
}
Sorted keys matter — the same call with properties in a different order must
hash the same, or the detector never fires.
Break it by telling the model what it is doing:
if (signal?.kind === "identical") {
results.push(errorResult(block.id,
`You have called ${signal.tool} with these exact arguments ` +
`${signal.count} times and received the same result. It will not ` +
`change. Either try different arguments or tell the user what you ` +
`could not find.`));
continue;
}
That message resolves the loop far more often than a cap does, because it
gives the model a reason and two concrete options.
Shape two: the drift
Slightly different arguments each time, converging on nothing.
search_docs { query: "refund policy" }
search_docs { query: "refunds policy" }
search_docs { query: "policy for refunds" }
search_docs { query: "refund rules" }
Each signature is unique, so the identical-repeat detector never fires. This
is the shape that quietly burns twenty turns.
Detect on tool frequency rather than exact repetition:
observe(b: ToolUseBlock): Signal | null {
const byTool = (this.tools.get(b.name) ?? 0) + 1;
this.tools.set(b.name, byTool);
if (byTool >= 5 && !this.progressed) {
return { kind: "drift", tool: b.name, count: byTool };
}
// ...
}
progressed is the important part — five calls to the same tool is fine if
each one returned something new. Track whether results are actually changing:
markResult(name: string, out: unknown) {
const h = sha1(JSON.stringify(out));
const prev = this.lastResult.get(name);
if (prev && prev !== h) this.progressed = true;
this.lastResult.set(name, h);
}
Shape three: the ping-pong
Two tools alternating without either producing a result the other can use.
get_order → not found
search_orders → 3 results
get_order → not found (wrong id again)
search_orders → 3 results
Detect on the recent sequence rather than on counts:
private recent: string[] = [];
observe(b: ToolUseBlock): Signal | null {
this.recent.push(b.name);
if (this.recent.length > 6) this.recent.shift();
if (this.recent.length === 6) {
const [a, c] = this.recent;
const alternating = this.recent.every((t, i) => t === (i % 2 ? c : a));
if (alternating && a !== c) return { kind: "pingpong", tools: [a, c] };
}
return null;
}
The break here is usually to hand back the missing link explicitly — the ids
from the search result, formatted so the next get_order can succeed.
Shape four: no progress at all
The subtlest one. Tools vary, arguments vary, and the agent's state is not
changing — nothing has been found, decided, or written.
export function stateFingerprint(s: RunState): string {
return sha1(JSON.stringify({
found: s.found.length,
decided: Object.keys(s.decisions).sort(),
written: s.written.length,
}));
}
Fingerprint after each turn. Three identical fingerprints in a row means the
agent is busy and not advancing, which is exactly what a turn cap eventually
catches, several expensive turns later.
This is the detector worth having if you only build one, because it covers the
loop shapes you have not thought of.
Wire it into the loop
const detector = new LoopDetector();
const prints: string[] = [];
while (turns < maxTurns && cost < budget) {
const res = await client.messages.create({ /* ... */ });
turns++;
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const signal = detector.observe(block);
if (signal) {
metrics.increment(`agent.loop.${signal.kind}`);
results.push(errorResult(block.id, adviceFor(signal)));
continue;
}
const out = await execute(block, ctx);
detector.markResult(block.name, out);
results.push(out);
}
prints.push(stateFingerprint(state));
if (prints.slice(-3).every((p, _, a) => p === a[0]) && prints.length >= 3) {
return stop("no_progress", state);
}
}
Note the detector returns an error result rather than throwing. The turn
continues; the model gets told; the run usually recovers.
Give the model an exit
A loop often persists because the agent has no acceptable way to fail. Provide
one as a tool:
const giveUp = tool({
name: "report_blocked",
description:
"Call this when you cannot make progress. Say what you tried and what " +
"information you would need. This is a correct outcome, not a failure.",
schema: z.object({
tried: z.array(z.string()).min(1),
needed: z.string(),
}),
async run(a) { return { acknowledged: true, ...a }; },
});
"This is a correct outcome, not a failure" does real work in that description.
Without an explicit exit, a model instructed to be helpful keeps trying,
because stopping reads as failing.
The arguments are useful to you too: needed aggregated across runs is a list
of the tools or data your agent is missing.
What to log
logger.info("agent turn", {
runId, turn: turns,
tools: toolNames,
repeatMax: detector.maxRepeat(),
progressed: detector.progressed,
costUsd: cost,
});
repeatMax and progressed are the two fields that make a stuck run obvious
in a log search. A run with repeatMax: 9, progressed: false needs no further
investigation to classify.
Track the loop-signal rate per tool as well. A tool that triggers loop
detection constantly is usually a tool-design problem — an unhelpful empty
result, a missing "no matches" distinction, or a description that promises
more than it delivers.
Keep the cap
None of this replaces the turn and budget ceilings. Detection handles the
loops you can name; the cap handles the ones you cannot.
The difference is that with detection, the cap becomes the rare backstop it
should be rather than your primary control, and most stuck runs end with a
useful answer instead of a truncated one.
If this was useful
AI That Acts covers the agent loop and
its failure modes — tool design that avoids loops, detection, error results as
a correction channel, and the guards that keep a first agent affordable.
The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)