AI Engineering
tutorial
Featured

Your Agent Called the Same Tool Seventy Times and Billed You for It

A ReAct-style agent calls the same search tool, gets the same unhelpful result, decides another identical call will help, and does it again. Seventy times. It never crashes and never finishes, it just burns tokens going nowhere until a timeout or your bill catches it. A hard step ceiling is a backstop, not a fix. What you want is a guard that notices the agent has stopped making progress and breaks the cycle in seconds.

Viral Ruparel
11 min read
Share:

A ReAct-style agent in production called one tool, search_knowledge_base, seventy-three times in a single conversation. Same tool, essentially the same query, the same unhelpful result each time, and after each result the model looked at the situation and decided that calling it once more was the reasonable next move. It burned about forty-seven thousand tokens doing this. It never errored. It never finished. It just circled the drain until something upstream timed out and a user saw a spinner that never resolved.

That is not an exotic edge case. Step repetition is the single most common way agents fail in production, somewhere around one in six of all failures. And it is the most expensive kind of failure, because a crash at least stops billing you. A loop keeps the meter running while producing nothing, and it does it silently, so you find out from a cost alert or a support ticket instead of an exception in your logs.

The frustrating part is that the fix is small. You do not need a smarter model or a rewritten prompt. You need a guard that watches for the one thing a stuck agent always does, which is repeat itself, and cuts the cycle the moment it sees it.

Why agents loop in the first place

Loops are not random. They come from a few specific, recurring causes, and it helps to name them because the fix depends on knowing you are actually stuck versus just working hard.

The most common cause is ambiguous tool feedback. A search tool returns "no exact match, more results may be available," and the model reads that as an invitation to try again. Nothing in the result says "stop," so the agent, which is optimizing for being helpful, tries the same call hoping the "more results" materialize. They never do, because the tool is deterministic, but the model has no memory that it already asked.

The second cause is a planner prompt that rewards persistence without a budget. If your system prompt says "keep working until the task is complete" and never defines what "give up" looks like, you have told the agent that quitting is failure and retrying is virtue. So it retries.

The third is a missing stop condition in the tool itself. The agent asks for a record that does not exist, the tool returns an empty set instead of a clear "this does not exist and will not exist," and the agent reads emptiness as "I must be querying wrong," reformulates slightly, and tries again, forever.

None of these are model intelligence problems. They are harness problems. The model is behaving rationally given the feedback it gets, and the harness is what decides when rational persistence has become a loop. By default most harnesses decide that far too late, if at all.

The step ceiling is a backstop, not a fix

Almost everyone ships some version of this first. It is the agent loop with a maximum step count, and it is not wrong, it is just not enough.

// The loop everyone starts with. The only guardrail is a step ceiling.
async function runAgent(task: string, tools: ToolMap) {
  const messages: Message[] = [{ role: "user", content: task }];
  const MAX_STEPS = 25;

  for (let step = 0; step < MAX_STEPS; step++) {
    const reply = await model.complete(messages, { tools });
    if (reply.toolCall) {
      const result = await tools[reply.toolCall.name](reply.toolCall.args);
      messages.push(reply, toolMessage(reply.toolCall, result));
      continue; // straight back to the model, no questions asked
    }
    return reply.content; // finished
  }
  throw new Error("Agent exceeded step limit"); // fires at 25, not at 3
}

The ceiling does one useful thing: it guarantees the process eventually ends. What it does not do is notice that the agent stopped making progress at step three and spent the next twenty-two steps repeating itself. It also cannot tell an honest twenty-step task apart from a five-step task stuck in a four-step loop, so tuning it is a lose-lose. Set it high enough for real work and you tolerate expensive loops. Set it low enough to kill loops fast and you decapitate legitimate long-running tasks. The ceiling is the wrong instrument for this because it measures quantity of steps, and the thing you actually care about is whether the steps are going anywhere. I wrote about the broader version of this waste, deadlines and cancellation propagating through a run, in Your Agent Kept Working After Everyone Stopped Waiting. Loop detection is the finer-grained sibling: instead of "this run has taken too long," it asks "has this run learned anything in the last few steps."

Detect the loop by fingerprinting each call

The core insight is that a stuck agent produces a repeating signature. If the same tool is called with the same arguments and gets the same result, the agent has gained no new information, and calling it again cannot possibly help. So you fingerprint each call and watch for repeats.

import { createHash } from "node:crypto";

// A compact fingerprint of a single tool interaction. Arguments AND a result
// digest go into the key, so a retry that returns something new, or a
// paginated call with a changing cursor, does NOT look like a repeat.
function fingerprint(name: string, args: unknown, result: unknown): string {
  const canonical = JSON.stringify({ name, args, result: digest(result) });
  return createHash("sha1").update(canonical).digest("hex");
}

// Hash the result rather than storing it whole. Cheap to compare, and it means
// "same call, same answer" collapses to one stable key.
function digest(result: unknown): string {
  return createHash("sha1").update(JSON.stringify(result ?? null)).digest("hex");
}

class NoProgressGuard {
  private recent = new Map<string, number>(); // fingerprint -> times seen
  // Some tools are meant to be polled. Give them room instead of false alarms.
  constructor(private limits: { default: number; perTool: Record<string, number> }) {}

  // Returns true when this exact interaction has repeated past its threshold.
  record(name: string, args: unknown, result: unknown): boolean {
    const key = fingerprint(name, args, result);
    const seen = (this.recent.get(key) ?? 0) + 1;
    this.recent.set(key, seen);
    const limit = this.limits.perTool[name] ?? this.limits.default;
    return seen >= limit;
  }
}

The subtlety that makes this usable in production rather than a source of false alarms is what goes into the key. Arguments are in there, so pagination with a moving cursor never collapses to a repeat. A result digest is in there, so a genuine retry after a timeout, which is the same call producing a different result, does not trip the guard either. What trips it is exactly the failure you want to catch: same tool, same arguments, same result, showing up again, which is the mathematical definition of the agent having learned nothing.

The per-tool limits handle the legitimate exception. A check_job_status tool is supposed to be called repeatedly while a job runs, so you give it a threshold of ten or leave it out of the guard entirely. A search tool that returns the identical result twice is already stuck, so it gets a threshold of two. You are not turning the guard off for pollers, you are telling it what normal looks like for each tool.

Catch the subtler stall where the action changes but nothing does

Exact repetition is the easy case. The harder one is an agent that varies its calls slightly, rephrasing the query, tweaking a filter, trying a different tool, and still gets nowhere. No single fingerprint repeats, so the tuple guard stays quiet, but the run is just as stuck. For this you track progress at the level of the whole run, not the individual call.

// A run-level progress signal. If N consecutive steps produce no change in the
// agent's working state, the trajectory has converged on nothing.
class ProgressTracker {
  private lastStateDigest: string | null = null;
  private stalledSteps = 0;

  // `state` is whatever concretely represents progress in your domain: the set
  // of facts gathered, files written, fields filled, a task checklist.
  step(state: unknown, maxStalled: number): boolean {
    const d = digest(state);
    if (d === this.lastStateDigest) {
      this.stalledSteps++;
    } else {
      this.stalledSteps = 0; // real change resets the counter
      this.lastStateDigest = d;
    }
    return this.stalledSteps >= maxStalled;
  }
}

The key decision here is what you feed in as state. It should be the concrete artifact the task is supposed to move, not the conversation transcript, because the transcript changes every turn even when nothing real is happening. For a research agent, state is the set of facts it has gathered. For a form-filling agent, it is the fields populated so far. For a coding agent, it is the files it has modified. When that digest stops changing across several steps, the agent is spinning even though its messages look busy, and the tracker catches it where the fingerprint guard cannot. This is the same discipline as validating tool calls at the boundary rather than trusting the model to behave, which I went into for malformed and failed calls in Your Agent's Tool Calls Fail and It Just Keeps Going.

Break the cycle instead of just killing it

Detection is half the job. The other half is what you do at the moment the guard fires, and the instinct to immediately terminate is the wrong one, because you already paid for the run and throwing it away wastes that spend. Escalate through a ladder instead, and terminate only when the ladder runs out.

async function runAgentGuarded(task: string, tools: ToolMap, getState: () => unknown) {
  const messages: Message[] = [{ role: "user", content: task }];
  const guard = new NoProgressGuard({ default: 2, perTool: { check_job_status: 10 } });
  const progress = new ProgressTracker();
  const HARD_CEILING = 40; // last-resort backstop, well above normal runs
  let interventions = 0;

  for (let step = 0; step < HARD_CEILING; step++) {
    const reply = await model.complete(messages, { tools });
    if (!reply.toolCall) return { status: "ok", output: reply.content };

    const { name, args } = reply.toolCall;
    const result = await tools[name](args);
    messages.push(reply, toolMessage(reply.toolCall, result));

    const looping = guard.record(name, args, result);
    const stalled = progress.step(getState(), 4);
    if (!looping && !stalled) continue; // making progress, carry on

    // Ladder step 1 and 2: nudge, then take the offending tool away.
    if (interventions < 2) {
      interventions++;
      messages.push({
        role: "system",
        content:
          `The call to "${name}" repeated without making progress. That path is ` +
          `not working. Do not call it again with the same input. Try a different ` +
          `approach, or if the task cannot be completed, say so and stop.`,
      });
      if (interventions === 2) delete (tools as Record<string, unknown>)[name];
      continue;
    }

    // Ladder step 3: give up cleanly with a structured, handleable failure.
    return {
      status: "stopped_no_progress",
      reason: `Repeated "${name}" without progress after ${interventions} interventions`,
      partial: getState(),
    };
  }
  return { status: "stopped_ceiling", reason: "Hit hard step ceiling", partial: getState() };
}

Three things make this production-grade rather than a blunt kill switch. First, the cheapest intervention comes first: a plain system message telling the agent the path is dead. In practice most loops break right here, because the model was never being stubborn, it just did not know it was repeating, and once told, it picks a different tool. Second, if the nudge fails, you remove the offending tool for the next turn, which forces a genuine change of strategy instead of hoping for one. Third, when you do finally terminate, you return a structured result with a reason and whatever partial state was gathered, not a generic timeout, so the caller can retry differently, escalate to a human, or surface a real message to the user. A loop that ends in stopped_no_progress with partial results is recoverable. A loop that ends in a swallowed timeout is a mystery.

The parts people get wrong

Hashing over nondeterministic fields. If your tool results include a timestamp, a request id, or a latency number, every result is unique and the fingerprint never repeats, so the guard silently does nothing. Strip volatile fields before you digest, or digest only the semantically meaningful part of the result. The guard is only as good as the stability of the thing it hashes.

Treating every poll as a loop. A status-check or long-poll tool is supposed to repeat, and if you run it through the default threshold you will kill healthy runs and conclude the guard is broken. That is what the per-tool limits are for. Enumerate your legitimately repeating tools once and give them headroom, rather than lowering your guard everywhere to accommodate them.

Killing without diagnosing. If the guard terminates a run and emits nothing, you have traded a visible loop for an invisible one, because now the run just ends and you do not know why. Every time the guard fires, emit an event with the tool, the repeat count, and the intervention that broke it or failed to. That signal tells you which tools loop most and which prompts induce it, and it is the same signal you would want in your traces anyway. Detection and observability are two uses of one event, not two systems.

Setting the stall window too tight. A legitimate task can genuinely produce no change in working state for a step or two, for example while the agent reasons before acting. If maxStalled is one, you will cut real work. Give it a few steps of slack so a brief pause reads as thinking, and tune it against your own traces rather than guessing.

The takeaway

A looping agent is the worst failure mode you can ship, not because it is hard to fix but because it hides. It does not throw, it does not crash, it just quietly repeats itself and charges you for the privilege until a timeout or a bill notices. A step ceiling alone lets that run for twenty wasted iterations before it acts. A no-progress guard acts in two or three, because it watches the one thing a stuck agent always does, which is fail to make progress, and it breaks the cycle with a nudge before it ever has to resort to killing the run. It is a fingerprint, a counter, and an escalation ladder, a couple hundred lines total, and it turns your most expensive silent failure into a cheap, logged, recoverable event.

If your agents run long enough to loop and you have no signal for when they stop making progress, that gap is where your token bill and your reliability quietly leak out at the same time. Book a consultation call and we can look at where your agents stall and put a guard between that stall and your invoice.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Isn't a hard step limit enough to stop a looping agent?+

A step limit stops the bleeding, but it stops it late and it tells you nothing. If your ceiling is twenty-five steps, an agent that starts looping at step three still burns twenty-two useless iterations before the backstop fires, and each of those iterations is a full model call plus a tool call you paid for. Worse, the ceiling cannot tell the difference between an agent doing twenty steps of real work and an agent repeating one step twenty times, so you either set it high and tolerate expensive loops or set it low and kill legitimate long tasks. A no-progress guard fires the moment progress actually stops, usually within two or three wasted steps instead of twenty, and it hands you the reason. Keep the hard ceiling as a last-resort backstop and put the guard in front of it.

How do I tell a real loop apart from legitimate retries or pagination?+

The difference is whether the arguments or the results change. A legitimate retry after a timeout is the same call producing a different result, and pagination is the same tool with a changing cursor argument, so neither hashes to a repeated tuple if you include arguments and a result fingerprint in the key. The loop you want to catch is the same tool, the same arguments, and the same result appearing again, which means the agent has learned nothing and is about to not learn it a third time. Whitelist the tools you expect to poll and give them a higher repeat threshold rather than turning the guard off, so a status-check tool can poll ten times while a search tool that repeats twice gets stopped.

What should the agent do when the guard fires, just quit?+

Quitting immediately wastes the run you already paid for, so escalate before you terminate. The first intervention is the cheapest: inject a system message telling the agent that the last call made no progress and that repeating it will not help, which is often enough to knock it onto a different path. If it loops again, force a change of strategy by restricting or removing the offending tool from the next turn. Only if that also fails do you terminate, and when you terminate you return a structured failure the caller can handle, not a generic timeout. The ladder matters because most loops break at the first nudge and never reach termination.

Where should the call ledger live in a distributed agent?+

Keep it in the same place you keep the rest of the run's state, keyed by the run or trace id, so it survives across the process boundaries a distributed agent crosses. For a single-process loop an in-memory ring buffer of the last several tool calls is enough, since you only care about recent history, not the whole trajectory. For an agent that spans workers or resumes from a checkpoint, store the recent-call fingerprints alongside the durable state so the guard still sees the repeat after a handoff. The ledger is small, a few hashes and counts, so it costs almost nothing to carry.

Does loop detection replace observability and tracing?+

No, they solve different halves of the same problem. The guard is a runtime control that acts on a loop while it is happening, and tracing is the record that tells you why it happened after the fact. You want both, and they share a signal: the same no-progress event that triggers the guard should emit a span or a metric so you can see which tools loop most, which prompts induce it, and whether a model or tool change made it better or worse. The guard without tracing stops loops blindly. Tracing without the guard watches them cost you money in real time.