HomeJournalThis post

Agent Run Budgets That Stop Cleanly

Implement a deterministic stop policy with typed termination reasons and a ledger that can be safely inspected or resumed.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Agent run budgets should stop work without erasing what already completed. This guide turns five independent limits into one typed, resumable ledger.

Agent run budgets are a composite contract

Agent run budgets keep one logical run inside declared limits for turns, tokens, elapsed time, tool calls, retries, and an overall weighted envelope. A single maximum-turn option is useful but incomplete: a turn can emit many tokens, a tool can block, or repeated recoveries can spend resources without advancing the task.

The OpenAI Agents SDK running guide documents the runner loop and maximum-turn behavior, including a typed exception when that limit is exceeded. Use that primitive as one input to an orchestration policy rather than implying it accounts for every external effect or wall-clock boundary.

This article models a frozen synthetic trace, not a real model or production cancellation system. Its purpose is to make reservation, settlement, typed stopping, completed-effect identity, and a resume cursor concrete enough to test before connecting vendors or user data.

Agent run budgets need a clean stop modeled as a state transition with an owner, not an exception sprinkled around a loop. Model running, refusing-new-work, settling-completed-work, and resumable-stopped as distinct states so every event has one legal predecessor and the ledger cannot silently charge work that never began.

Name the logical run before metering it

A budget belongs to a stable run identifier that survives worker restarts and distinguishes a resume from a new request. Record the policy version, initiator, task class, model configuration, start time, deadline, and parent workflow before the first model call so later usage can be reconciled to the same boundary.

Agent run budgets should not reset silently when a handoff changes agents or a durable worker retries delivery. If product semantics allow a continuation with new capacity, create an explicit top-up event naming the approver and new ceiling; otherwise resumption must carry forward the settled ledger and remaining allowance.

This boundary complements durable AI agent execution because durability answers where state survives, while budgeting answers what the continued run may still spend. Combining them prevents restart loops from manufacturing fresh limits.

Reservation and settlement solve different races. Reserve the maximum permitted cost before a model or tool begins, then settle the actual receipt afterward; if reservation fails, no effect starts, and if settlement is smaller, the unused allowance returns without rewriting the event history.

Composite run gaugesFive independent caps and a composite cap are exercised against one frozen six-event trace.turns · tokens · timesettled / completion captyped stop gate
Composite run gauges
Five independent caps and a composite cap are exercised against one frozen six-event trace.
Turns
3 of 8
Tokens
9,930 of 24,000
Elapsed
36.1 of 90 seconds
Tools
3 of 10
Retries
1 of 2
Composite
12,180 of 30,000 units
  • Each tighter policy independently rejects before starting its first unaffordable event.
Figure 1: The completion receipt and six rejection receipts come from the same frozen trace.

Reserve before starting expensive work

Check capacity against worst-case declared work before issuing a model request or tool call. Reserve the maximum output tokens, one turn, one tool slot, an attempt timeout, and any retry allowance that action can consume; if the projection crosses a hard limit, reject before start so no untracked effect begins.

Reservation is intentionally conservative, but settlement releases the difference between reserved and actual usage as soon as reliable evidence arrives. The Agents SDK usage guide describes request and token accounting fields that can feed this step, including cached and reasoning tokens where the model reports them.

Agent run budgets become unsafe when code charges only after completion. Two workers can both see apparent headroom and oversubscribe it, while a crashed process can lose the knowledge that an effect is already in flight; a durable reservation record closes both gaps.

An AI agent turn limit bounds loop shape but says nothing about expensive individual turns. Pair it with an agent token budget, elapsed-time deadline, tool-call ceiling, and retry allowance, then record which constraint won when several become exhausted at the same event.

Approval boundaries deserve separate events. A human can authorize a top-up, a new tool scope, or an extended deadline, but the ledger should retain the original stop and name the exact changed dimension. That preserves an audit trail and prevents a product from presenting an repeatedly expanded run as though it honored its first limit.

Settle actual usage without rewriting history

Append a settlement event that references the reservation and records actual turns, input tokens, output tokens, elapsed time, tool calls, retry count, and completed effect identifiers. Never mutate the original reservation away, because the released capacity and the time spent waiting are evidence about policy fit.

When a provider receipt arrives after a timeout, reconcile it with a new ledger entry rather than reopening the stopped run as if nothing happened. Unknown outcome is a real state for side-effecting tools; query by idempotency key or effect ID before a resume decides whether another call is safe.

The simulator keeps completed effect IDs outside the cancellation zone. Its totals are designed solely to verify accounting invariants, yet the shape supports a production audit: every charged unit points to a named event, and every begun effect has a discoverable disposition.

Agent timeout handling must distinguish refusal from cancellation. Refusing the next action is deterministic and usually safe; cancelling an in-flight external effect may leave an unknown outcome, so the resume record needs an idempotency key and an explicit reconciliation state instead of optimistic replay.

Runnable artifact — The simulator teaches orchestration policy and does not measure a real model, tool vendor, or production cancellation guarantee.

import assert from "node:assert/strict";

const trace = Object.freeze([
  Object.freeze({ id: "e1", reserve: { turns: 1, tokens: 3000, elapsedMs: 8000 }, actual: { turns: 1, tokens: 2180, elapsedMs: 6100 }, effectId: "answer-1" }),
  Object.freeze({ id: "e2", reserve: { toolCalls: 1, elapsedMs: 12000 }, actual: { toolCalls: 1, elapsedMs: 7200 }, effectId: "fx-search" }),
  Object.freeze({ id: "e3", reserve: { turns: 1, tokens: 3500, elapsedMs: 9000 }, actual: { turns: 1, tokens: 2940, elapsedMs: 8300 }, effectId: "answer-2" }),
  Object.freeze({ id: "e4", reserve: { retries: 1, toolCalls: 1, elapsedMs: 9000 }, actual: { retries: 1, toolCalls: 1, elapsedMs: 4100 }, effectId: "fx-retry" }),
  Object.freeze({ id: "e5", reserve: { turns: 1, tokens: 6200, elapsedMs: 9000 }, actual: { turns: 1, tokens: 4810, elapsedMs: 7600 }, effectId: "answer-3" }),
  Object.freeze({ id: "e6", reserve: { toolCalls: 1, elapsedMs: 6000 }, actual: { toolCalls: 1, elapsedMs: 2800 }, effectId: "fx-store" }),
]);
const dimensions = Object.freeze(["turns", "tokens", "elapsedMs", "toolCalls", "retries"]);
const zero = () => ({ turns: 0, tokens: 0, elapsedMs: 0, toolCalls: 0, retries: 0, compositeUnits: 0 });
const composite = (value) => value.tokens + value.toolCalls * 350 + value.retries * 900 + value.turns * 100;
const add = (a, b) => {
  const next = zero();
  for (const key of dimensions) next[key] = (a[key] || 0) + (b[key] || 0);
  next.compositeUnits = composite(next);
  return next;
};
const exceeds = (projected, policy) => [...dimensions, "compositeUnits"].find((key) => Number.isFinite(policy[key]) && projected[key] > policy[key]);
const policies = Object.freeze({
  turns: Object.freeze({ turns: 2 }),
  tokens: Object.freeze({ tokens: 8000 }),
  elapsedMs: Object.freeze({ elapsedMs: 28000 }),
  toolCalls: Object.freeze({ toolCalls: 1 }),
  retries: Object.freeze({ retries: 0 }),
  composite: Object.freeze({ compositeUnits: 9000 }),
  completion: Object.freeze({ turns: 8, tokens: 24000, elapsedMs: 90000, toolCalls: 10, retries: 2, compositeUnits: 30000 }),
});
function run(policy, options = {}) {
  let totals = options.startTotals ? structuredClone(options.startTotals) : zero();
  const settled = new Set(), effects = [], ledger = [];
  for (let cursor = options.startCursor || 0; cursor < trace.length; cursor++) {
    const event = trace[cursor];
    if (options.cancelAt === event.id) return { terminal: "cancelled_reconciliation_required", cursor, totals, effectIds: [...effects], ledger, overshoot: 0 };
    if (options.pauseAt === event.id) return { terminal: "paused", cursor, totals, effectIds: [...effects], ledger, overshoot: 0 };
    const projected = add(totals, event.reserve);
    const dimension = exceeds(projected, policy);
    if (dimension) return { terminal: "rejected_before_start", reason: "budget_exhausted", dimension, event: event.id, cursor, totals, effectIds: [...effects], ledger, overshoot: 0 };
    assert.ok(!settled.has(event.id), "event cannot settle twice");
    settled.add(event.id);
    const before = totals;
    totals = add(totals, event.actual);
    effects.push(event.effectId);
    ledger.push({ event: event.id, state: "settled", before, reserved: event.reserve, actual: event.actual, after: totals, effectId: event.effectId });
  }
  return { terminal: "completed", cursor: trace.length, totals, effectIds: effects, ledger, overshoot: 0 };
}
const policyMatrix = Object.fromEntries(Object.entries(policies).filter(([name]) => name !== "completion").map(([name, policy]) => [name, run(policy)]));
const cancellation = run(policies.completion, { cancelAt: "e4" });
const stopped = { ...run(policies.completion, { pauseAt: "e5" }), terminal: "stopped_by_operator", reason: "explicit_stop" };
const paused = run(policies.completion, { pauseAt: "e4" });
const resumeRegistry = new Set();
function resume(snapshot) {
  const key = snapshot.cursor + ":" + snapshot.effectIds.join("|");
  if (resumeRegistry.has(key)) return { terminal: "already_resumed", cursor: snapshot.cursor, effectIds: snapshot.effectIds, overshoot: 0 };
  resumeRegistry.add(key);
  const suffix = run(policies.completion, { startCursor: snapshot.cursor, startTotals: snapshot.totals });
  return { ...suffix, terminal: "resumed_completed", priorEffectIds: snapshot.effectIds, effectIds: [...snapshot.effectIds, ...suffix.effectIds] };
}
const resumed = resume(paused), duplicateResume = resume(paused);
let doubleSettlementRejected = false;
try {
  const settled = new Set(["e1"]);
  if (settled.has("e1")) throw new Error("event cannot settle twice");
} catch { doubleSettlementRejected = true; }
for (const [name, receipt] of Object.entries(policyMatrix)) {
  assert.equal(receipt.terminal, "rejected_before_start", name);
  assert.equal(receipt.overshoot, 0, name);
  assert.ok(receipt.cursor >= 0 && receipt.cursor < trace.length, name);
}
assert.equal(cancellation.terminal, "cancelled_reconciliation_required");
assert.equal(stopped.terminal, "stopped_by_operator");
assert.equal(paused.terminal, "paused");
assert.equal(resumed.terminal, "resumed_completed");
assert.equal(duplicateResume.terminal, "already_resumed");
assert.equal(new Set(resumed.effectIds).size, resumed.effectIds.length);
assert.deepEqual(resumed.totals, run(policies.completion).totals);
assert.ok(doubleSettlementRejected);
const receipt = { fixture: "frozen six-event synthetic run", trace, policies, policyMatrix, terminalReceipts: { cancellation, stopped, paused, resumed, duplicateResume }, hostileChecks: { doubleSettlementRejected, duplicateResumeIdempotent: duplicateResume.terminal === "already_resumed" }, invariant: { reserveBeforeStart: true, maximumOvershoot: 0, effectIdsUniqueAfterResume: new Set(resumed.effectIds).size === resumed.effectIds.length }, realModelMeasured: false };
console.log(JSON.stringify(receipt, null, 2));
console.log("PASS: five caps, composite policy, terminal receipts, and hostile resume checks execute");

Choose limits that fail independently

Turns limit reasoning-loop length, token ceilings bound model volume, elapsed deadlines protect user experience, tool-call caps constrain external operations, and retry limits stop repeated failure recovery. Keep each dimension visible even if a composite score also exists, because two thousand tokens cannot explain away a forbidden fifth payment call.

The model settings reference distinguishes maximum output tokens, per-model-attempt timeout, and retries. Those settings govern narrower attempts; the orchestrator still owns the logical deadline and cumulative allowance that spans several calls and tools.

Agent run budgets should classify limits as hard, soft-warning, or approval-gated. A soft threshold can ask the agent to summarize and prepare a checkpoint, whereas a hard authority or tool-call boundary must not be traded against cheaper tokens.

Runaway agent costs often appear after useful effects have already completed. Preserve those effect IDs and their settled charges, stop admitting new work, and make resume continue after the durable cursor; restarting from turn zero converts a budget control into a duplicate-side-effect generator.

Stop at a well-defined effect boundary

A clean stop can reject before start, cancel in flight, pause after a settled effect, or finish with a final answer. Each state needs a typed reason, the event that triggered it, the dimension exceeded, outstanding reservations, effect status, and whether a continuation is safe.

Use structured concurrency for AI-agent tools so child work belongs to a parent scope and receives cancellation when the run closes. Cancellation is a request, not proof: the parent must await or reconcile children before reporting that no external operation occurred.

Agent run budgets should prefer pausing after an idempotent checkpoint when the deadline is near. If immediate cancellation is necessary, persist “unknown” for any effect whose outcome cannot be proven and block a blind retry until that uncertainty is resolved.

Composite policy needs a deterministic tie-break. The lab evaluates constraints in a pinned order, but the receipt records every exhausted cap at that event, allowing an operator to see that tokens and time both failed even though only one typed reason controls the state machine.

Typed termination statesA branching state machine distinguishes rejection, cancellation, settled effects, completion, and resumable pause.startreservethen actreject-before-startfinal answerresumable pause
Typed termination states
A branching state machine distinguishes rejection, cancellation, settled effects, completion, and resumable pause.
Termination transitions
StateEffectResume?
Reject before startnone begunyes, with larger budget
Cancel in flightunknown until settledafter reconciliation
Final answercompletenot required
Pausecompleted IDs savedyes
Figure 2: A stop reason is useful only when effect state and resumability are explicit.

Return a typed stop reason to the product

“Agent failed” throws away the information a product needs. Define stable codes such as turn_limit, token_limit, deadline, tool_limit, retry_limit, composite_limit, authority_denied, and external_cancellation, then add a human explanation that does not expose hidden reasoning or sensitive tool arguments.

The response should distinguish a partial but useful result from an unsafe or indeterminate one. Include completed deliverables, pending steps, retry guidance, resumability, and a support correlation ID; only offer “continue” when the ledger proves remaining work can resume without duplicating effects.

Typed reasons also improve analytics. Product teams can see whether limits are too tight for a legitimate task class, platform teams can locate tool latency, and finance teams can measure prevented runaway cost without pretending every stopped run was a defect.

Retries spend two budgets: the attempt allowance and whatever tokens or tool time the failed attempt consumed. A retry counter that ignores usage produces deceptively tidy limits, while a usage ledger without attempt identity makes it impossible to explain why the same logical turn was billed twice.

Preserve a durable resume cursor

The cursor should point after the last fully settled event, not merely to the last line of a generated plan. Save the next state-machine node, completed effect IDs, input artifact digests, outstanding uncertainty, policy version, consumed totals, remaining allowances, and expiration conditions required to determine whether the context is still valid.

Agent run budgets must be revalidated on resume. A deadline may have passed, authorization may have changed, tool results may be stale, or the requested task may now conflict with a newer user instruction; checking those facts is safer than replaying serialized model state blindly.

When a resume requires new capacity, append a policy transition rather than editing the historical ceiling. That makes the authorization to spend more reviewable and keeps reports from comparing an original 10,000-token run with a silently expanded one.

Agent run budgets become debuggable when each reservation, settlement, refusal, and cursor update is append-only. Replay that ledger to reconstruct remaining authority; do not trust a mutable remaining-tokens field whose provenance disappeared after a worker crash or manual intervention.

Budget policy also needs a trustworthy clock and concurrency story. Use a monotonic elapsed-time source inside a process, persist absolute deadlines for recovery, and document how clock skew is handled across workers. Serialize reservations or use an atomic conditional update so parallel branches cannot each spend the same remaining allowance. The frozen simulator is single-threaded, so those distributed guarantees remain integration obligations rather than demonstrated results.

Test hostile run traces before deployment

Create fixtures that reach each boundary exactly, exceed it by one unit, settle below a reservation, crash after an effect begins, receive a late usage receipt, and resume with changed policy. Property tests can assert that totals never decrease, no rejected action is charged, completed effects stay unique, and a cursor never advances past an unsettled operation.

The included simulator stops a declared event before it starts and proves the earlier effects remain listed. Because it uses fixed values and no network, identical inputs reproduce the ledger; it cannot establish a vendor’s timeout precision or guarantee that a remote tool honored cancellation.

Pair those deterministic checks with integration exercises in a sandbox. Verify idempotency, telemetry, time sources, queue delivery, and reconciliation under process termination, then document which portions of the policy are enforced locally versus delegated to an SDK, model provider, or tool service.

The browser-facing stop message should name the constrained resource without exposing private chain-of-thought or internal policy detail. Offer a safe resume path when one exists, preserve completed output, and keep an operator-only receipt for the deeper event sequence and reconciliation decisions.

Reservation ledger waterfallThe frozen ledger shows reservation, actual settlement, released token headroom, effect identity, and cursor-three pause/resume.reservesettlereleasefx-storecursor 3before e4
Reservation ledger waterfall
The frozen ledger shows reservation, actual settlement, released token headroom, effect identity, and cursor-three pause/resume.
Executed run-budget ledger
EventReservedActualResult
e5 model turn6,200 tokens4,8101,390 released
e6 tool1 call1 calleffect fx-store complete
pause before e4cursor 3three effects savedresume completes with zero overshoot
Figure 3: Reservation prevents oversubscription; idempotent resume preserves what actually settled.

Operate one budget ledger as product evidence

Dashboard remaining capacity and stop reasons by task class, but protect user prompts and tool payloads from broad observability access. Useful aggregates include starts, completions, pauses, median resource share, boundary frequency, late receipts, unresolved effects, resumes, and top-ups; a rise in token stops means something different from a rise in deadlines.

Keep runtime limits separate from permission budgets for AI agents and from admission control. Authority says which actions are allowed, admission says whether shared infrastructure can accept work, and the run ledger says what this admitted, authorized task may consume.

Review the contract when the Agents SDK lifecycle, usage schema, timeout behavior, retry behavior, or durable state integration changes. Run the frozen simulator first, then one sandbox trace, and ship only when the product can explain exactly why a bounded run stopped and how it may safely continue.

Revisit the policy when runner lifecycle, usage schemas, retry semantics, or durable execution changes. The newsletter plate can show five independent rings, while the runnable trace remains the canonical proof that agent run budgets stop cleanly and retain a valid resume cursor.