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]
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.
- The invariant, usually one test command
- The last failing output, clipped hard
- 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
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.
- The targeted tests actually ran.
- The linter ran on touched files.
- 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
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")
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
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 -rfor 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
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
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
Run it like this:
python agent_loop_guard.py
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.
- Identical traceback twice must return
killwithA1. -
"All good"plusclaimed_done=Truewithout tests must returnkillwithA3. - A unique error under the step budget must return
continue. - 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
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)