Chapter 19

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.

TypeScript
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.

Links