The agent failures that page you are the easy ones. Something throws, a run dies, an alert fires, you look. The failures that quietly cost you are the ones where the agent keeps working — taking actions, narrating confident progress, burning tokens — without getting any closer to done. I call it loop drift, and it’s the failure mode I trust least to announce itself, because from the inside it looks exactly like work.

This is a teardown of one instance: what it looked like, why our checks missed it, and the detection and evals that would have caught it in minutes instead of on the invoice.

The incident

A code-maintenance agent, task: “find and fix the failing test in the billing module.” Straightforward — the kind it closed dozens of times a day. This run took 41 steps, spent about 15× a normal run’s tokens, and ended by hitting the step cap. No exception. No error in the logs worth the name. The final message was, in part: “I’ve made significant progress narrowing down the issue and am close to identifying the root cause.”

It was not close. Reconstructing the trajectory, here’s what actually happened:

  • Steps 1–6: reasonable. Read the test, ran it, found a failing assertion, opened the module under test.
  • Steps 7–15: it read the same three files in slightly different orders. Each step’s reasoning was fluent and plausible — “let me check how apply_discount handles the null case” — and each ended with a tool call that gathered information it already had.
  • Steps 16–30: it started running grep variations. grep discount, then grep -i discount, then grep "discount" with quotes. Each returned nearly the same lines. Each time the model treated the result as a fresh clue and declared a step of progress.
  • Steps 31–41: it proposed the same one-line fix three times, each time “verifying” by re-reading the file rather than re-running the test, each time reporting the verification as forward motion, until the step cap ended it.

The fix, when a human took over, was four lines and ten minutes. The agent had all the information it needed by step 6. It spent the next 35 steps convincing itself it was progressing.

Why our checks didn’t catch it

We had guardrails. They were the wrong ones.

We had a step cap. It fired — that’s how the run ended. But a step cap is a backstop, not a detector: it bounds the damage of drift, it doesn’t notice drift. By the time it trips you’ve already paid for every wasted step. It’s the retry cap problem from the retry-budgets post in a different costume — a cap turns unbounded waste into bounded waste, and turns a silent cost problem into a silent correctness problem, because a run that dies at the cap looks a lot like a run that was genuinely hard.

We trusted the model’s self-assessment. This is the deep one. Our loop asked the model, each step, whether it was making progress and whether it was done — and the model said yes, it was progressing, right up to the cap. That felt reasonable when we built it. It isn’t, and the reason is structural: the model’s sense of progress is generated from the same context that’s drifting. Every “I’m narrowing it down” was a fluent continuation of a transcript full of fluent continuations. A drifting agent’s self-report drifts with it. Asking a stuck agent whether it’s stuck is asking the unreliable narrator to review their own reliability.

The context made it worse over time. Each near-duplicate action left its result in the window. By step 30 the context was thick with slightly-varied greps and repeated file reads, and that repetition is itself a prior: the most likely continuation of a transcript full of grep variations is another grep variation. The window was pushing the model toward the drift. (Context that actively degrades behavior is its own topic — a future post — but it’s a co-conspirator here, not the root cause.)

The root cause was simpler than any of these: we had no external, model-independent signal for progress. Everything we measured, the model could fake without knowing it was faking.

Detecting drift from the outside

The fix is to measure progress with signals the model doesn’t author. Two cheap ones caught this class of failure for us immediately.

Action novelty. A drifting agent repeats itself. You don’t need to understand the actions to notice they’ve stopped being new — fingerprint each tool call and watch the rate of novel fingerprints. When an agent takes ten actions and only two are distinct, it’s spinning.

import hashlib, collections

class DriftMonitor:
    """Trips when recent actions stop being novel or state stops changing."""

    def __init__(self, window=8, min_novel=3):
        self.window = window          # look at the last N actions
        self.min_novel = min_novel    # require at least this many distinct
        self.actions = collections.deque(maxlen=window)
        self.state_hashes = collections.deque(maxlen=window)

    def _fingerprint(self, tool_name, args):
        # normalize args so trivial variations collapse to the same print
        norm = str(sorted((k, str(v).strip().lower()) for k, v in args.items()))
        return hashlib.sha1(f"{tool_name}:{norm}".encode()).hexdigest()[:12]

    def record(self, tool_name, args, world_state_hash):
        self.actions.append(self._fingerprint(tool_name, args))
        self.state_hashes.append(world_state_hash)

    def is_drifting(self):
        if len(self.actions) < self.window:
            return False, None
        novel = len(set(self.actions))
        if novel < self.min_novel:
            return True, f"only {novel} distinct actions in last {self.window} steps"
        if len(set(self.state_hashes)) == 1:
            return True, f"world state unchanged across last {self.window} steps"
        return False, None

Note the normalization in _fingerprint. That’s what would have caught the grep variations — grep discount and grep -i "discount" fingerprint close enough that the monitor sees repetition instead of three “different” searches. Without normalization the model’s superficial variety defeats you.

State delta. The stronger signal, when you can get it: did the world actually change? For the code agent, “world state” is the test result — has anything moved the failing test toward passing? An agent that has taken eight actions without changing the one number that defines success is not making progress, whatever it says about itself.

# in the agent loop
monitor = DriftMonitor()
for step in range(STEP_CAP):
    action = model.decide(context)
    result = execute(action)
    state_hash = hash_relevant_state()   # e.g. hash of the current test output

    monitor.record(action.tool, action.args, state_hash)
    drifting, why = monitor.is_drifting()
    if drifting:
        break_the_loop(reason=why)        # escalate, reset, or abort — not retry
        break

    context = append(context, action, result)

hash_relevant_state() is the design work. It should hash the thing that defines task success — the test output, the target file’s contents, the count of open sub-goals — not the whole world, or every incidental step will look like progress. Picking that state is the same discipline as writing a good eval: you’re forced to say concretely what “closer to done” means, and most agents drift precisely because no one ever did.

When the monitor trips, the response is emphatically not to retry — that’s more of the same fuel on the fire. It’s to break the pattern: escalate to a human, reset the contaminated context to a clean checkpoint, switch strategy, or abort with a clear “stuck” status. The goal is to stop and be visibly stopped, not to fail silently at the cap.

Catching it before production, with evals

Detection in prod bounds the damage. Evals stop you from shipping the drift in the first place, and drift needs evals built for it, because the standard ones miss it.

Grade the outcome, never the self-report. The single most important rule. Your eval’s pass condition must be an external fact — the test passes, the file has the right contents, the ticket reached the right status — never the model’s claim that it succeeded. We had graded a sibling agent partly on whether its final message claimed success, and it happily passed while drifting. Rip that out. The model’s summary is not evidence.

Measure steps-to-done as a first-class metric, not just pass/fail. A run that passes in 40 steps and a run that passes in 6 are both “green” on a boolean eval, but the first is drift that happened to recover. Track the step (and token) distribution across your golden set, and alert on regressions in it. A new prompt that keeps the pass rate flat while the median step count creeps up has made your agent driftier, and only the distribution shows it.

def evaluate_run(trajectory, task):
    return {
        # outcome: an external fact, never the model's self-report
        "solved": task.verify_external_state(),   # e.g. run the test, check it passes
        # efficiency: catches drift that recovered
        "steps": len(trajectory),
        "tokens": sum(s.tokens for s in trajectory),
        # drift signature: how repetitive was the path?
        "distinct_action_ratio": _distinct_ratio(trajectory),
        # progress shape: did state change monotonically, or thrash?
        "state_changes": _count_state_transitions(trajectory),
    }

Seed the golden set with drift bait. The tasks that induce drift are the ones where the agent has to stop — where the information is already sufficient and the correct move is to commit, or where the right answer is “this can’t be done, escalate.” Put those in your eval set deliberately: an already-fixed bug (does it recognize there’s nothing to do?), an underspecified task (does it ask, or spin?), a genuinely impossible one (does it give up cleanly, or loop forever?). An agent that never learns when to stop will pass a golden set made only of solvable, well-specified tasks, and then drift on the first ambiguous one in prod.

What I’d take away

Loop drift is dangerous precisely because it doesn’t look like failure. The agent is busy, articulate, and confident, and every internal signal agrees it’s fine. The lessons that survived this one:

  1. Never let the model grade its own progress. Its self-report drifts with the run. Measure progress with something the model doesn’t author — action novelty, and above all a delta in the external state that defines success.
  2. A step cap bounds drift; it doesn’t detect it. You need a detector that trips before the cap and breaks the pattern instead of feeding it — escalate or reset, never retry.
  3. Evals grade outcomes and efficiency, not claims. Track steps-to-done as a distribution, and seed the golden set with tasks where the right move is to stop — commit, ask, or give up.

The uncomfortable core of it: to detect drift you have to define “progress” concretely enough for a machine to check without the model’s help. Most agents drift because nobody on the team ever wrote that definition down. Writing it down — as a state hash, as an eval’s external pass condition — is most of the cure.