Chapter 6 of 8 · 13 min read
A step budget bounds the blast radius, not the bill
On this page
An agent loop that has stopped making progress and can still call tools is a process with write access and no supervision. It will keep spending, and the spending is the least of it: each pass through the loop can also write a row, send a message or move a record in a system other teams depend on, and nothing inside the loop is counting.
The control that bounds this is a budget — a limit, checked before each step, on how much one run may consume before it is stopped. What it is bounding is the blast radius: how much one run can spend, write and disturb in other systems before something stops it, with the money falling out as a side effect of that bound. The first chapter of this topic warned about an iteration cap presented as a saving, and that warning stands. What follows is the same mechanism used for the job it is actually good at, and the difference is not academic: the two uses fire under opposite conditions, and which one you are building decides both what value the bound should have and what has to happen when it fires.
A budget and a stopping rule are opposite controls
| Stopping rule | Step budget | |
|---|---|---|
| Fires when | the required work is done | the run has gone wrong |
| Evaluated against | run state predicates | counters |
| Frequency in a healthy system | most runs | 1–2% of runs |
| Correct outcome | return the answer | escalate, loudly |
| Tuned by | product and policy owners | operations and safety |
| Optimises | avoidable work | worst case |
| If it never fires | you are doing extra work | you have no bound |
The row that matters most is the fourth, and it follows from the first two rather than being a separate policy. A stopping rule fires because the required work is done, so it has an answer the system stands behind. A budget fires because a counter hit a limit, which is a statement about consumption and says nothing at all about the state of the work — and by construction, if there had been an acceptable answer the stopping rule would have fired first and the run would already be over.
So a run that reaches its budget has, provably, no result worth returning. Returning whatever the transcript happens to contain at that moment, formatted as though it were a completed task, converts a bounded failure into an unbounded one: the ticket gets closed, the customer gets a reply built on half the evidence, and nothing in the metrics distinguishes it from a real success.
Budgets are plural and they bound different failures
A single step counter catches a runaway loop and misses several of its neighbours. Each dimension bounds a distinct way for a run to go bad, and each has a natural unit.
| Budget | Bounds | Typical value here | Fires on |
|---|---|---|---|
| Steps | model/tool ping-pong that makes no progress | 20 | 1.4% of runs |
| Wall clock | a dependency that hangs rather than errors | 6 min | 0.5% |
| Total tokens | a context that grows past what anyone intended | 250k | 0.2% |
| Spend | anything the other three missed | $2.00 | 0.1% |
| Calls per tool | duplicate writes, repeated identical reads | 3 writes, 8 reads | 0.9% |
| Distinct tools used | a run wandering outside its task | 7 | 0.1% |
The reason one dimension does not cover the others is that each failure mode moves a different counter and leaves the rest still. A dependency that hangs rather than erroring consumes wall clock while the step count barely moves, because the run is waiting rather than looping. A context that grows past what anyone intended consumes tokens while the step count stays inside its limit. Each row exists because there is a way to go wrong that the other rows cannot see, which is also the test for whether you need a dimension you do not have.
The per-tool limit is the one that most directly bounds the blast radius,
and it is the one most often absent, because a global step count treats a
hundred ledger_write calls and a hundred order_lookup calls as the same
event. They are not: one is idempotent and free, the other is a side effect
on a system other teams depend on. A step counter measures consumption of
your own budget; a per-tool write counter measures the damage done outside
it, and only the second is what "blast radius" names. Splitting the limit by
tool, and further by whether the tool reads or writes, is where the radius
actually gets bounded.
class BudgetExhausted(Exception):
def __init__(self, dimension: str, limit: float, observed: float):
super().__init__(f"budget '{dimension}' exhausted: {observed} > {limit}")
self.dimension, self.limit, self.observed = dimension, limit, observed
@dataclass
class Budget:
steps: int = 20
wall_clock_s: float = 360.0
tokens: int = 250_000
usd: float = 2.00
per_tool_writes: int = 3
per_tool_reads: int = 8
def check(self, run: RunState) -> None:
if run.step >= self.steps:
raise BudgetExhausted("steps", self.steps, run.step)
if run.elapsed_s() >= self.wall_clock_s:
raise BudgetExhausted("wall_clock_s", self.wall_clock_s, run.elapsed_s())
if run.total_tokens >= self.tokens:
raise BudgetExhausted("tokens", self.tokens, run.total_tokens)
if run.usd_spent >= self.usd:
raise BudgetExhausted("usd", self.usd, run.usd_spent)
for tool, n in run.tool_calls.items():
limit = self.per_tool_writes if TOOLS[tool].writes else self.per_tool_reads
if n >= limit:
raise BudgetExhausted(f"tool:{tool}", limit, n)Raising with the dimension attached is what makes the control operable. An
exhaustion counter that only says "budget hit" tells you a run failed; one
broken out by dimension tells you which failure mode is common this week,
and in practice the mix shifts — a carrier API slowdown moves firings from
steps to wall_clock_s within an hour, which is a useful alert on its
own.
What has to happen when it fires
The last two transitions are drawn because they exist in real systems and
are the thing to design out. A run that reaches Exhausted and takes the
Returned edge has laundered a failure into a success, and every
measurement in this topic breaks downstream of it. Cost per successful task
counts it in the denominator, so the measure that was supposed to be
ungameable is now being fed a false success. The stopping rule's truncation
rate never sees it, because the rule did not fire. And the ablation in
chapter four would have scored a bad model downgrade as harmless, since the
runs it damaged came back labelled accepted. One mislabelled outcome
corrupts three separate measurements, which is why this is a design
constraint rather than a preference.
The two paths also differ in what they cost and in whether anybody can see
it. The Escalated path costs $6.40 and is visible. The Returned path
costs whatever a wrong credit costs and is invisible, which is the trade the
next chapter's arithmetic is about.
Escalation is more than raising an exception. What the human desk needs is the partial state the run accumulated — the shipment candidates it was choosing between, the policy sections it read, the eligibility verdict if it got one — plus the exhaustion dimension, because "this run made 240 steps without settling on a shipment" tells an agent-desk operator exactly where to start. Handing over a raw transcript instead means the operator re-reads 240 steps of the agent's confusion, and the $6.40 estimate stops holding — which matters beyond the desk's invoice, since that figure is an input to every decision in the final chapter.
The other obligation on exhaustion is to stop the damage that is already in flight, which a step budget cannot do by itself. A run that has written 118 notes to a customer account has committed them; raising an exception at note 119 does not remove them. Bounding side effects properly needs the writes to be idempotent on a key derived from the run and the intent — the same note content for the same account within a run resolves to one record — or it needs a compensation path the escalation triggers. Without one of those, a budget bounds how much further the run can go and not how much it already did.
There is a tempting middle path that is worth refusing explicitly, which is warning the model that its budget is nearly spent and asking it to wrap up. It sounds humane and it reproduces the failure from the previous chapter exactly: the model, told it has two steps left, produces a confident summary of what it has, and that summary is a fluent artefact indistinguishable from a real answer. If a wrap-up message is in the design, whatever it produces has to be labelled as partial in the run record and routed to the escalation path regardless of how complete it reads, so the label comes from the control flow rather than from the text.
Choosing the number without repeating chapter one's mistake
The value comes from the step distribution of successful runs, which is exactly where a budget and the truncation the first chapter warned about part company. On the shipping-exception agent, successful runs sit at 5 steps at p50, 11 at p90, 14 at p99, and the longest genuine success in 40,000 replayed tickets took 19. Runs that ended badly cluster hard against whatever the cap is, which is the signature of a stuck loop rather than a difficult ticket.
| Cap | Fires on | Of firings, genuinely stuck | Successful tasks lost | Spend saved per 1,000 tickets |
|---|---|---|---|---|
| 8 | 16.2% | 11% | 118 | $28 |
| 14 | 3.1% | 62% | 21 | $9 |
| 20 | 1.4% | 88% | 2 | $4 |
| 40 | 1.1% | 96% | 0 | $1 |
| none | — | — | 0 | — |
Read the second and third columns together, because their relationship is the whole design. The second column is how often the control acts; the third is how often it was right to. Moving the cap from 8 to 20 cuts the firing rate by more than a factor of ten and raises the share that were genuinely stuck from about one in nine to nearly nine in ten. A control that acts rarely and is right almost every time it acts is what a safety control should look like, and the reason the two columns move together is that the stuck runs are a fixed population — they keep going forever, so every cap catches all of them — while the legitimate long runs are progressively spared as the cap rises past them.
Twenty sits just above the observed maximum for a legitimate run, so nearly everything it catches is a loop rather than a hard ticket. Eight, the value from the first chapter, sits below the 85th percentile of successful runs, so five sixths of its firings were work that would have finished.
The two columns on the right are the honest summary of what a well-chosen budget does to the bill: $4 per thousand tickets, against $186 of spend, which is 2%. Set the cap where it saves real money and it is destroying successful tasks; set it where it is safe and it is not a cost control at all.
That trade-off is worth pricing rather than describing, and the two right-hand columns contain enough to do it. At a cap of 8 the model spend saved is $28 per thousand tickets and the successful tasks lost are 118, each of which becomes an escalation at about $6.40 of handling — over seven hundred dollars of handling cost bought with twenty-eight dollars of tokens. At a cap of 20 the saving is smaller and so is the loss, and only two tasks per thousand are involved. The bottom rows are not just safer; they are the only rows where the control is not losing money.
That is the reconciliation between this chapter and the first one. Both are about the same mechanism, and the disagreement was never about whether to have a bound. Presenting the bound as a saving is what forces its value downward, because every step of tightening looks like an improvement on a per-call or per-run measure and only shows its cost in the denominator nobody was reporting.
One consequence of choosing the number this way is that the budget has to be re-derived whenever the pipeline changes shape. Adding the disambiguation retry from chapter four's ablation, or moving a check earlier so the stopping rule fires sooner, shifts the whole step distribution, and a cap left at its old value silently changes from a bound on pathology to a bound on hard tickets. The cheap safeguard is to compute the successful-run percentiles on a schedule and alert when the cap falls below p99 plus a margin, which turns a value that rots quietly into one that complains.
Where a budget leaves you exposed
It bounds a run and knows nothing about a task. A ticket that exhausts its budget, escalates, gets retried by an automated policy, exhausts again and escalates again has consumed two budgets, and if the retry policy allows three attempts the effective bound on the task is three times what anyone wrote down. That multiplication is the same shape as nested retries anywhere: two layers each enforcing their own limit produce a product rather than a sum, and neither layer's configuration mentions the other. Budgets that matter therefore need a task-level counterpart, usually a rule that a task which exhausts once goes to a human instead of back into the loop.
It is also blind to the direction of a run. Twenty steps of steady progress and twenty steps of the same two tools alternating look identical to a counter, because a counter reads consumption and progress is a property of what the state contains. So the budget fires at the same moment on a run that was about to succeed and a run that was never going to, and every step between the point where the run stopped progressing and the point where the counter filled is waste that no value of the limit recovers. Progress detection — noticing that the last four steps produced no new fields in the run state, or that the model has emitted the same tool call with the same arguments twice — catches the pathological case far earlier and far more cheaply, and treating it as a separate signal that can trigger escalation before the budget does is where most of the remaining waste in the tail lives.
The counters also assume one run is one sequence, and that assumption is about to fail. As soon as the run forks, every dimension in that dataclass needs a policy about whether the children share the parent's allowance or get their own, and the wall-clock dimension stops meaning what it did, since four branches running for a minute each consume four minutes of budget in one minute of clock. Fan-out is the next chapter, and the accounting is the smaller of its two problems.
Agent cost and latency control — read the current version at https://daviddominguez.dev/learn/en/agent-cost-and-latency-control/a-step-budget-bounds-the-blast-radius-not-the-bill