Budget and Loop Ceilings
Also known as: runaway protection, step limits, cost guards
Bound every agent run by steps, tokens, wall clock, and spend — and stop with a useful partial result rather than an exception.
The problem
An agent loop is an unbounded while loop with a paid API call inside it. The failure is not hypothetical: a tool that returns an error the model cannot fix produces an infinite retry cycle; two agents can hand work back and forth indefinitely; a model that never emits a stop signal keeps going. Left unbounded, one malformed request can produce a five-figure bill overnight, and nobody notices until the invoice.
The pattern
Enforce four independent ceilings on every run — steps, tokens, wall-clock time, and monetary spend — with a single budget object checked before each iteration. Exhaustion is not an exception: the run stops and returns whatever it has, labelled as incomplete. Add pattern-based detection for the loops that stay within budget while making no progress.
Use when
- Any agent loop, without exception — this is the cheapest insurance in the entire stack
- Tools can fail repeatedly in ways the model may not recognize as unfixable
- You run agents on user-triggered input, where an adversary controls the workload
- Multiple agents can call each other, creating cycles
Avoid when
- Never omit ceilings entirely — raise them for genuinely long-running batch work, but keep them
- Do not set them so tight that normal runs are cut short; size from observed p99, not from fear
Tradeoffs
GainRunaway cost becomes structurally impossible rather than merely unlikely
CostLegitimate long tasks can be cut short if ceilings are set too tight
GainPartial results are usually still useful, and always better than an exception
CostCallers must handle an 'incomplete' status rather than assuming success or failure
GainBudget telemetry shows exactly which tasks are expensive and why
CostFour ceilings to tune per agent type, and they drift as behaviour changes
Every experienced team has a story about the loop that ran all weekend. The guard is twenty lines of code and there is no defensible reason to ship an agent without it.
Four ceilings, one object
Steps alone are not enough — one step that retrieves a 200k-token document blows a token budget that fifteen normal steps would not. Track all four dimensions in one object, check before each iteration, and record consumption after each. Spend is the one non-engineers care about, so make it a first-class dimension rather than something inferred from tokens later.
@dataclass
class Budget:
max_steps: int = 20
max_tokens: int = 200_000
max_seconds: float = 300
max_cost_usd: float = 2.00
steps: int = 0
tokens: int = 0
cost_usd: float = 0.0
started: float = field(default_factory=time.monotonic)
def exhausted(self) -> str | None:
if self.steps >= self.max_steps:
return f"step limit ({self.max_steps})"
if self.tokens >= self.max_tokens:
return f"token limit ({self.max_tokens:,})"
if time.monotonic() - self.started >= self.max_seconds:
return f"time limit ({self.max_seconds}s)"
if self.cost_usd >= self.max_cost_usd:
return f"cost limit (${self.max_cost_usd:.2f})"
return None
def charge(self, usage: Usage) -> None:
self.steps += 1
self.tokens += usage.input_tokens + usage.output_tokens
self.cost_usd += price(usage)Stop gracefully, not abruptly
When a budget is exhausted, give the model one final turn to summarize what it accomplished — with tools disabled so it cannot start new work. A partial answer with a stated limitation is far more useful to a caller than a raised exception, and it costs one cheap call.
async def run(task: str, budget: Budget) -> Result:
messages = [Message(role="user", content=task)]
while True:
if (reason := budget.exhausted()):
return await wrap_up(messages, reason, budget)
response = await model(messages, tools=TOOLS)
budget.charge(response.usage)
messages.append(response.message)
if not response.tool_calls:
return Result(status="complete", output=response.text, budget=budget)
messages.extend(await execute_batch(response.tool_calls))
async def wrap_up(messages: list[Message], reason: str, budget: Budget) -> Result:
metrics.increment("agent.budget_exhausted", tags={"reason": reason.split(" ")[0]})
messages.append(Message(role="user", content=(
f"You have reached the {reason}. Stop working. Summarize what you "
f"established, what remains unfinished, and the single best next step."
)))
final = await model(messages, tools=[]) # no tools: it cannot start anything new
return Result(status="incomplete", output=final.text, stopped_by=reason, budget=budget)Budgets must span the whole tree, not each agent separately. An orchestrator with a $2 budget spawning ten workers each with their own $2 budget has a $22 exposure. Pass a shared budget object down, or allocate sub-budgets that are deducted from the parent's remaining balance.
Detecting progress-free loops
Some loops stay comfortably inside every ceiling while accomplishing nothing — the same search, the same failing tool call, the same reasoning restated. Fingerprint each step and stop when a fingerprint repeats too often. This catches the pathology much earlier than a step ceiling, and the earlier you catch it the less it cost.
def step_fingerprint(response: Response) -> str:
calls = sorted((c.name, canonical(c.arguments)) for c in response.tool_calls)
return hashlib.sha1(repr(calls).encode()).hexdigest()[:12]
class LoopDetector:
def __init__(self, repeat_limit: int = 3):
self.seen, self.limit = Counter(), repeat_limit
def check(self, response: Response) -> str | None:
if not response.tool_calls:
return None
fp = step_fingerprint(response)
self.seen[fp] += 1
if self.seen[fp] >= self.limit:
return f"repeated the same tool calls {self.limit} times without progress"
return None- Enforce steps, tokens, wall clock, and spend — any one alone is insufficient
- Share the budget across the whole agent tree, not per agent
- On exhaustion, take one final tool-free turn to summarize; return a partial result
- Fingerprint steps to catch progress-free loops well before the ceilings fire
- Emit budget consumption as telemetry — it is your best cost-attribution signal
Set ceilings from your observed p99, not from imagination. If 99% of successful runs finish in eight steps, a limit of twenty is generous and a limit of two hundred is just an expensive way to discover you had a bug.