Bound Each Branch and the Whole Run
Max turns isn't enough. You need independent limits on calls, tokens, cost, wall-clock time, fan-out depth, and side effects. A multi-agent run can end logically (an acceptance gate passed), technically (a fatal error or cancellation), or economically (the next step no longer fits the budget) - and all three outcomes must be provided for.
import type { BudgetLimits, BudgetSnapshot, Usage } from "./types.js";
export class BudgetExceededError extends Error {
public constructor(message: string) {
super(message);
this.name = "BudgetExceededError";
}
}
export class BudgetLedger {
readonly #limits: BudgetLimits;
#usedCalls = 0;
#usedTokens = 0;
#usedCostUnits = 0;
#usedSimulatedLatencyMs = 0;
public constructor(limits: BudgetLimits) {
this.#limits = limits;
}
public consume(usage: Usage): void {
const next = {
calls: this.#usedCalls + usage.calls,
tokens: this.#usedTokens + usage.tokens,
costUnits: this.#usedCostUnits + usage.costUnits,
latency: this.#usedSimulatedLatencyMs + usage.simulatedLatencyMs
};
if (next.calls > this.#limits.maxCalls) {
throw new BudgetExceededError("Agent call budget exceeded");
}
if (next.tokens > this.#limits.maxTokens) {
throw new BudgetExceededError("Token budget exceeded");
}
if (next.costUnits > this.#limits.maxCostUnits) {
throw new BudgetExceededError("Cost budget exceeded");
}
if (next.latency > this.#limits.maxSimulatedLatencyMs) {
throw new BudgetExceededError("Latency budget exceeded");
}
this.#usedCalls = next.calls;
this.#usedTokens = next.tokens;
this.#usedCostUnits = next.costUnits;
this.#usedSimulatedLatencyMs = next.latency;
}
public remainingCalls(): number {
return this.#limits.maxCalls - this.#usedCalls;
}
public snapshot(): BudgetSnapshot {
return {
...this.#limits,
usedCalls: this.#usedCalls,
usedTokens: this.#usedTokens,
usedCostUnits: this.#usedCostUnits,
usedSimulatedLatencyMs: this.#usedSimulatedLatencyMs
};
}
}Place the limits by level so one bad branch doesn't drag down the whole run.
Level · Limit · Why
- Tool call - Timeout, output cap, retry count; One bad tool doesn't hold the worker
- Worker - Calls, tokens, time, local attempts; A specialist doesn't consume the whole run
- Fan-out - Workers, concurrency, depth; The planner doesn't create a recursive explosion
- Run - Total cost, deadline, side-effect count; The global upper bound
- User or tenant - Rate and daily spend; One actor doesn't drain the service
Anthropic describes an early failure mode where a lead agent could spawn about 50 subagents for a simple query, and recommends scaling effort by complexity; AutoGen provides explicit termination conditions and max turns for teams. The specific numbers depend on the domain, but having a policy doesn't depend on the framework.
Don't return an empty "didn't finish." On a budget stop, save the completed artifacts, unresolved work items, the reasons for stopping, and a safe resume point. A partial result must be explicitly marked partial.
Limits protect one run. But a long task survives a process crash only if orchestration is separated from the agent loop and durable.