Engineering

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.

MobileByteSensei 8 min read
agentsarchitectureai

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.

An agent loop whose only terminal is the model's own judgement, producing ninety-four distinct and individually defensible steps past the point of completion at iteration seven — against a loop with enumerated halt classes, where a deterministic goal check, a declared set of failure terminals, a fixed-point condition and a budget each terminate on a condition the model does not produce

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.

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".

Follow by RSS