Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./August 20, 2026/14 min

Your AI Agent Is Looping, and No Test Caught It

Filed underai-testing/ai-agents/playwright/llm/cicd
Your AI Agent Is Looping, and No Test Caught It

Table of Contents
  • Why does an AI agent loop instead of finishing?
  • Why doesn’t a max-iterations cap catch it?
  • What signal actually distinguishes a loop from deep work?
  • Detect the repeated step
  • Detect the alternation
  • How do you test for livelock in Playwright?
  • Where does this fit against your existing eval suite?
  • The one thing to do today

On this page

  • Why does an AI agent loop instead of finishing?
  • Why doesn’t a max-iterations cap catch it?
  • What signal actually distinguishes a loop from deep work?
  • Detect the repeated step
  • Detect the alternation
  • How do you test for livelock in Playwright?
  • Where does this fit against your existing eval suite?
  • The one thing to do today

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.

§ Where the loop hides

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.”

[ WARNING ]

A max-iterations cap is a circuit breaker, not a detector. It bounds the blast radius of a loop; it does not tell you a loop happened, where it happened, or which tools were involved. Treating “we set max_iterations” as loop protection is how oscillation reaches production wearing a green eval.

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.

src/agent/loopDetector.ts
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.

src/agent/loopDetector.ts
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.

tests/agent/livelock.spec.ts
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);
});
§ Delta ·

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

[ TIP ]

Run this on the pull-request path, mocked and deterministic. It tests control flow, not model quality, so it’s cheap and doesn’t belong in an expensive nightly eval. Parameterize it across every agent that has more than one tool — any agent with a choice can oscillate, and the ones with retrieval plus a lookup tool are the usual suspects.

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.

[ NOTE ]

What I’d do differently, having shipped the cap-only version first: I’d have made “hit the iteration limit” a hard CI failure from day one, instead of a warning we filtered out of the logs. Every capped run is a loop you didn’t diagnose. Treat the cap as an alarm, never as a resolution.

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.

§ Frequently Asked FAQ
+ 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.

§ Further Reading 03 of 03
01AI Testing

Your AI Agent Retries and Charges the Card Twice

A tool call commits but the response is lost, so your AI agent retries and double-charges. Here's the Playwright test a senior SDET writes for exactly-once.

Read →
02AI Testing

Your AI Feature Has Three Clocks. They Disagree.

Your browser clock, your server clock, and the model's idea of now rarely agree. Pin all three in Playwright before a date bug quietly reaches production.

Read →
03AI Testing

Your AI Feature Works at 5 Docs and Breaks at 50

Your AI feature passes with three test documents and degrades at fifty. Build a Playwright context-length gate that finds the cliff before production does.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.