Skip to content

Stopping Conditions: Preventing Infinite Agent Loops

5 min read · updated August 3, 2026

while True around a stochastic decision-maker is not a loop, it is a liability. Every agent needs several halt conditions, because each one catches a different pathology and none of them catches the others.

Why loops do not terminate

The naive loop ends when the model stops requesting tools. That is a termination condition supplied by the thing being controlled, which is the wrong direction of control. Four ways it fails to fire:

  • Oscillation. The agent edits a file, tests fail, reverts the edit, tests fail differently, re-applies the edit. Each step is locally reasonable. The cycle has no exit.
  • Optimistic re-checking. A tool returns empty. The model concludes it must have made a small mistake and retries with a trivially different query. Forever.
  • Goal drift. The original task scrolls out of context or gets summarised away, and the agent continues doing adjacent, plausible work with nothing to complete against.
  • Ambiguous completion. There is no observable predicate for “done”. “Improve the docs” never terminates because nothing in the environment ever says it has.

Three of those four are not fixable by prompting. Oscillation and optimistic re-checking are consequences of a model that cannot see its own history as a pattern, and goal drift is a consequence of a finite window. An instruction like “do not repeat yourself” helps at the margin and is not a control. The control is code that counts.

Six independent conditions

ConditionDescription
stepsHard cap on loop iterations. The crudest and the most reliable. Set it from the task type: 5 for a lookup, 40 for a coding task. If a run routinely hits it, the cap is not the problem.
tokensCumulative input + output across the run. Catches the case where few steps each carry an enormous context -- ten steps over a 200k window is not a cheap run.
costThe one a finance team can reason about, and the only one that stays meaningful when you change models mid-run. See agent cost control for enforcing it before the call rather than after.
wall clockBounds the user's wait and catches a tool that hangs without timing out. Independent of the others: a run can be cheap, short in steps, and stuck for twenty minutes on one call.
no progressK consecutive steps producing no new information -- repeated calls, repeated results, no state change. Catches oscillation, which every budget-based condition catches only after wasting the budget.
explicit finishThe agent called the finish tool with a structured result. The only condition that means success. All five above mean the run was stopped.

One class, six checks

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

@dataclass
class Halt(Exception):
    reason: str
    detail: str = ""

@dataclass
class Budget:
    max_steps:    int   = 25
    max_tokens:   int   = 400_000
    max_usd:      float = 2.00
    max_seconds:  float = 600.0
    max_stall:    int   = 3          # identical (tool,args) repeats
    max_no_change:int    = 4         # steps with no new information

    steps:   int   = 0
    tokens:  int   = 0
    usd:     float = 0.0
    started: float = field(default_factory=time.monotonic)
    _seen:   dict  = field(default_factory=dict)
    _stale:  int   = 0

    # --- called BEFORE each model call -----------------------------
    def check(self):
        if self.steps   >= self.max_steps:   raise Halt("step_budget")
        if self.tokens  >= self.max_tokens:  raise Halt("token_budget")
        if self.usd     >= self.max_usd:
            raise Halt("cost_budget", "spent %.4f" % self.usd)
        if time.monotonic() - self.started >= self.max_seconds:
            raise Halt("wall_clock")
        if self._stale  >= self.max_no_change:
            raise Halt("no_progress", "%d steps added nothing" % self._stale)
        self.steps += 1

    # --- called AFTER each model call ------------------------------
    def charge(self, usage, price_in, price_out):
        self.tokens += usage.get("prompt_tokens", 0)
        self.tokens += usage.get("completion_tokens", 0)
        self.usd += usage.get("prompt_tokens", 0)     / 1e6 * price_in
        self.usd += usage.get("completion_tokens", 0) / 1e6 * price_out

    # --- called BEFORE dispatching a tool --------------------------
    def guard_call(self, name, args):
        key = hashlib.sha256(
            (name + json.dumps(args, sort_keys=True)).encode()).hexdigest()
        self._seen[key] = self._seen.get(key, 0) + 1
        if self._seen[key] > self.max_stall:
            raise Halt("repeat_loop",
                       "%s called %d times with identical args"
                       % (name, self._seen[key]))
        return self._seen[key] > 1        # True = we have seen this before

    def observe(self, produced_new_information: bool):
        self._stale = 0 if produced_new_information else self._stale + 1

The design choices worth stealing. check() runs before the model call, not after — a budget checked afterwards has already been exceeded, and for the cost budget that difference can be a whole expensive call. Every halt raises with a reason string, so the caller receives a label rather than a value it has to interpret. And guard_call returns whether the call is a repeat, which lets the loop feed that fact back to the model one step before the hard stop — a warning is usually enough.

The observe hook needs a definition of “new information”, and the cheap one works well: hash the tool result and compare against the results already in the transcript. Same bytes, no progress. It misses semantic no-ops but catches the literal oscillations, which are the ones that burn budgets.

The termination handshake

Inferring success from the absence of tool calls is the bug in the eighty-line loop in the agent loop, and it is worth fixing explicitly. Give the model a tool it must call to end the run:

{"type": "function", "function": {
  "name": "finish",
  "description": "End the task. Call this exactly once, when you have
                  either completed the task or determined you cannot.
                  Do not call it to report progress.",
  "parameters": {"type": "object", "properties": {
    "status":  {"type": "string",
                "enum": ["completed", "blocked", "impossible"]},
    "summary": {"type": "string",
                "description": "What you did, in 2-3 sentences."},
    "evidence":{"type": "array", "items": {"type": "string"},
                "description": "Tool results or file paths that
                                support the claim of completion."},
    "blocker": {"type": "string",
                "description": "Required when status is not
                                'completed'. What stopped you."}
  }, "required": ["status", "summary"]}}}

The status enum is the point. Without it, “I could not find the file” and “I fixed the bug” are both a text message with no tool calls, and your caller cannot tell them apart — which means your dashboards will report both as successes. With it, a blocked run is a first-class outcome that can be routed to a human. The evidence array is a second-order benefit: requiring it makes unsupported completion claims noticeably less comfortable to produce, and gives a reviewer somewhere to look.

If the model emits prose without calling finish, do not accept it as an answer. Append a tool-style nudge — “call finish with a status to end the task” — and let the step budget catch it if it will not.

What to do with the reason

Halt reasons are your primary operational signal, and they want different responses:

  • step_budget or token_budget firing regularly means the task is bigger than the configuration, not that the agent is broken. Look at whether the work should be split.
  • repeat_loop and no_progress are prompt and tool problems. Read the repeated call: usually a tool returned something the model could not interpret as an answer.
  • cost_budget should be rare. If it is common, the budget is aspirational rather than real, and the run is being killed at a random point rather than a designed one.
  • wall_clock almost always means a tool has no timeout. Fix the tool.

One caveat on the caps themselves: derive them from an observed distribution rather than from intuition. Run a hundred representative tasks with a deliberately generous budget, take the step count at the 95th percentile of the runs that succeeded, and set the cap a little above it. A cap chosen by guesswork either kills good runs or fails to catch bad ones, and there is no way to tell which without the histogram.

Emit the reason as a metric label on every run. The distribution of halt reasons over a week tells you more about an agent’s health than any success rate, because the success rate averages all four of these into one uninformative number.

Stopping Conditions: Preventing Infinite Agent Loops · Multigrid