Skip to content

Add budgets to cap what an agent may spend in money, tokens, or working time #3701

Description

@dwin-gharibi

Overview

There is no way to bound what an agent consumes. max_iterations caps how many turns a run takes, but a turn is not a unit of cost: ten turns against gpt-4o with large contexts cost far more than a hundred against gpt-4o-mini. An agent that fans out to sub-agents multiplies that further, and nothing stops a runaway or badly-prompted session from spending until someone notices and hits Ctrl+C.

This matters most exactly where supervision is thinnest - unattended jobs, CI, background agents, and demos handed to someone else.

Motivation

The runtime already computes everything needed to enforce this. It just has no way to act on it:

  • computeMessageCost (pkg/runtime/loop.go) prices every response from usage and the model's pricing table.
  • Model pricing comes from the models.dev catalogue, or an explicit model-level cost: block (CostConfig).
  • after_llm_call hooks receive that exact per-call cost, and the docs describe using it for "a sidecar cost ledger".

But a ledger can only record. executeAfterLLMCallHooks (pkg/runtime/hooks.go) calls dispatchHook and discards the returned *hooks.Result - both callsites (loop.go, harness.go) invoke it as a bare statement, and pkg/runtime/after_llm_call_test.go locks that in. Continue: false is only honored in pkg/hooks/executor.go, which the event never reaches.

So after_llm_call is observational by design: a hook can watch spend accumulate and can do nothing about it. Enforcement has to live in the runtime.

Use cases

1. A budget name is one shared pot. When several agents reference shell-work, they draw from the same ceiling, not a copy each. This is the whole point: per-agent copies would let a run spend max_cost × N by fanning out to N sub-agents, and the ceiling would mean nothing for exactly the workloads that most need one. Distinct names give independent pots. The same applies to sub-sessions, which share the root's wallets - they run on the same LocalRuntime (loop.go already distinguishes rootStream := !sess.IsSubSession()), so a tracker installed there is reached by every child.

Note this intentionally differs from max_iterations, where per-child caps (agent_delegation.go) are the right semantics. Money doesn't behave like iterations, so that precedent must not be copied.

2. A budget spans the session, not a message. RunStream is invoked once per user message. Resetting there would silently redefine max_cost as "per message" - a session could spend the whole ceiling again on every turn, which is not a budget at all.

3. max_time must measure working time, not wall-clock. Follows from (2): a session sits idle while the user reads and types. Wall-clock would let a budget expire during a coffee break - leave the TUI open ten minutes and the next message instantly trips max_time: 2m without the agent having done anything. It has to accumulate turn durations.

4. Tokens need their own accumulator. max_tokens cannot read session.Usage(): ApplyCompaction resets those counters because they measure context length, not spend. Reading them would make max_tokens silently stop working on exactly the long runs it exists for.

5. Unpriced models can't be enforced against. computeMessageCost returns nil for a model with no pricing table (unknown ID, or a custom endpoint). There is no honest number to add, so such spend cannot count towards max_cost. Failing the run closed would break local/custom-endpoint users; counting it as $0 would be a lie that reads reassuringly low precisely because it is incomplete. Warn once, mark the reading, and document cost: as the fix.

6. Terminal, not resumable. Unlike max_iterations, no "continue?" prompt. A budget is a ceiling set deliberately; offering to raise it defeats the point. Raising it means editing the config. This also avoids touching resumeChan and the headless consumers.

7. Boundary-checked. Checked between turns, like max_iterations, so a run overshoots by at most the turn in flight and max_time won't interrupt an in-flight call. A hard context.WithTimeout would be tighter but changes error semantics for in-flight tools; boundary checking is the conservative first cut and should be documented as such.

Proposed solution

Two ways to declare a budget, composing:

# One ceiling for the whole session, charged for every agent.
budget:
  max_cost: 0.50      # USD
  max_tokens: 100000  # cumulative input+output
  max_time: 10m       # time agents spend working

# Named budgets an agent opts into by name.
budgets:
  shell-work:
    max_cost: 0.03
  research:
    max_time: 1m

agents:
  root:
    budgets: [shell-work]
  researcher:
    budgets: [shell-work, research]   # shares shell-work with root

Every limit optional; unset means unlimited; declaring nothing leaves runs unbudgeted (the default, so existing configs are unaffected). Crossing any ceiling stops the run with a message naming the exact YAML path to raise, and the TUI tracks every budget live, broken down per agent.

The named form follows the existing top-level mcps / rag / toolsets reference-by-name convention.

Alternatives

No response

Related issues

No response

Additional context

  • BudgetConfig, Config.Budget, Config.Budgets, AgentConfig.Budgets; validation (negative limits, unknown budget names); agent-schema.json + an HCL block rule for the budgets map.
  • A budgetTracker / budgetSet in pkg/runtime, installed once per runtime, recorded from the existing computeMessageCost value so the ceiling can never disagree with what the session bills.
  • Enforcement beside enforceMaxIterations; a budget_exceeded stream end reason.
  • budget_usage / budget_exceeded events; sidebar tracking with per-agent breakdown; warning on stop.
  • Docs page, runnable examples/budget.yaml, tests.

loopState's own doc comment already anticipates this feature by name - "trivial to add new per-stream tracking (cost ceiling, token budget, turn timing)".

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/agentFor work that has to do with the general agent loop/agentic features of the apparea/configFor configuration parsing, YAML, environment variablesarea/runtimeRuntime engine, agent loop execution, tool dispatch, loop detectionarea/tuiFor features/issues/fixes related to the TUI

    Fields

    No fields configured for Enhancement.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions