BlogAgents7 min read

The agent that tried the same thing forty times

Not an infinite loop in the classical sense — a model reading an error, failing to understand it, and trying again with a growing transcript behind it.

An agent calls a tool. The tool returns an error. The agent reads the error, adjusts slightly, and calls again. Same error. Adjusts. Calls again.

At turn forty it is still going. Every turn carries the entire transcript, so turn forty costs perhaps eight times what turn five cost. There is no exception to catch, no infinite recursion, no stack overflow — just a system doing exactly what it was told, forever.

This is not a loop in the control-flow sense. It is a model that cannot tell the difference between "try again differently" and "try again".

Why it costs more the longer it runs

The economics are worse than a normal runaway process, and the reason is structural.

A conventional infinite loop costs a fixed amount per iteration. An agent loop costs more every iteration, because the conversation grows: each failed attempt and each error message is appended and re-sent on every subsequent turn.

Rough shape, at 2,000 tokens per turn added to the transcript:

TurnInput tokensCumulative inputCost at $2/MTok
510,00030,000$0.06
1020,000110,000$0.22
2040,000420,000$0.84
4080,0001,640,000$3.28

Cumulative cost grows roughly with the square of the turn count. Turn 40 is not twice turn 20 — it is four times. Which means the difference between catching this at turn eight and catching it at turn forty is most of the money.

Repeat detection is the cheapest signal

The clearest symptom is literal repetition — the same tool with the same arguments:

function signature(c: ToolCall): string {
  return `${c.name}:${stableStringify(c.args)}`
}

// Three identical consecutive calls. The agent has read an error,
// failed to understand it, and is retrying rather than adapting.
function isRepeating(history: ToolCall[], window = 3): boolean {
  const recent = history.slice(-window)
  if (recent.length < window) return false
  return new Set(recent.map(signature)).size === 1
}

Catch the near-miss too. An agent that alternates between two failing approaches is equally stuck:

// Any signature appearing 3+ times in the last 6 calls, consecutive or not.
function isCycling(history: ToolCall[], window = 6, threshold = 3): boolean {
  const counts = new Map<string, number>()
  for (const c of history.slice(-window)) {
    const s = signature(c)
    counts.set(s, (counts.get(s) ?? 0) + 1)
    if (counts.get(s)! >= threshold) return true
  }
  return false
}

Measure progress, not activity

Repetition catches the obvious case. The subtler one is an agent doing genuinely different things, none of which advance the task — reading file after file, running search after search, exploring rather than converging.

Progress needs a task-specific definition, which is the work:

type Progress = {
  /** Did anything change in the world? */
  sideEffects: number
  /** Did the agent learn anything it did not have before? */
  novelInformation: number
  /** Has it stated a plan and moved through it? */
  stepsCompleted: number
}

// No side effects and no new information across several turns means
// the agent is circling. Different from repeating, same conclusion.
function isStalled(recent: Progress[]): boolean {
  return recent.length >= 5
      && recent.every((p) => p.sideEffects === 0 && p.novelInformation === 0)
}

novelInformation is measurable more easily than it sounds: hash each tool result and count distinct hashes. An agent re-reading files it has already read produces no new hashes, and that is a real, cheap progress signal.

Bound three things, not one

maxTurns alone is a blunt instrument — it either cuts off legitimate long tasks or permits expensive short ones. Three bounds together behave much better:

type RunLimits = {
  maxTurns: number        // 20-30 for most tasks
  maxCostUsd: number      // see cost ceilings
  maxWallClockMs: number  // the one people forget
}

Wall-clock matters because an agent can be slow without being expensive — long tool calls, retries with backoff — and a run holding a worker for forty minutes is a capacity problem even when its token spend is modest.

The budget ceiling covers the money; these two cover the other ways a run can fail to end.

Give it a way to give up

Much of this is caused by an agent having no legitimate way to stop. If the only terminal states are success and crash, a model facing an impossible task will keep trying, because trying is the only available action.

Make failure a first-class outcome the agent can choose:

const GIVE_UP_TOOL = {
  name: "report_blocked",
  description:
    "Call this when you cannot complete the task. This is a correct and " +
    "expected outcome, not a failure on your part. Explain what you tried " +
    "and what is blocking you. Do not keep retrying an approach that has " +
    "already failed twice.",
  input_schema: {
    type: "object",
    properties: {
      attempted: { type: "array", items: { type: "string" } },
      blocker:   { type: "string" },
      needs:     { type: "string", description: "What would unblock this" },
    },
    required: ["attempted", "blocker"],
  },
}

This is the same argument as teaching an extractor to refuse, and it works for the same reason: models are trained to be helpful, and stopping does not read as helpful unless you say it is.

In practice a well-described give-up tool prevents more runaway loops than any detector, because the agent stops on its own at turn six rather than being killed at turn thirty. The detectors are the backstop for when it does not.

What to do when it trips

Halt, do not retry. A retry restarts the same loop with a fresh budget.

Save the transcript and surface the last few turns — the repeated call and the error it kept hitting are almost always sufficient to diagnose it, and the fix is usually a tool returning an unhelpful error message rather than anything wrong with the agent.

That is worth stating plainly, because it is where the fix usually lives: an error saying invalid input gives a model nothing to adapt to. An error saying invalid input: 'date' must be YYYY-MM-DD, received '03/04/2026' gets fixed on the next turn. Most loops we have looked at are caused by the tool, not the agent.


More in put a hard ceiling on the run, if you cannot replay it, and agentic workflows.

Something here

the audit is the cheapest way to find out for certain.