Summary: OpenRouter published a guide on 11/12/2026 showing how to stop an AI agent from repeatedly calling a failing tool until a step cap or your wallet gives out. It ships doom-loop detection in @openrouter/agent 0.11.0, fingerprints each tool call, and blocks or kills runs when the same call keeps coming back. Across their 18 test runs, detection cut tool calls from 314 to 64 and spend from $0.29 to $0.05 — numbers worth understanding before you leave an agent running overnight.
Why an agent loop never ends on its own
An agent loop stops when the model answers without asking for another tool. If that tool keeps failing, the model has no reason to stop: the error goes back into the conversation, the instructions still say to finish the task, and one more attempt looks like progress. Nothing counts attempts unless you add code that does.
The loop also compounds in cost. Every turn resends the whole conversation, including every earlier failure. OpenRouter logged one run where input grew from 131 tokens to 3,262, and the cost per turn nearly quadrupled. Multiply that across a pricier model and many concurrent sessions, and they note a loop left overnight can add up to thousands of dollars.
Their test rig makes the failure vivid: a billing agent whose only tool always throws a timeout, like a billing service that’s down. On Mistral Large 4 with no guard, all 41 calls were identical, and the run only ended because a stepCountIs(40) cap fired. The model’s final answer said the service was timing out — it knew that on every turn and retried anyway.
What doom-loop detection actually does
The guard is one option in the callModel call: doomLoop: true. Under the hood, the SDK fingerprints each tool call by its name and arguments, canonicalizing the arguments first so reordered JSON keys don’t hide a repeat. A repeat only counts when the model has seen the previous result and asks again — five identical calls within one turn count as one.
As the streak grows, it climbs a default ladder: observe at streak 2, block at 3, stop at 6. A blocked call skips execute entirely and returns a tool error telling the model it’s repeating itself and that another try won’t help. That framing matters. A timeout error invites a retry; this error explains that retrying changes nothing. In most runs the model read it and wrote a final answer immediately, ending with SessionEnd: complete. If the model keeps asking, stop ends the run with reason: doom_loop.
Two implementation details you’ll want: getDoomLoopVerdict() returns a message only when detection ended the run, which makes it your alert signal. And if the defaults feel loose, you can lower the ladder — OpenRouter shows a GPT-6 Luna run ending at streak 4 for $0.0002. The ladder also has two rungs off by default: steer (a note to the model that it’s repeating itself) and escalate (running the next turn on a stronger model you pick). These hooks sit on the same SDK I covered in the OpenRouter Agent SDK write-up, so existing integrations can add this without restructuring the loop.
When models dodge the fingerprint
Detection catches identical calls, and models are clever enough to know that. After a block, some changed the argument slightly: in nine follow-up Mistral Large 4 runs, the model dodged detection five times, and three of those hit the 40-step cap, mostly by alternating INV-2291 and inv-2291. GPT-6 Luna used trailing spaces and newlines. The models are following the block message’s instruction to take a different approach — with only one tool, a different input is the only approach they have.
The fix is a loopKey on the tool, which tells the detector which part of the input actually identifies the call:
loopKey: ({ invoice_id }) => invoice_id.replace(/\D/g, ''),
// "inv-2291 " and "INV-2291" are one call
With that in place, every variant maps to 2291, the streak keeps growing, and five of six runs stopped after two real calls. In the sixth, the model tried a different invoice — INV-0000 — and the streak correctly reset, which is exactly the behavior you want. OpenRouter’s guidance: normalize case, spacing, and prefixes; if a field never changes which record the tool fetches, list only the fields that matter with loopKey: [‘invoice_id’]. And a tool whose job is to repeat, like a status poller, takes loopKey: false.
The three layers, and the one that survives your own bugs
Detection has a real limitation OpenRouter states plainly: it only catches repeats. A model that tries something new every turn never trips a fingerprint. That’s why Step 3 of their guide adds stopWhen caps — stepCountIs(15), maxCost(0.01), and even a failure counter that ignores arguments — checked after every round. Set both caps from your normal run data; theirs took four to six turns and cost $0.0018 to $0.0053, so 15 steps and $0.01 leave room without permitting a 40-turn run. The cost cap matters more, since later turns cost more than early ones.
But every guard above runs inside your agent’s process. If your code has a bug, ships without the caps, or runs a hundred sessions each staying under maxCost, nothing limits the total. That’s the case for the fourth guard: a credit limit on the API key, enforced on OpenRouter’s side. A request that doesn’t fit fails with a 402 and limit_source: “openrouter_key_limit”. One note on completeness — the guide’s text cuts off mid-explanation of this credit-limit mechanism, so the excerpt confirms the 402 behavior but not every detail of how the limit is configured.
What to check next
If you’re running agents in production, three things to do this week. First, upgrade to @openrouter/agent 0.11.0, flip on doomLoop: true, and wire getDoomLoopVerdict() into your alerting — it fires only when detection stopped a run. Second, audit your tools’ inputs and write loopKeys for anything a model could trivially vary; a polling tool needs loopKey: false instead. Third, put a credit limit on every production API key so the outer boundary exists even when your inner guards have bugs.
Keep in mind what these results do and don’t establish. The numbers come from OpenRouter’s own test agent — a single always-failing billing tool run three times on six current models, including Grok 4.7’s wildly variable 41, 12, and 41 calls on the same input. Real agents with diverse tool sets and genuine failure modes may behave differently, and the published excerpt doesn’t include production incident data. The layered pattern is the durable lesson regardless: detect repeats, normalize what counts as a repeat, cap every run, and keep one guard outside your own code.
