DEV Community

Sam Chen
Sam Chen

Posted on

Seven Agent-Loop Anti-Patterns (and What to Run Instead)

Agents look busy, yet busy is not progress today. I catalog seven loop failures that hide broken patches. Kill the loop early, then apply a boring replacement.

Want the core conclusion in one blunt line? Agents are loops, so loops need brakes. I do not merge an agent patch without a kill switch. Would you still merge that patch without one?

Why these loops fool reviewers

An agent prints tokens and the terminal scrolls fast. That scrolling log feels like engineering, but is it? Most loops never ask a hard stop question.

They retry, replan, and restate the same bug. The log looks rich while the diff stays tiny. Reviewers clap at motion instead of checking a stop rule.

I treat every coding agent as a lab animal. It is not a coworker or a release manager. Would you let a lab animal push to main?

Where the lab actually runs

I need cheap retries and a throwaway server for this.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode's free model access and free server option fit a rehearsal lab. I still refuse to treat that box like a factory. The guard below wraps any local endpoint you already own.

How to read each entry

Every anti-pattern has the same three blocks. Symptoms come first, then the root cause. The replacement is a check you can run.

Skip any entry that does not match your logs. Do not collect the whole set like stickers. Steal only the brake that would have saved last week.

The catalog at a glance

ID Anti-pattern Kill signal
A1 Retry storm same error twice
A2 Context hoarding prompt is mostly sludge
A3 Fake done tests never ran
A4 Tool amnesia tool output ignored
A5 Spec drift goal changed mid-loop
A6 Privilege creep unbounded shell
A7 One-shot architecture no spike, many files

Print the table and keep it near the demo laptop. I use the kill signal column during reviews. Motion without a kill signal is just spend.

A1. The retry storm

Symptoms

  • The same stack trace appears twice in a row.
  • Step count climbs while the diff stays tiny.
  • The agent apologizes, then repeats the last patch.

Root cause

The loop treats failure as a vague vibe. It never fingerprints the last error, so retry becomes habit. Apology tokens are not a debugger.

Replacement

Hash the error body. Cap identical retries at two. Then stop and hand the hash to a human.

import hashlib

def error_id(text: str) -> str:
    clipped = " ".join(text.split())[:400]
    return hashlib.sha256(clipped.encode()).hexdigest()[:12]
Enter fullscreen mode Exit fullscreen mode

Still retrying after two identical hashes? You are not debugging anymore. You are paying for a loop that already answered.

A2. Context hoarding

Symptoms

  • The prompt still includes three failed plans.
  • File dumps dwarf the actual failing test.
  • Latency jumps and the answers get vaguer.

Root cause

The agent never garbage-collects its own notes. History feels safe, like a backup drive. History is sludge that steers the next token.

Replacement

Keep a working set of three artifacts only.

  1. The invariant, usually one test command
  2. The last failing output, clipped hard
  3. The current diff, nothing else
WORKING SET
- invariant: pytest -q tests/test_parser.py
- last_fail: test_parse_empty AssertionError
- diff: parser.py, +12 -4
Enter fullscreen mode Exit fullscreen mode

Would a senior engineer review that context pack? If not, reset it before the next step. More files in context is not more truth.

A3. Fake done

Symptoms

  • The agent says "fixed" with no command output.
  • The README changes while tests stay red.
  • The commit message claims coverage that never ran.

Root cause

Completion is a sentence, not a machine signal. Language models love closing statements a lot. Your merge button should not love them.

Replacement

Done means three green checks, in this order.

  1. The targeted tests actually ran.
  2. The linter ran on touched files.
  3. The invariant path still matches the contract.
pytest -q tests/test_parser.py
ruff check parser.py
git diff --stat -- parser.py tests/test_parser.py
Enter fullscreen mode Exit fullscreen mode

No output from those commands? The loop is still open. A cheerful summary is not a test runner.

A4. Tool amnesia

Symptoms

  • A tool returns stderr and the next step ignores it.
  • The agent rewrites a file it just read.
  • Paths in the plan do not exist on disk.

Root cause

Tool results get treated as narration only. They never bind into the next action. The model continues the story instead of the log.

Replacement

Require a bind step after every tool call. If stderr exists, the next action must cite it. No cite means no next step.

def bind_tool(result: str, next_action: str) -> None:
    token = result.strip().splitlines()[-1][:80]
    if "error" in result.lower() and token not in next_action:
        raise RuntimeError("tool result not bound")
Enter fullscreen mode Exit fullscreen mode

Would you ignore compiler output in a real review? Then do not ignore tools. Bind or kill. No third option.

A5. Spec drift

Symptoms

  • You asked for a small parser change.
  • The agent starts a framework rewrite anyway.
  • New files appear outside the allowed paths.

Root cause

The loop optimizes for looking helpful on screen. Helpful is not the ticket you wrote. Scope expands because nobody pinned it down.

Replacement

Pin a one-line contract. Diff every step against that file. A contract is cheaper than a postmortem.

# contract.yml
goal: parse CSV to dicts
allowed_paths: ["parser.py", "tests/test_parser.py"]
forbidden: ["rewrite framework", "add web server"]
max_files: 2
Enter fullscreen mode Exit fullscreen mode

A step that violates the contract is a kill. It is not a debate with the model. Restate the goal after you revert.

A6. Script privilege creep

Symptoms

  • Generated bash uses rm -rf or curl-to-pipe.
  • The agent asks for sudo "just this once".
  • Secrets show up in command logs after a retry.

Root cause

The model copies internet snippets with toy assumptions. Those snippets assume your laptop is disposable hardware. It is not disposable, and neither are the secrets.

Replacement

Run agent commands through a deny-by-default wrapper. Allow only the tools the contract needs. Everything else prints blocked and exits.

#!/usr/bin/env bash
# proposed wrapper, lab use only
allow='^(pytest|ruff|python|git diff|git status)'
cmd="$*"
[[ $cmd =~ $allow ]] || { echo "blocked: $cmd"; exit 2; }
exec $cmd
Enter fullscreen mode Exit fullscreen mode

If the command is not on the list, the loop stops cold. Why give a rehearsal agent a root shell? You would not.

A7. One-shot architecture

Symptoms

  • The first reply scaffolds twelve packages at once.
  • There is no spike and no walking skeleton.
  • You cannot run anything for a long stretch.

Root cause

Agents confuse completeness with competence under pressure. A diagram is cheaper than a passing test. So they emit trees instead of spikes.

Replacement

Force a twenty-line spike before any structure. One file. One test. One command.

SPIKE RULES
- one file
- one test
- one command
- ten minutes max
Enter fullscreen mode Exit fullscreen mode

Still want a monorepo after the spike passes? Fine, after the spike. Architecture without a green command is fan fiction.

Artifact: a loop guard you can run

I want a kill switch, not another dashboard. Here is a proposed Python guard for lab loops. Treat it as experimental code, not a benchmark.

"""agent_loop_guard.py — proposed circuit breaker for coding agents."""
from __future__ import annotations

from dataclasses import dataclass, field
from hashlib import sha256
from time import time


@dataclass
class LoopGuard:
    max_steps: int = 8
    max_seconds: float = 120.0
    max_same_error: int = 2
    started: float = field(default_factory=time)
    steps: int = 0
    error_counts: dict[str, int] = field(default_factory=dict)
    last_reason: str = ""

    def fingerprint(self, text: str) -> str:
        body = " ".join(text.lower().split())[:500]
        return sha256(body.encode()).hexdigest()[:10]

    def check(
        self,
        *,
        output: str,
        claimed_done: bool,
        tests_ran: bool,
    ) -> str:
        self.steps += 1
        elapsed = time() - self.started
        err = self.fingerprint(output)
        lowered = output.lower()
        if "error" in lowered or "traceback" in lowered:
            self.error_counts[err] = self.error_counts.get(err, 0) + 1

        if self.steps > self.max_steps:
            self.last_reason = "budget: too many steps"
        elif elapsed > self.max_seconds:
            self.last_reason = "budget: wall clock exceeded"
        elif self.error_counts.get(err, 0) >= self.max_same_error:
            self.last_reason = "A1: retry storm"
        elif claimed_done and not tests_ran:
            self.last_reason = "A3: fake done"
        else:
            return "continue"
        return "kill"

    def report(self) -> dict:
        return {
            "steps": self.steps,
            "seconds": round(time() - self.started, 1),
            "reason": self.last_reason,
            "errors": self.error_counts,
        }


if __name__ == "__main__":
    guard = LoopGuard()
    samples = [
        {
            "output": "Traceback: KeyError x",
            "claimed_done": False,
            "tests_ran": False,
        },
        {
            "output": "Traceback: KeyError x",
            "claimed_done": False,
            "tests_ran": False,
        },
        {
            "output": "All good",
            "claimed_done": True,
            "tests_ran": False,
        },
    ]
    for row in samples:
        decision = guard.check(**row)
        print(decision, guard.report()["reason"])
        if decision == "kill":
            break
Enter fullscreen mode Exit fullscreen mode

Run it like this:

python agent_loop_guard.py
Enter fullscreen mode Exit fullscreen mode

You should see a kill on the second identical traceback. That is the whole point of the guard. Fail closed, then inspect the reason string.

Mini test plan for the guard

Do not trust the snippet because it looks neat. Run these four cases before you wrap a real agent. If one case fails, stop wrapping.

  1. Identical traceback twice must return kill with A1.
  2. "All good" plus claimed_done=True without tests must return kill with A3.
  3. A unique error under the step budget must return continue.
  4. Nine tiny steps must trip the step budget even without errors.
python - <<'PY'
from agent_loop_guard import LoopGuard
g = LoopGuard(max_steps=2)
assert g.check(output="ok", claimed_done=False, tests_ran=False) == "continue"
assert g.check(output="ok", claimed_done=False, tests_ran=False) == "continue"
assert g.check(output="ok", claimed_done=False, tests_ran=False) == "kill"
print("budget case passed")
PY
Enter fullscreen mode Exit fullscreen mode

If case four does not kill, you wrapped nothing useful. Fix the import path first, then rerun. A guard that cannot fail closed is decoration.

Decision table: keep going or stop?

Observation Anti-pattern Action
Same traceback twice A1 kill and show hashes
Prompt is mostly old plans A2 reset the working set
"Done" without pytest A3 refuse the merge
Tool stderr unused A4 force bind or kill
New files outside contract A5 revert and restate the goal
Shell not on the allowlist A6 block the command
More than one file before tests A7 delete the extra files

Print the action column during demos and stick to it. People argue with feelings instead of log hashes. A printed table ends those arguments very fast.

Wiring this to a free server

Point your agent at the lab endpoint you already use. Wrap each step with LoopGuard.check before the next tool call. Keep secrets off that machine on purpose.

The free server is for rehearsal of these brakes. It is not your production control plane. If the endpoint hiccups, the guard should kill, not spin.

Log fingerprints, not raw prompts that may hold tokens. You want traces you can diff later. You do not want secret sprawl in agent logs.

Limitations

This catalog is a lab protocol, not an agent framework. It will not save a bad spec. Garbage goals still produce garbage diffs.

The guard is proposed sample code for local wrapping. I am not claiming production uptime, latency, or win rates. Label every number you add later yourself.

It will false-positive on flaky tests that flap. It will false-negative on quiet logic bugs. A green test file can still encode the wrong rule.

Free model access changes without a blog post. Do not bake quotas, model names, or hardware guesses into scripts. Read the current operator docs before you demo anything.

Who should not use this

  • Anyone merging agent patches without a human review
  • Teams handling payments, health data, or secrets
  • Folks who need a managed agent platform today
  • Live demos that cannot survive an early stop

If you need a factory, buy a factory process. Do not costume a lab loop as one. That costume fails in front of people, every time.

What to copy tomorrow

Copy the contract file first. Copy the allowlist wrapper second. Copy the fake-done rule third.

Skip the mythology about autonomous coworkers in chat UIs. Agents are loops with a streaming printer. Loops need brakes you can quote in a review.

Already have a free model server for rehearsal? Wire this guard before the next agent demo.

Top comments (0)