DEV Community

Cover image for Your AI Agent Called the Same Tool 40 Times — Detecting Loops in TypeScript
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your AI Agent Called the Same Tool 40 Times — Detecting Loops in TypeScript


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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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 };
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Four loop shapes: identical repeat, drift, ping-pong, and<br>
no-progress.

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,
  }));
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }; },
});
Enter fullscreen mode Exit fullscreen mode

"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,
});
Enter fullscreen mode Exit fullscreen mode

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.

A stuck run identified by repeat count and a progress flag rather than by<br>
hitting the<br>
cap.

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.

AI That Acts — Tool Calling in TypeScript

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)