Your AI Agent Is Looping, and No Test Caught It

Table of Contents
Your support agent called search_kb, didn’t like the result, called get_ticket, re-read the same ticket, decided it needed more context, called search_kb again with a nearly identical query, and did that eleven more times until the framework’s iteration cap fired and forced a final answer. The answer was wrong, but plausibly wrong — so your eval scored it as a normal miss, not as the runaway it actually was. The model wasn’t stuck. It was making progress it couldn’t perceive, oscillating between two tools that each looked like the reasonable next move. That’s livelock, and a max-iterations cap doesn’t catch it. It hides it.
I watched this exact pattern on an anonymized enterprise support-triage agent burn roughly a fifth of its total token spend on runs that never converged — and none of it showed up on the eval dashboard, because a capped loop returns an answer and gets graded like any other answer. The cap didn’t protect us. It laundered a reliability bug into an accuracy bug. Below is why agents livelock, why the cap you already have makes it invisible, and the Playwright test that fails the build when your agent oscillates.
Why does an AI agent loop instead of finishing?
Because an agent’s control flow is a while loop with no invariant that guarantees progress, and the model re-derives its next step from a context that barely changed. Every mainstream agent runtime — OpenAI’s function-calling loop, Anthropic’s tool-use loop, the ReAct pattern they descend from — is the same shape: call model, run tool, feed result back, repeat until the model emits a final answer. Nothing in that loop asserts that state moved forward. If two tools return results the model reads as “still not enough,” it will alternate between them indefinitely.
This is not the same failure as an agent calling tools in the wrong order or failing to recover from a tool error. Those are trajectory bugs — wrong step, right count. Livelock is a termination bug: every individual step is defensible, and the sequence still never ends. The classic distributed-systems distinction applies cleanly here. A deadlocked process is stuck doing nothing; a livelocked one is busy doing work that never changes the outcome. Your agent is the second kind, and it’s paying per token for the privilege.
25
LangGraph default recursion_limit
Steps before GraphRecursionError
15
LangChain AgentExecutor max_iterations
Default cap before forced stop
~20%
Token spend on non-converging runs
Observed, anonymized triage agent
The frameworks know termination is a problem — that’s why they ship caps. LangGraph raises GraphRecursionError at a default recursion_limit of 25, and LangChain’s AgentExecutor defaults max_iterations to 15. Both are real safety rails. Neither tells you why the limit was hit, and one of them fails in a way that’s actively worse than crashing.
Why doesn’t a max-iterations cap catch it?
Because a cap changes how the run ends, not whether it was healthy — and the two common cap behaviors both destroy the signal. This is the part teams get wrong: they add a cap, feel safe, and ship a loop detector that reports nothing.
The first behavior is “force a final answer at the limit.” LangChain’s early_stopping_method="force" does exactly this: it stops the loop and returns whatever the model can produce in one more shot. That output flows into your eval harness and gets graded on correctness. A run that oscillated twelve times and a run that answered cleanly in two steps land in the same bucket if their final strings happen to match — and a false pass on the verdict is exactly what you’d expect when the only thing you measure is the last token.
The second behavior is “raise at the limit.” That’s better — a GraphRecursionError is at least loud — but it fires at step 25, long after the loop was detectable at step 6, and it can’t distinguish a legitimately deep task from a two-tool ping-pong. You get an alert that says “too many steps,” not “these two tools are chasing each other.”
What signal actually distinguishes a loop from deep work?
Repetition of state, not length of trajectory. A legitimately hard task takes many distinct steps; a livelock takes the same step over and over. So don’t assert on step count — assert on the multiset of (tool_name, normalized_args) pairs the agent emits, and fail when any pair repeats beyond a small threshold or when two pairs alternate in a cycle.
The unfakeable detail here, the one you only learn after debugging a few of these: you have to normalize the arguments before you compare them. A livelocked agent rarely sends byte-identical calls. It sends search_kb("password reset steps") then search_kb("how to reset password") — semantically the same query, different strings. Compare raw args and every loop looks like progress. Normalize (lowercase, strip, sort keys, optionally embed-and-cluster) and the oscillation snaps into view.
Detect the repeated step
Fingerprint each call as (tool, normalized_args) and count occurrences. When any single fingerprint shows up more than a small number of times, the agent is re-asking a question it already answered.
type Call = { tool: string; args: Record<string, unknown> };
function fingerprint(call: Call): string { const canonical = JSON.stringify(call.args, Object.keys(call.args).sort()); return `${call.tool}:${canonical.toLowerCase().replace(/\s+/g, ' ').trim()}`;}
export function findCycle(calls: Call[], repeatLimit = 2): string | null { const seen = new Map<string, number>(); for (const call of calls) { const fp = fingerprint(call); const n = (seen.get(fp) ?? 0) + 1; seen.set(fp, n); if (n > repeatLimit) return fp; // same logical step, too many times } return null;}Detect the alternation
That catches a single repeated step. Catching alternation — the A → B → A → B ping-pong that never repeats a single fingerprint fast enough to trip the counter — needs one more check: scan for a short repeating subsequence in the fingerprint stream.
export function findOscillation(calls: Call[], window = 2, cycles = 2): boolean { const fps = calls.map(fingerprint); for (let i = 0; i + window * cycles <= fps.length; i++) { const pattern = fps.slice(i, i + window).join('|'); let repeats = 1; while (fps.slice(i + repeats * window, i + (repeats + 1) * window).join('|') === pattern) repeats++; if (repeats >= cycles) return true; // A→B→A→B detected } return false;}How do you test for livelock in Playwright?
Drive the agent through a real browser session, record every tool call it makes, and assert the trajectory terminates without repeating a normalized step — all before the framework’s cap would ever fire. The key move is to capture the tool calls as first-class test data, the same way you’d assert on an agent’s tool calls for correctness, then run the loop detector over that record.
Record the tool-call trajectory
Intercept the agent’s tool invocations with page.route, push each (tool, args) onto an array the test owns, and let the call proceed to a real test backend. You now have the full trajectory as an assertable value, not a log you have to grep.
Reproduce the loop deterministically
Livelock is triggered by ambiguous tool results, so seed the backend to return the near-miss responses that make the model second-guess — the KB hit that’s almost-but-not-quite relevant. Mock the model or pin the seed so the run is repeatable; a flaky loop test is worse than none.
Assert termination, then assert no cycle
Two assertions, in order: the run finished under a step budget below the framework cap, and findCycle/findOscillation return clean. The budget catches slow divergence; the cycle check catches the ping-pong that stays under budget but never learns anything.
Here’s the test itself. Note it asserts the agent stops on its own — reaching the cap is a failure, not the pass condition.
import { test, expect } from '@playwright/test';import { findCycle, findOscillation } from '../../src/agent/loopDetector';
test('triage agent converges without oscillating', async ({ page }) => { const calls: { tool: string; args: Record<string, unknown> }[] = [];
await page.route('**/agent/tool', async (route) => { calls.push(route.request().postDataJSON()); await route.continue(); // hit the real test backend });
await page.goto('/support/chat'); await page.getByPlaceholder('Describe your issue').fill('I cannot reset my password'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('final-answer')).toBeVisible({ timeout: 30_000 });
expect(calls.length, 'agent exceeded step budget — likely diverging').toBeLessThan(10); expect(findCycle(calls), 'repeated the same normalized tool call').toBeNull(); expect(findOscillation(calls), 'ping-ponged between two tools').toBe(false);});Before
max_iterations = 15 // loop runs 12 steps, forces an answer // eval sees: final string, graded on correctness // result: silent reliability bug, green dashboard
After
step budget = 10 + cycle detection // test fails at the first repeated normalized call // CI blocks the PR with the exact ping-pong pair // result: loop caught before merge
Where does this fit against your existing eval suite?
Alongside it, guarding a dimension your eval doesn’t measure. Accuracy evals answer “was the final answer right?” Trajectory tests answer “did it take the right steps?” Neither answers “did it finish for the right reason?” — and that’s the gap livelock lives in. A run can be graded correct, take a plausible path, and still have burned twelve steps and hit the cap on the way to a lucky final token. As Anthropic’s guidance on building effective agents stresses, agentic systems trade predictable control flow for autonomy; termination is one of the guarantees you give up and have to win back with tests.
Wire the detector into your agent’s own runtime too, not just CI. The same findCycle that fails a Playwright test can emit a structured event when it trips in production, so you correlate the loop against the trace instead of inferring it from a token bill three days later. The test proves the class of bug can’t reach main; the runtime hook catches the novel oscillation your fixtures didn’t imagine.
The one thing to do today
Grep your agent’s logs for runs that hit max_iterations or recursion_limit in the last week. Every one of those is a loop your eval graded as a normal answer. Pick the noisiest tool pair, write the findCycle fingerprint for it, and add the single Playwright assertion that fails when that pair repeats. You’ll have a regression gate for your worst offender before lunch — and a token bill that stops paying for work that goes nowhere.
Why does an AI agent loop instead of finishing a task?
Because the agent runtime is a while loop with no progress invariant. Every mainstream loop — OpenAI function calling, Anthropic tool use, the ReAct pattern — calls the model, runs a tool, feeds the result back, and repeats until the model emits a final answer. Nothing asserts that state moved forward, so when two tools each look like “still not enough,” the model alternates between them indefinitely. Every step is defensible; the sequence never ends.
Doesn't a max-iterations cap already prevent infinite loops?
It bounds the loop but hides it. LangChain’s AgentExecutor defaults max_iterations to 15 and can force a final answer at the limit; LangGraph raises GraphRecursionError at a default recursion_limit of 25. A forced answer flows into your eval and is graded on correctness, so a 12-step oscillation and a clean 2-step run share a bucket. The cap is a circuit breaker, not a detector.
What signal distinguishes a real loop from a legitimately deep task?
Repetition of normalized state, not trajectory length. A hard task takes many distinct steps; a livelock repeats the same one. Assert on the multiset of (tool, normalized_args) fingerprints and fail when one repeats past a small threshold or two alternate. Normalization is the trick — a looping agent sends search_kb("reset password") then search_kb("password reset"), so lowercase, strip, and sort keys before you compare, or every loop reads as progress.
How do you test for agent livelock in Playwright?
Intercept tool calls with page.route, record each (tool, args) onto a test-owned array, and let them reach a real test backend seeded with the ambiguous responses that trigger second-guessing. Then assert two things in order: the run terminated under a step budget below the framework cap, and the cycle detector finds no repeated or alternating fingerprints. Reaching the cap is a failure, not the pass condition.
Should the loop detector run only in CI or also in production?
Both. In CI it proves the known oscillation can’t reach main. In the runtime, the same fingerprint check emits a structured event when it trips, so you correlate the loop against your GenAI trace live instead of inferring it from a token bill days later. CI covers the loops you imagined; the runtime hook catches the ones your fixtures didn’t.
