DEV Community

Dibyanshu kumar
Dibyanshu kumar

Posted on

The loop your agent can't see

The $136 design document

One ticket. Eleven design turns. $136 in model spend. Zero lines of code.

Our AI development orchestrator had been running tickets end to end for weeks — read the ticket, sketch, design, code, review, open the PR — and on this one it never left the design stage. Each turn, the author revised the design document. Each turn, the reviewers found something new to object to. Each turn, the document got longer, and a longer document has more surface to object to. The loop cap we'd written never fired.

Here's why. The cap said: stop after N design turns unless a reviewer has raised a blocking finding. That exemption sounds right — you don't want a counter to silence a real objection. But the reviewers were fresh processes emitting a fresh batch of findings every turn. There was always a blocking finding. Not the same one — a new one, every time. The exemption was unbounded, and the counter it guarded was decorative.

That incident is the reason this post exists. It's also the only time our system has rotted, and we now have the data to say so.

Why the model can't save itself

The failure mode has a name — context rot, or lost in the middle, or patching inertia — and a simple mechanism.

A language model conditions every generated token on everything in its window. Say turn 1 reads a 4k-token prompt and produces 1k tokens, some of which are wrong. In a single long session, turn 2's input is all 5k. The model has no way to assign the wrong tokens zero attention — they're conditioning input like everything else. So turn 2's output inherits their error probability, and turn 3 inherits turn 2's. Wrong output becomes wrong input, compounding.

Illustratively, with a 12% error rate on the first turn and the whole history re-fed each time:

Turn Input tokens (single long session) Wrong tokens carried in context Error risk of this turn's output
1 4.0k 0 12%
2 5.0k 0.12k 17%
3 6.0k 0.29k 23%
4 7.0k 0.52k 28%
5 8.0k 0.80k 34%

Against that, a fresh-worker turn reads the same ~1.2k tokens every time (the design slice plus a ~0.1k distilled verdict), and the raw output — wrong tokens included — is discarded. The context never grows, and the error risk stays where turn 1 left it. (Numbers are an illustration of the direction of the effect, not a measurement.)

The instinctive fix is to tell the model to watch for it: "if you notice you're going in circles, stop and rethink." That doesn't work, and the reason is structural — the instruction is executed by the same model whose attention is already sitting on the polluted prefix. A degraded context can't be trusted to notice it's degraded.

Bigger context windows make this worse, not better. A million-token window holds a million tokens of pollution.

What we built instead

Our orchestrator treats the model as a stateless worker and keeps the real state outside it. Five mechanisms, in the order they matter:

1. A fresh process per turn. Every author turn and every reviewer is a new model invocation with an empty context. No session is ever resumed across turns. Turn 8 cannot dwell on turn 3's bad draft, because it has never seen it.

2. State on disk. A workspace directory holds the state file, the living design document, the decisions log, and a per-turn artifact stack. Any turn — or any machine, since the workspace is a git repo — rebuilds the full picture from files. The prompt for turn 15 is the same size as the prompt for turn 2.

3. A bounded read-set, stated in the prompt. The worker is told exactly what to read:

Re-hydrate a BOUNDED read-set: the approved design names the files and insertion points — read those plus a few targeted greps for the symbols you touch; do NOT crawl the module. Also read the previous turn's verdict file for the UNRESOLVED findings you must address this turn.

Note what it does not say: read the previous turn's transcript, reasoning, or output. Those no longer exist.

4. A distilled hand-off — produced by code, not by a model. Each reviewer must write its findings as a structured, schema-checked file: id, severity, blocking, location, claim. An orchestrator function — ordinary Python, no model call — merges those into one verdict file: the findings the next turn must address, and nothing else. More on why this matters below.

5. Deterministic loop caps. After the $136 incident we replaced the exempted counter with two that nothing can bypass. The workhorse: three consecutive review verdicts that aren't terminal (neither ready nor ship) and the run halts, routing to a human with the open findings attached. A hard cap on total design turns backstops it; each human override after a trip raises that ceiling by a fixed increment, so the cap doesn't re-trip on every later turn and spam the gate. Neither counter asks the model anything. (A first draft counted identical verdicts and fired on a design that said "ready" three times in a row — escalating because the design kept being ready. Non-terminal only.)

The part most people get wrong: summarizers

The standard way to bound an agent's context is compaction: when the window fills, ask a model to summarize the history, and continue from the summary. Most agent frameworks do this. It's better than nothing, and it's still a rot vector.

A summarizer is a model conditioned on the polluted text. Whatever the wrong tokens biased, they bias the summary too — now in compressed, authoritative-sounding form, with the summarizer's own hallucinations layered on. You've laundered the pollution, not removed it.

Our hand-off has no model in it. The chain is: fresh author → fresh reviewers → plain code → fresh author. The only place a model touches the hand-off is in producing the structured findings, and that output is schema-validated before it's accepted — a reviewer whose output doesn't parse is re-dispatched, not summarized.

This is the design decision we'd defend hardest, and the one that seems least common.

What we measured

Claims about rot are cheap; the store held 22 completed production runs with per-turn event logs, so we checked. The classic rot signatures — the ones circuit-breaker middleware exists to catch — are all "the same thing comes back next turn." Across 22 runs and 38 code turns:

Rot signature Occurrences
Same set of change-owned test failures, turn N and turn N−1 0
Two consecutive build failures 0
A review finding repeated verbatim into the next turn 0

Every set of test failures introduced by a turn was cleared in the very next author turn. The ping-pong loop that motivates "context sanitization" middleware does not appear — because there is no polluted context to sanitize.

The check is ten lines. If your agent writes a structured event per turn, run it on your own logs:

import json, hashlib
prev = {}                                # run -> last turn's failure fingerprint
for e in map(json.loads, open("events.jsonl")):
    if e["event"] != "tests_run" or not e.get("failing_tests"): continue
    fp = hashlib.sha256("\n".join(sorted(e["failing_tests"])).encode()).hexdigest()
    if prev.get(e["run"]) == fp:
        print(f'{e["run"]}: same failures again at turn {e["turn"]}')
    prev[e["run"]] = fp
Enter fullscreen mode Exit fullscreen mode

If that prints anything, you have the loop — and a per-turn counter on it is a far cheaper fix than a bigger context window.

What we don't claim

Measured absent is not impossible. Twenty-two runs is a small corpus. The caps have fired in anger exactly once — on the $136 ticket's successor run — and a second incident on a post-cap run is our trigger to look again.

And one loop mode is observed-but-bounded: one run drew three consecutive blocking code reviews, each raising new findings. That's reviewer churn, not context rot — every worker was fresh — and the per-run turn budget contained it. We considered adding a code-stage streak cap for it and decided against: one instance in 22 runs, on a run whose design stage predates the caps, doesn't justify another knob. The repo's own history says caps that never fire are the ones that quietly rot.

The short version

  • A model can't notice its own loop; a counter can.
  • The state belongs on disk, and each turn should start with an empty context.
  • Don't let a model summarize the history — merge structured output with code.
  • Then measure it. The check is ten lines and the answer might surprise you in either direction.

Top comments (0)