BlogAgents7 min read

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:

  1. Run the task successfully a few times. Record actual cost.
  2. Set the ceiling at roughly 3–5× the median.
  3. 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.

Something here

the audit is the cheapest way to find out for certain.