An agent can solve a simple task with one call and spend much more once it reads files, calls tools, revises a hypothesis, and tries again. The problem appears when the loop has a turn limit but nobody knows how much the next call can still consume.

The fix is to make the budget part of the runtime. Record confirmed usage, estimate the next call, reserve room for it, and stop before sending it when the balance is not enough. The model can suggest economy, but it should not be the component enforcing the limit.

This post uses TypeScript without a provider SDK. The callModel and tool adapters are illustrative. The guard's contract can be verified with local tests and connected to any provider that returns token usage.

Diagram showing an AI agent recording tokens, checking the remaining budget, and stopping before the next call.

Short rule

  • A per-request limit does not cap an entire agent loop.
  • Check the budget before the next call, not only after it.
  • Input, output, reasoning, tool, and cached tokens must follow the provider's usage policy.
  • The final state should say whether the agent completed, exhausted its budget, or stopped with unknown usage.

Why is max_tokens not a run budget?

An output parameter limits one call. An agent can make several calls, repeat a tool, and resend a larger history on every round. Even when every call respects its own ceiling, the run remains unbounded if the runtime does not add up the usage.

OpenAI's reasoning model documentation explains that max_output_tokens includes reasoning tokens, visible output, and internal formatting. It also shows input_tokens, output_tokens, reasoning_tokens, and total_tokens in response usage. That helps measure a call, but it does not create a ceiling for the next turn.

Anthropic's token counting guide can count a message with tools before generation and returns input_tokens. Gemini's token counting guide exposes separate input, output, thought, cache, tool-use, and total fields. The names differ. The architectural decision does not: normalize adapter usage into one internal record.

The durable execution owner for AI agents covers where to persist state when a run must continue after failure. This post takes the narrower step before that: deciding whether the next operation still fits the budget.

Which limits should an agent combine?

A useful budget has at least three dimensions, plus an optional clock:

Limit Controls Does not solve by itself
Tokens Input, output, and reasoning usage reported by the provider A tool that hangs without generating tokens
Tool calls How many external operations may start Large context in a small number of calls
Turns How many model-tool rounds the loop may execute One expensive call
Time How long the run may remain active Usage already spent before timeout

The OpenAI Agents JS running agents guide documents maxTurns as a loop limit. Use a similar policy even in a custom agent. It is a backstop, not a substitute for a token budget.

The budget also needs a clear unit. You can control tokens, estimated cost, or internal credits. If you do not want to tie the runtime to a model price, control tokens and keep pricing in observability. Prices and billing rules change. The provider's usage event is the more stable evidence for a stop decision.

How should the runtime represent the budget?

Keep policy separate from the loop. The policy says how much a run may use. The guard decides whether a call fits. The provider adapter reports usage after the call ends.

type Usage = {
  inputTokens: number;
  outputTokens: number;
  reasoningTokens?: number;
  toolTokens?: number;
};

type BudgetLimits = {
  totalTokens: number;
  maxToolCalls: number;
  maxTurns: number;
};

class BudgetExceeded extends Error {
  constructor(readonly reason: "tokens" | "tools" | "turns") {
    super(`agent budget exceeded: ${reason}`);
  }
}

class RunBudget {
  private usedTokens = 0;
  private toolCalls = 0;
  private turns = 0;

  constructor(private readonly limits: BudgetLimits) {}

  beforeTurn() {
    if (this.turns >= this.limits.maxTurns) {
      throw new BudgetExceeded("turns");
    }
  }

  beforeToolCall() {
    if (this.toolCalls >= this.limits.maxToolCalls) {
      throw new BudgetExceeded("tools");
    }
    this.toolCalls += 1;
  }

  reserveModelCall(estimatedTokens: number) {
    if (this.usedTokens + estimatedTokens > this.limits.totalTokens) {
      throw new BudgetExceeded("tokens");
    }
  }

  recordModelCall(usage: Usage) {
    this.usedTokens +=
      usage.inputTokens +
      usage.outputTokens +
      (usage.reasoningTokens ?? 0) +
      (usage.toolTokens ?? 0);
    this.turns += 1;
  }

  snapshot() {
    return {
      usedTokens: this.usedTokens,
      toolCalls: this.toolCalls,
      turns: this.turns,
      remainingTokens: this.limits.totalTokens - this.usedTokens,
    };
  }
}

The numbers in the example are test policies, not universal recommendations. The guard does not need to know the provider price. It needs a conservative estimate for the next call and a usage update from the adapter.

There is an important limit: an estimate can be wrong. If it is too small, the runtime can admit a call that exceeds the budget. If it is too large, the agent stops before using the full allowance. Treat the limit as a safety boundary and record the difference between reserved and actual usage.

Where should the loop check the budget?

Check before each operation that can consume a resource. The order matters. Checking after the call turns an overrun into a surprise.

type ModelResult =
  | { type: "final"; text: string; usage: Usage }
  | { type: "tool_calls"; calls: ToolCall[]; usage: Usage };
type ToolCall = { name: string; input: unknown };

async function runAgent(prompt: string, budget: RunBudget) {
  for (;;) {
    budget.beforeTurn();
    budget.reserveModelCall(estimateNextCall(prompt));

    const response = await callModel({ prompt }); // illustrative adapter
    budget.recordModelCall(response.usage);

    if (response.type === "final") {
      return { status: "completed", text: response.text, usage: budget.snapshot() };
    }

    const results = [];
    for (const call of response.calls) {
      budget.beforeToolCall();
      results.push(await executeTool(call)); // illustrative adapter
    }

    prompt = appendToolResults(prompt, results);
  }
}

The example shows two separate policies. reserveModelCall protects model usage. beforeToolCall limits external operations and effects. A tool can also have its own cost, such as a paid query or browser operation. In that case, add a second ledger or convert the cost into internal credits, but do not hide the event inside the model message.

The post about cancelling an AI agent when a tool hangs covers timeout and cancellation. A budget complements that control: it can stop a run that is responsive but making more calls than the task allows.

How should the runtime handle provider usage?

Normalize usage fields as soon as a call ends. Do not add only visible output. In reasoning models, internal tokens may count toward the limit. In tool calls, definitions and results may contribute to usage too.

function normalizeUsage(raw: {
  input_tokens?: number;
  output_tokens?: number;
  total_tokens?: number;
  output_tokens_details?: { reasoning_tokens?: number };
  tool_use_prompt_tokens?: number;
}): Usage {
  return {
    inputTokens: raw.input_tokens ?? 0,
    outputTokens: raw.output_tokens ?? 0,
    reasoningTokens: raw.output_tokens_details?.reasoning_tokens ?? 0,
    toolTokens: raw.tool_use_prompt_tokens ?? 0,
  };
}

This adapter is illustrative because each SDK uses different fields. Adapter tests should pin real provider responses and fail when the SDK changes shape. Do not add total_tokens to input and output in the same sum, or the same usage will be counted twice. Choose one source of truth per response.

The Gemini guide also describes count_tokens before generation. Use it when input size is the main risk, but remember that generation can consume output, thought, or tool-use tokens afterward. Counting improves the reservation; it does not replace recording usage.

What should happen when the budget runs out?

Exhausting the budget is not the same as failing. The agent may have produced a partial answer, completed an important tool, or stopped before any external effect. The result must carry enough state for the caller to decide.

type StopReason = "completed" | "budget_tokens" | "budget_tools" | "budget_turns" | "failed";

type RunResult = {
  status: StopReason;
  text?: string;
  usage: ReturnType<RunBudget["snapshot"]>;
  retryable: boolean;
};

A token stop can be retryable: false when the next step would repeat the same task without changing context. It can also be retryable: true when a summary policy, cheaper model, or checkpointed queue can resume the work. The runtime owns that choice, not the agent's generated text.

Do not automatically raise the budget when the agent asks for more time. That turns a predictable boundary into a negotiation performed by the component consuming the resource. If escalation exists, define it outside the loop, record the new authorization, and apply another ceiling.

How can you verify that the guard works?

The useful unit test does not call a real model. It injects known usage and proves that the next call cannot start after the limit. An integration test can validate the provider adapter separately.

const budget = new RunBudget({
  totalTokens: 100,
  maxToolCalls: 2,
  maxTurns: 3,
});

budget.reserveModelCall(60);
budget.recordModelCall({ inputTokens: 40, outputTokens: 20 });

// An estimate of 41 tokens for the next call must be blocked.
expect(() => budget.reserveModelCall(41)).toThrow("tokens");

budget.beforeToolCall();
budget.beforeToolCall();
expect(() => budget.beforeToolCall()).toThrow("tools");

This uses a Vitest or Jest-style assertion and is intentionally a small fixture. In a real suite, also cover:

  • reasoning usage that is not visible in the answer;
  • tool use counted as input;
  • an estimate larger than the remaining balance;
  • maxTurns reached before another call;
  • an adapter failure before usage is returned;
  • an interruption after an external write without confirmation.

When a call ends without usage, do not treat the value as zero. Mark usage as unknown and choose a conservative policy, such as blocking the next call or applying a smaller residual limit. A false zero makes the ledger optimistic on the exact failure path where it should be cautious.

Should the budget appear in observability too?

Yes. The post about coding-agent observability in CI explains why a log needs to show what the agent did and how it was verified. For a budget, record run_id, model, turn, tool_calls, reserved_tokens, used_tokens, remaining_tokens, stop_reason, and usage_status.

Do not log prompts, secrets, or complete results just to explain an overrun. A short event lets you group spend by run and compare estimates with actual usage. If telemetry shows that most usage comes from repeated history, the next step may be a summary or prompt caching for multi-step tool calls, not a larger limit.

In long Claude Code, Codex, or custom harness runs, I use RemoteCode to continue agentic workflows with less repeated context. It is the author's tool, mentioned because context and budget interact. It does not replace the guard, ledger, or stop decision.

Frequently asked questions

Can a prompt enforce the budget?

Not safely. A prompt can guide the model, but the application must measure usage and decide whether the next call may start. The boundary should sit outside the model's free decision.

Should I control tokens or money?

Tokens are a more stable unit across environments, while money is better for billing and alerts. An application can control tokens per run and calculate cost in a separate layer using the provider's current price.

Does a turn limit replace a budget?

No. One turn can consume many tokens, and many small calls can exceed the allowed cost. Combine tokens, tool calls, and turns, with a timeout when the work also has a deadline.

Can I retry a run that exhausted its tokens?

Only with an explicit policy. Before retrying, check the checkpoint, external effects, and stop reason. If context has not changed, a retry is likely to spend more without adding information.

Conclusion

An economical agent does not come from a system-prompt instruction. It comes from a loop that measures usage, reserves the next call, and stops before the budget is exceeded. Tokens, tools, turns, and time protect different risks, so the runtime needs to record each stop reason.

Start with a small guard, one ledger per run, and three tests: insufficient balance, tool limit, and unknown usage. Then compare estimates with the provider's actual usage. That gives you an honest basis for choosing between summarizing, switching models, resuming from a checkpoint, or ending the run.

Sources consulted