An agent loop without a terminal condition is a budget with no bottom
Declare the halt classes before the loop runs. 'Until it looks done' is not a condition.
TL;DR
An agent loop asks a model "what next?" until something says stop. If the only stopping rule is the model's own judgement that the work is finished, the loop has no reliable bottom — there is always another plausible improvement, and a model asked to find one will.
Our refactoring agent ran for 94 iterations and $31 of tokens on a task that was complete at iteration 7. It was not stuck in a cycle; each step was a different, defensible, unnecessary improvement.
The fix is to enumerate halt classes before the loop starts — named, checkable conditions, each with a deterministic test — and to make "the model thinks it is done" exactly one of them, verified against something outside the model.
The case this post is about
An agent that applies a refactor across a codebase:
def refactor(goal: str, max_iters: int = 100):
history = []
for i in range(max_iters):
step = agent.decide(goal=goal, history=history, tools=TOOLS)
if step.action == "done":
return step
result = execute(step)
history.append((step, result))
raise RuntimeError("max iterations reached")
One terminal condition the model controls (done), one backstop that is a number. This is the
shape most agent loops have on the first attempt.
The symptom
A run on "extract the duplicated retry logic into a shared helper":
iter 1-5 found 6 call sites, wrote the helper, migrated 4 ← the actual task
iter 6-7 migrated the last 2, ran the tests: green ← complete here
iter 8 "the helper could take a configurable backoff strategy"
iter 12 "added a strategy interface; now adding a second implementation"
iter 19 "the interface should be generic over the exception type"
iter 31 "added KDoc to all public members"
iter 44 "the tests could use a parameterised fixture"
iter 58 "extracted the test fixture into a shared module"
iter 71 "the shared test module needs its own build file"
iter 94 max_iters reached
Three things about that log:
It never repeated itself. A loop-detection check on action hashes would not have fired — every step was genuinely new. "Stuck in a cycle" was not the failure mode.
Every step was defensible in isolation. A configurable backoff strategy is a reasonable idea. Each one, reviewed alone, would plausibly pass.
And the output was worse than at iteration 7. The final diff was +1,847 lines across 23 files, introducing a generic strategy interface with two implementations for a codebase with one retry policy. The task's own goal had been met by a +120-line diff.
The cost: $31.40, 94 model calls, 41 minutes. And a diff nobody could review.
Why the obvious fix failed
"Lower max_iters." We set it to 20. The agent then produced 20 iterations of work and stopped
mid-refactor, leaving the codebase in a state with the helper written and three call sites
unmigrated. A cap converts "too much" into "arbitrarily truncated", which is worse — a partial
refactor is a broken build.
"Add loop detection on repeated actions." Already present, and never triggered, because the actions were all distinct. The failure was scope expansion, not repetition.
"Tell it in the prompt to stop when the goal is met." It was in the prompt. The model's assessment of "met" is exactly the thing being over-generous, so asking it more firmly moves the threshold slightly and changes nothing structural.
"Review each step before executing." Returns the human to the loop and removes the point of the agent. We tried it for a day; the steps are individually reasonable, so a reviewer approves them, and the aggregate is the same.
The actual mechanism
A loop needs a terminal condition that is not produced by the thing being looped.
step.action == "done" fails that, for a specific reason: the model is being asked to assess its
own completion against a goal stated in natural language. "Extract the duplicated retry logic" does
not have a crisp boundary — a model generating the next plausible step will always find one,
because plausible is what it is good at.
So the question becomes: what can terminate it?
An externally checkable goal state. Not "is it good" but "does this specific, deterministic check pass". Tests green, the duplicate count is zero, a gate script exits 0.
A declared set of failure terminals. The things that mean "stop, a human is needed": a destructive action requested, a budget reached, a check that cannot be satisfied.
And a fixed-point condition. Not "did the action repeat" but "did the world stop changing" — if an iteration produces no change to the measured goal state, further iterations are not progressing.

A terminal condition the model produces is not a terminal condition. Enumerate the halt classes up front, give each one a deterministic check, and treat "the model says done" as a claim to be verified rather than a result.
The fix
Enumerate the halts, check them deterministically, and verify the model's completion claim.
from dataclasses import dataclass
from enum import Enum
class Halt(Enum):
GOAL_MET = "goal-met" # the deterministic check passes
FIXED_POINT = "fixed-point" # an iteration changed nothing measurable
BUDGET_EXHAUSTED = "budget-exhausted" # tokens or dollars
HUMAN_REQUIRED = "human-required" # destructive or out-of-scope action proposed
CHECK_UNSATISFIABLE = "check-unsatisfiable" # the goal check cannot pass as specified
@dataclass
class GoalCheck:
"""A deterministic, runnable statement of done. No model involved."""
def state(self) -> dict: ... # measurable facts
def met(self) -> bool: ... # the condition
class ExtractRetryLogic(GoalCheck):
def state(self):
return {
"duplicate_sites": count_duplicate_retry_blocks(), # AST scan
"tests_pass": run("./gradlew test").returncode == 0,
"helper_exists": Path("core/retry/Retry.kt").exists(),
}
def met(self):
s = self.state()
return s["duplicate_sites"] == 0 and s["tests_pass"] and s["helper_exists"]
def refactor(goal: str, check: GoalCheck, budget_usd: float):
history, prev_state = [], None
while True:
# --- halt checks run BEFORE asking the model again ---
if check.met():
return Halt.GOAL_MET, history
if spent() >= budget_usd:
return Halt.BUDGET_EXHAUSTED, history
state = check.state()
if state == prev_state and history:
return Halt.FIXED_POINT, history # an iteration achieved nothing
prev_state = state
step = agent.decide(goal=goal, history=history, state=state, tools=TOOLS)
if step.action == "done":
# the model's CLAIM, verified against the check
if check.met():
return Halt.GOAL_MET, history
history.append((step, f"not done: {unmet(check)}")) # tell it what is missing
continue
if is_destructive(step) or out_of_scope(step, check):
return Halt.HUMAN_REQUIRED, history
history.append((step, execute(step)))
Five decisions:
The goal check is code, not prose. duplicate_sites == 0 and tests_pass is runnable, and it
is the thing that defines done. Writing it is most of the work of setting up the task, and it is
the part that makes the loop bounded.
done is a claim, verified. When the model says done and the check disagrees, the loop
continues with the specific unmet condition fed back — which turned out to also improve the
agent's behaviour, because it gets told what is actually missing.
The check's state() is passed into the prompt. The model can see duplicate_sites: 2, which
keeps it oriented toward the measured goal rather than toward plausible improvements.
out_of_scope is the scope-creep guard. A step touching files unrelated to the goal check's
state is a halt, not an action:
def out_of_scope(step, check) -> bool:
touched = set(step.files)
relevant = set(files_contributing_to(check.state()))
return not touched.issubset(relevant | {helper_path})
And FIXED_POINT catches the case a repetition check misses: distinct actions that do not move
the measured state. That is what would have ended our 94-iteration run at around iteration 9.
Every halt is reported with its evidence, so a run is auditable:
HALT goal-met after 7 iterations
duplicate_sites: 0 tests_pass: true helper_exists: true
spend: $2.10 diff: +124 −96 across 7 files
How we proved it
The same task, re-run:
iterations spend diff reviewable
original loop 94 $31.40 +1847 −96 no
max_iters=20 20 $6.80 +410 −61 no (broken build)
halt classes 7 $2.10 +124 −96 yes
Seven iterations and $2.10 for the diff that was correct all along.
Which halt fired, across 40 real tasks:
goal-met 31
fixed-point 4 ← would have run to the cap before
human-required 3 (2 destructive, 1 out-of-scope)
budget-exhausted 1
check-unsatisfiable 1 (the goal check was wrong — a useful finding)
That check-unsatisfiable is worth calling out: the goal check asserted a duplicate count of zero,
and one "duplicate" was a deliberately divergent implementation. The halt surfaced a bad
specification instead of letting the agent grind against it — which a model-judged loop would have
resolved by changing the code to match the check.
That the done-verification feedback improves behaviour:
iterations where the model claimed done prematurely: 18 across 40 tasks
of those, the next iteration moved the check closer: 16
Telling the agent which condition is unmet is more useful than telling it to keep going.
And the cost across the 40 tasks:
original loop (est.) halt classes
median spend $11.20 $1.90
p90 spend $34.60 $4.10
One limit, and it is the main one: writing the goal check is real work, and for some tasks it is
genuinely hard — "improve the error messages in this module" has no crisp deterministic test. For
those we fall back to a tighter budget plus human-required on anything outside a named file set,
which bounds the cost without claiming a goal condition. Pretending every task has a clean
met() would be the dishonest version of this post.
What this generalizes to
A loop's terminal condition must come from outside the loop. This is a control-theory point, not an AI one. A process that decides its own completion against a soft criterion does not terminate reliably — in agents, in retry logic, in optimisation loops, in negotiation protocols.
Enumerate the terminals before you start. Naming the halt classes up front forces the question "what does done mean, checkably" at the moment it is cheapest to answer. Discovering you cannot answer it is itself the most valuable output.
Distinguish no-repetition from no-progress. Loop detection on action identity misses the case where every step is novel and none advances the goal. Measure the world, not the actions.
Treat a self-assessment as a claim to verify. "The model says it is done" is useful input and not a result. Checking it costs one deterministic run and converts a judgement into a fact — and the feedback makes the next attempt better.
Related
- Your agent's tool output is untrusted input — the other thing not to delegate to the model
- The eval that passed because the function returned a plausible stub — verification that executes
- The agent fixed the test, not the bug, and the test was right — goal checks that can be gamed
Keep reading
This is part of 100 Days of Coding Agents — building agents that are useful on real codebases, with the failure modes nobody puts in the demo. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".