An agent budget controller is a deterministic admission and accounting layer that reserves resources before work starts, commits actual usage afterward, and stops new work when any hard ceiling is reached. Asking the model to “be economical” is not enforcement.
This guide builds a framework-neutral controller for tokens, model calls, tool calls, wall-clock time, concurrency, and estimated money. It is designed for nested workflows in which a coordinator can delegate to workers.
Budget several resources independently
A single dollar ceiling is not enough. A run can be cheap but take too long, stay within tokens but overload a downstream API, or use few calls that each trigger an expensive tool.
Define at least:
| Dimension | Why it needs a limit |
|---|---|
| Model calls | Stops recursive planning and repair loops |
| Input/output tokens | Bounds context growth and generation |
| Tool calls | Protects external systems and API quotas |
| Estimated money | Provides a business-level ceiling |
| Wall-clock deadline | Preserves user and queue latency |
| Concurrent work | Prevents bursts and resource starvation |
| Delegation depth | Stops recursive fan-out |
| Attempts per operation | Stops retries from consuming the entire run |
OWASP identifies denial-of-wallet and recursive tool abuse as agent risks and recommends limits on chain depth, retries, tokens, and cost. It also recommends circuit breakers and monitoring rather than trusting the agent to terminate itself. OWASP AI Agent Security Cheat Sheet
Treat budgets as capabilities
The parent grants each child a sub-budget. A child cannot spend the parent's entire remainder unless the parent explicitly delegates it.
interface BudgetLimit {
readonly modelCalls: number;
readonly toolCalls: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly estimatedMicros: bigint;
readonly deadlineAtMs: number;
readonly maxDepth: number;
}
interface Usage {
readonly modelCalls: number;
readonly toolCalls: number;
readonly inputTokens: number;
readonly outputTokens: number;
readonly estimatedMicros: bigint;
}
interface BudgetGrant {
readonly id: string;
readonly parentId?: string;
readonly depth: number;
readonly limit: BudgetLimit;
readonly used: Usage;
readonly reserved: Usage;
readonly status: "open" | "exhausted" | "closed" | "cancelled";
}
Use integers for accounting. Monetary examples here use millionths of a currency unit; choose and record one currency. Do not use binary floating point for authorization decisions around exact monetary ceilings.
The price table is configuration, not a constant embedded in prompts. Record its version with each estimate. Provider billing can distinguish input, output, cached, reasoning, image, or audio units; represent only the categories you can reconcile from authoritative usage records.
Reserve before calling
Without reservation, ten workers can all observe the same remaining budget and each start an allowed call whose combined usage exceeds the ceiling.
Use an atomic reservation:
interface ReservationRequest {
readonly modelCalls: number;
readonly toolCalls: number;
readonly maxInputTokens: number;
readonly maxOutputTokens: number;
readonly maxEstimatedMicros: bigint;
}
type ReservationResult =
| { readonly accepted: true; readonly reservationId: string }
| {
readonly accepted: false;
readonly reason: "limit" | "deadline" | "closed";
};
async function reserve(
grantId: string,
request: ReservationRequest,
): Promise<ReservationResult> {
// In production, perform the read, limit check, and reservation write
// in one transaction or compare-and-swap operation.
return budgetStore.reserveAtomically(grantId, request, Date.now());
}
Reserve the maximum response allowed for the call, not the response you hope to receive. After completion, commit measured usage and release the unused reservation. On timeout with no trusted usage record, retain a conservative reservation until reconciliation or charge the reserved amount according to policy.
available = limit - committed - reserved
Never let a child release or modify a sibling's reservation. Scope every grant and reservation to tenant, run, and parent.
Distinguish estimates from billing facts
Tokenizers and provider usage reports may not align with a preflight estimate. Tool costs can depend on rows scanned, pages fetched, or external pricing that becomes known only afterward.
Maintain separate fields:
- estimated usage: used for admission before execution;
- reported usage: returned by the model or tool provider;
- billed usage: imported from billing data when available;
- variance: monitored for pricing, tokenizer, or instrumentation drift.
OpenTelemetry's GenAI attribute registry defines usage fields such as input and output tokens and notes how cached and reasoning tokens relate to totals. Those semantic conventions are still version-sensitive; record the convention and instrumentation versions rather than assuming a stable universal billing schema. OpenTelemetry GenAI attribute registry
Do not fabricate missing provider usage by labeling a local token estimate as measured. It can still drive a conservative admission policy if named accurately.
Allocate sub-budgets explicitly
For nested work, the parent reserves a grant before creating the child:
interface ChildGrantRequest {
readonly parentGrantId: string;
readonly childId: string;
readonly limit: BudgetLimit;
}
function validateChildGrant(
parent: BudgetGrant,
child: ChildGrantRequest,
): void {
if (parent.depth + 1 > parent.limit.maxDepth) {
throw new Error("Delegation depth exceeded");
}
if (child.limit.deadlineAtMs > parent.limit.deadlineAtMs) {
throw new Error("Child deadline exceeds parent deadline");
}
// The store must atomically reserve every child limit against the
// parent's available capacity before the child can start.
}
A grant can be smaller than the child's theoretical maximum. That is the point: the coordinator decides which branch deserves scarce resources. Reclaim unused capacity only after the child reaches a terminal state and no charge can arrive later.
The agent-swarm guide explains why decomposition must have clear contracts, while the bounded-parallelism guide handles concurrency at the worker pool. A budget controller complements both; it does not replace rate limiting or backpressure.
Define behavior near exhaustion
The controller needs a policy for insufficient remaining budget:
- Reject: do not start work that cannot fit.
- Degrade: choose a separately evaluated lower-cost path.
- Return partial: surface completed artifacts and missing coverage.
- Ask: request authorization for a larger budget before proceeding.
Do not silently lower quality for consequential decisions. If the approved workflow requires independent verification and the review grant cannot fit, return budget_exhausted; do not ship the unreviewed artifact as if it passed.
Use soft thresholds for warnings and hard thresholds for enforcement. For example, a soft threshold can trigger context compaction or stop speculative work. It must not weaken an authorization or evidence requirement.
Handle cancellation and ambiguous completion
Cancellation should propagate through an AbortSignal or equivalent, but external work may ignore it or finish after the local deadline. Closing the grant prevents new reservations; it does not prove that in-flight effects stopped.
Record:
- request and reservation IDs;
- provider operation IDs;
- cancellation time and acknowledgment;
- measured or estimated final usage;
- any external effect requiring reconciliation.
For side-effecting tools, budget exhaustion must never trigger a blind retry. Follow the outcome-unknown and idempotency pattern in Durable AI Agents.
Observe without leaking content
Useful run-level signals include available budget, reservation failures, usage by component, estimate-to-report variance, terminal reason, and work discarded after cancellation. Avoid putting prompts, retrieved documents, tool arguments, or secrets into metric labels. High-cardinality identifiers belong in protected traces or logs, not metric dimensions.
Alert on:
- rapid repeated reservations from one run;
- consistent token-estimate undercounting;
- children returning usage above their grant;
- usage arriving after grant closure;
- high budget-exhaustion rates by task class;
- unusual cost per successful task.
Cost per run alone can reward cheap failures. Pair resource measures with task-success and safety evaluations.
Test the controller with ordinary deterministic tests
Create race tests in which many workers reserve concurrently and assert committed plus reserved usage never exceeds the grant. Also test:
- exact-boundary reservations;
- expired deadlines;
- rollback after a request fails before dispatch;
- partial use followed by release;
- duplicate commit and release messages;
- child allocation across siblings;
- late usage after cancellation;
- integer overflow and negative input rejection;
- price-table version changes;
- parent closure while children are running.
Property-based tests are useful for sequences of reserve, commit, release, close, and cancel operations. The invariant is that accounting never becomes negative and no accepted reservation exceeds an ancestor's capacity.
Budget-controller checklist
- Enforce separate call, token, tool, time, concurrency, depth, and money limits.
- Reserve atomically before dispatch.
- Commit actual usage and release only reconciled remainder.
- Allocate child grants from parent capacity.
- Use integer accounting and a versioned price table.
- Label estimates, provider reports, and billing facts separately.
- Prevent new work after cancellation while reconciling in-flight work.
- Surface partial or exhausted outcomes honestly.
- Test races, duplicate messages, and late reports.
- Evaluate any lower-cost fallback before enabling it.