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)".
Overview
There is no way to bound what an agent consumes.
max_iterationscaps how many turns a run takes, but a turn is not a unit of cost: ten turns againstgpt-4owith large contexts cost far more than a hundred againstgpt-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 fromusageand the model's pricing table.cost:block (CostConfig).after_llm_callhooks receive that exact per-callcost, and the docs describe using it for "a sidecar cost ledger".But a ledger can only record.
executeAfterLLMCallHooks(pkg/runtime/hooks.go) callsdispatchHookand discards the returned*hooks.Result- both callsites (loop.go,harness.go) invoke it as a bare statement, andpkg/runtime/after_llm_call_test.golocks that in.Continue: falseis only honored inpkg/hooks/executor.go, which the event never reaches.So
after_llm_callis 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 spendmax_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 sameLocalRuntime(loop.goalready distinguishesrootStream := !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.
RunStreamis invoked once per user message. Resetting there would silently redefinemax_costas "per message" - a session could spend the whole ceiling again on every turn, which is not a budget at all.3.
max_timemust 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 tripsmax_time: 2mwithout the agent having done anything. It has to accumulate turn durations.4. Tokens need their own accumulator.
max_tokenscannot readsession.Usage():ApplyCompactionresets those counters because they measure context length, not spend. Reading them would makemax_tokenssilently stop working on exactly the long runs it exists for.5. Unpriced models can't be enforced against.
computeMessageCostreturnsnilfor a model with no pricing table (unknown ID, or a custom endpoint). There is no honest number to add, so such spend cannot count towardsmax_cost. Failing the run closed would break local/custom-endpoint users; counting it as$0would be a lie that reads reassuringly low precisely because it is incomplete. Warn once, mark the reading, and documentcost: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 touchingresumeChanand the headless consumers.7. Boundary-checked. Checked between turns, like
max_iterations, so a run overshoots by at most the turn in flight andmax_timewon't interrupt an in-flight call. A hardcontext.WithTimeoutwould 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:
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/toolsetsreference-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 thebudgetsmap.budgetTracker/budgetSetinpkg/runtime, installed once per runtime, recorded from the existingcomputeMessageCostvalue so the ceiling can never disagree with what the session bills.enforceMaxIterations; abudget_exceededstream end reason.budget_usage/budget_exceededevents; sidebar tracking with per-agent breakdown; warning on stop.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)".