Put a hard ceiling on the run
An agent in a loop spends money at the speed of the API. The limit has to be enforced inside the run, before the call, not on a dashboard afterwards.
An agent gets a task it cannot complete. It tries a tool, reads the error, tries a variation, reads the error, tries again. Each turn carries the whole transcript so far, so each turn is more expensive than the last.
Nothing is broken. There is no exception, no alert, no failed health check. The loop is working exactly as designed and it will keep going until something stops it.
A billing alert tells you what an agent spent. A budget tells it what it may spend. Only one of those runs before the money is gone.
Why alerts are the wrong instrument
Provider spend alerts are evaluated on the provider's aggregation schedule — typically minutes to hours behind, and often daily for the cheaper tiers. An agent in a loop makes calls at API latency, so a few hundred requests fit inside the reporting delay comfortably.
They are also account-scoped. The alert fires for the whole organisation, telling you that something is spending, at a point where the useful action is finding which run and killing it.
The limit has to be enforced where the decision is made: in the run, before the call.
A budget the run carries
type Budget = {
runId: string
limitUsd: number
spentUsd: number
/** Turns, not just money. Cheap infinite loops are still infinite. */
maxTurns: number
turns: number
}
class BudgetExceeded extends Error {
constructor(readonly budget: Budget, readonly reason: "cost" | "turns") {
super(`run ${budget.runId} hit its ${reason} ceiling`)
}
}
async function guardedCall(b: Budget, req: Request): Promise<Response> {
if (b.turns >= b.maxTurns) throw new BudgetExceeded(b, "turns")
// Pre-flight: estimate this call before making it. Input tokens are
// known exactly; output is bounded by max_tokens, so the upper bound
// is computable and that is what you check against.
const worstCase = estimateCost(req)
if (b.spentUsd + worstCase > b.limitUsd) throw new BudgetExceeded(b, "cost")
const res = await client.messages.create(req)
// Settle with actuals. The estimate was an upper bound; the real
// number is in the response.
b.spentUsd += actualCost(res.usage, req.model)
b.turns += 1
await persist(b) // survives a process restart
return res
}
The pre-flight check is the part that matters. Checking after the call means the call you were trying to prevent has already been paid for — which is fine once and not fine when the overrun is a 200k-token context.
Estimate the upper bound, not the average
function estimateCost(req: Request): number {
const r = RATES[req.model]
// Input is exact — you have the messages in hand.
const input = countTokens(req.messages) + countTokens(req.system ?? "")
// Output is unknown, but max_tokens caps it. Assume the cap.
const output = req.max_tokens
return (input * r.input + output * r.output) / 1_000_000
}
Assuming maximum output is deliberately pessimistic. It means the ceiling is
never breached, at the cost of occasionally refusing a call that would have been
affordable. For a safety limit that is the correct direction to be wrong in, and
it is a good argument for setting max_tokens tightly per step rather than
leaving it at the model maximum — a loose max_tokens makes every pre-flight
estimate pessimistic and the budget useless.
Bound turns as well as money
Cost alone is insufficient. A loop on a cheap model with short prompts can run thousands of turns before it registers financially, while producing nothing and holding a worker.
Bounding turns also gives you the cleaner diagnostic. A run that hits its cost ceiling on turn four had a genuinely large task. A run that hits 40 turns at trivial cost is stuck, and those want different responses.
Add repeat detection for the common case:
// If the last three tool calls are identical, the agent is not making
// progress. Stop now rather than at turn 40.
function isSpinning(history: ToolCall[]): boolean {
const recent = history.slice(-3)
if (recent.length < 3) return false
const sig = (c: ToolCall) => `${c.name}:${stableStringify(c.args)}`
return new Set(recent.map(sig)).size === 1
}
Identical consecutive calls with identical arguments is the signature of an agent that has read an error, failed to understand it, and tried the same thing again. It is worth catching early because the transcript is growing on every turn, so turns 30 through 40 cost far more than turns one through ten.
What to do when it trips
Exceeding a budget is not a crash. It is a state, and the run should end in it cleanly:
try {
await agent.run(task, budget)
} catch (err) {
if (err instanceof BudgetExceeded) {
await saveTranscript(budget.runId) // the evidence
await markRun(budget.runId, "halted", err.reason)
await notify(task.owner, {
runId: budget.runId,
spent: budget.spentUsd,
turns: budget.turns,
lastSteps: await lastSteps(budget.runId, 5),
})
return // not a retry
}
throw err
}
Do not auto-retry a budget failure. A retry starts a fresh budget and repeats the loop that just consumed the first one, which converts a bounded overrun into an unbounded one. Halting and telling somebody is the correct behaviour, and the transcript is what makes the halt actionable rather than merely a stop.
Setting the number
Derive it from what the task should cost, not from what you can tolerate losing:
- Run the task successfully a few times. Record actual cost.
- Set the ceiling at roughly 3–5× the median.
- Watch how often it trips.
A ceiling that never trips is not doing anything. One that trips on legitimate work is too tight and will be raised in an incident, which is the worst time to choose a number. The tripping rate you want is low and non-zero — a handful a week, each one a genuine stuck run worth looking at.
Per-tenant ceilings on top of per-run ones are worth adding wherever a customer can trigger agent runs, since one tenant looping is otherwise everyone's problem.
More in which feature tripled the bill, allow-lists, not deny-lists, and when the model bill triples.