Per-run token and dollar limits with threshold events, and an optional pause or cancel when a limit is reached. Counts delegated child runs too.
Budget
A Budget tracks configurable token and dollar limits for one run; it can warn, pause, or cancel when a limit is reached. It emits governance events as usage crosses configurable thresholds and when a limit is reached. What happens at the limit is up to on_exceed:
By default a budget also counts the run's delegated child runs (include_children=True), so a parent cannot be undercut by the agents it delegates to.
The enforcement is deliberately cooperative. A hard stop that preempted an in-flight LLM call or tool would leak cost (the provider already charged for the call) and leave the run half-observed. Dendrux instead acts at a boundary it controls: the start of the next iteration. The price is a bounded overshoot, described below.
Declaring a budget
import asyncio
from dendrux import Agent, Budget
from dendrux.llm.anthropic import AnthropicProvider
from dendrux.store import RunStore
async def main() -> None:
url = "sqlite+aiosqlite:///demo.db"
async with Agent(
name="QA",
provider=AnthropicProvider(model="claude-haiku-4-5"),
prompt="Answer in one short sentence.",
# A deliberately tiny cap so the thresholds fire on one short call.
budget=Budget(max_tokens=10, warn_at=(0.5, 0.75, 0.9)),
database_url=url,
) as agent:
result = await agent.run("What is the capital of France?")
store = RunStore.from_database_url(url)
async with store:
print([e.event_type for e in await store.get_events(result.run_id)])
# ['run.started', 'llm.completed', 'budget.threshold', 'budget.threshold',
# 'budget.threshold', 'budget.exceeded', 'run.completed']
print(result.status.value) # success (on_exceed defaults to "warn")
asyncio.run(main())All the arguments:
At least one of max_tokens and max_cost_usd is required; Budget() raises ValueError. So does a non-positive limit, a warn_at fraction outside (0, 1), or an unknown on_exceed.
Tokens are input_tokens + output_tokens, summed over the LLM calls. input_tokens counts fresh (non-cached) input, so cache reads and writes are not in the token total. The dollar limit is where caching shows up: cost is what the provider reported, or what your PriceTable computes.
Dollar limits
max_cost_usd needs a cost per call, from the provider (OpenRouter reports it) or from a PriceTable on the agent. A call with neither is unpriced. The dollar check counts the priced calls only, and the first time it sees an unpriced call in a run it writes one budget.unpriced event:
{"metric": "cost_usd", "used": 0.0132, "max": 0.5, "unpriced_calls": 1, "known_cost_usd": 0.0132}An unpriced call contributes no amount to the dollar comparison. budget.unpriced exposes that gap; without complete provider pricing or a PriceTable, max_cost_usd can under-count. Give the agent a price table for every model it uses. See Cost and pricing.
What the events look like
A real run, captured against a mock provider: a two-iteration agent with Budget(max_tokens=500, on_exceed="pause") whose first LLM call uses 600 tokens and requests a tool:
status: paused meta["budget_exceeded"] = [{"metric": "tokens", "used": 600, "max": 500}]
run_events:
seq=0 run.started
seq=1 llm.completed (first call, 600 tokens)
seq=2 budget.threshold {"metric": "tokens", "fraction": 0.5, "used": 600, "max": 500, "reason": "threshold_crossed"}
seq=3 budget.threshold {"metric": "tokens", "fraction": 0.75, "used": 600, "max": 500, "reason": "threshold_crossed"}
seq=4 budget.threshold {"metric": "tokens", "fraction": 0.9, "used": 600, "max": 500, "reason": "threshold_crossed"}
seq=5 budget.exceeded {"metric": "tokens", "used": 600, "max": 500, "reason": "budget_exceeded", "action": "pause"}
seq=6 tool.completed (the tool from the same response still runs)
seq=7 run.paused {"status": "paused", "pending_tool_calls": [], "reason": "budget",
"exceeded": [{"metric": "tokens", "used": 600, "max": 500}]}
<-- later: submit_resume with a higher limit -->
seq=8 run.resumed {"resumed_from": "paused"}
seq=9 llm.completed
seq=10 run.completedThings to read out of the trace:
- Every fraction fired in one tick. The first call returned at 600 tokens, past every threshold. The runtime fires each threshold the usage crossed, so a reader gets the whole "50, 75, 90, exceeded" story.
- Each event fires once per run. Events are keyed by
(metric, threshold), and the keys are saved with every pause. A run that pauses for approval or for the budget and later resumes does not repeat them. - Payloads say which metric.
metricis"tokens"or"cost_usd";usedandmaxare in that metric's unit (dollars as a float).budget.exceededalso carriesaction: theon_exceedthat applies (warn,pauseorcancel). - The tool at seq=6 still ran. The pause came after the response that crossed the limit had been fully handled.
When a pause or cancel takes effect
Budget usage is evaluated after each LLM response, which is when events fire. (An enforcing budget that counts children also re-reads usage at the checkpoint, since delegated runs may have spent meanwhile.) The action, though, is taken at the next top-of-iteration checkpoint, after the tools from that response have run and their results are in the history. Acting there means a pause never strands a tool call without its result, and a cancel never abandons a call half-done.
The consequence is overshoot of up to one iteration: tokens and dollars spent by the iteration that crossed the limit, and by the tools it ran, still count. If the iteration that crosses the limit also produces the final answer, the run completes normally; there is no next checkpoint.
At the same checkpoint, controls are applied in a fixed order: cancel, then a requested pause, then the budget action, then inbox delivery. See Pause and resume. An approval, client-tool or input pause inside an iteration happens first; the budget is re-evaluated after the run resumes.
With on_exceed="warn", budgets are evaluated after each LLM response and never act at a checkpoint.
Resuming a budget pause
A budget pause is a paused run, so it resumes with submit_resume(run_id). Resuming is only useful if there is headroom, so there is a gate:
The run continues only if the resuming
Agent's limits are strictly above current usage for every metric that was exceeded. Otherwise it pauses again immediately, with the same reason.
Current usage is read live, not from the pause snapshot, so spending by child runs since the pause counts. If the resuming Agent has no limit on a metric that was exceeded (or no budget at all), that metric is not checked. The gate does not look at on_exceed: a run that paused for the budget re-pauses until the limit is raised, even if the resuming agent now says "warn".
# The budget pause
paused = await agent.run("Summarize the corpus.")
assert paused.status.value == "paused"
print(paused.meta["budget_exceeded"]) # [{'metric': 'tokens', 'used': 52310, 'max': 50000}]
# An operator raises the limit. Build the Agent with the higher budget.
bigger = Agent(provider=provider, prompt="...", tools=[...],
budget=Budget(max_tokens=200_000, on_exceed="pause"), database_url=url)
await bigger.submit_resume(paused.run_id)Because the budget lives on the Agent, "raising the limit" means resuming with an Agent built with a higher Budget.
Budget cancel
on_exceed="cancel" finishes the run as cancelled. It is distinguishable from a user cancel in two places: the row's failure_reason is "budget_exceeded", and the run.cancelled event has {"reason": "budget_exceeded", "exceeded": [{"metric", "used", "max"}]} instead of "cancel_requested".
Children and roll-up
With include_children=True the number being compared with the limit is the root-inclusive subtree rollup: the run's own usage plus every descendant's, read live from llm_interactions. There is no double counting. A parent waiting on a delegated child therefore sees the child's spend as it happens, not when the child finishes. This is the same figure RunStore.get_subtree(run_id).usage returns.
include_children=False limits the run to its own calls.
A child run is a run with its own Agent, and its own budget if it has one. Each budget counts its own subtree.
Usage source and persistence
- With persistence (a database or
state_store), usage is read live from successfully recordedllm_interactions. Those writes are best-effort, so a failed write can make the rollup under-count; if the live read itself raises, the tracker falls back to in-memory usage. - Without persistence, usage is the loop's in-memory total.
include_childrencannot be honored (there is no store to roll up) and is ignored, with a one-time log warning. A pause can only be resumed from persisted state, so useon_exceed="pause"with a database.
Paused runs write their running totals (tokens and total_cost_usd) to the row when they pause, so a paused run no longer reads as zero spend.
SingleCall
SingleCall makes one LLM call and has no checkpoint after it. Events still fire (budget.threshold, budget.exceeded), but "pause" and "cancel" degrade to "warn"; the budget.exceeded event reports "action": "warn".
Applying action from the events
When on_exceed is not enough (a custom policy, a per-tenant throttle), the same events are available live and durably.
In-process, via a notifier. The notifier receives the same callbacks synchronously. A notifier can set a flag or call agent.cancel_run(run_id) or agent.request_pause(run_id) from outside the callback.
class StopOnBudget:
def __init__(self, agent):
self.agent = agent
async def on_governance_event(self, run_id, event_type, iteration, data, *, correlation_id=None):
if event_type == "budget.exceeded":
await self.agent.request_pause(run_id)
# other callbacks are no-ops
async def on_message_appended(self, *a, **k): pass
async def on_llm_call_completed(self, *a, **k): pass
async def on_tool_completed(self, *a, **k): passOut-of-process, via the read router or SSE. The events also land on run_events, so an external service streaming the run can watch for budget.threshold / budget.exceeded and act. request_pause and cancel_run work from any process with a DB handle.
Where this fits
- Declared on
Agent(budget=Budget(max_tokens=..., max_cost_usd=..., on_exceed=..., include_children=...)). - Evaluated by
dendrux.loops._budget.BudgetTrackerafter every LLM call and at the top of every iteration. - Emits
budget.threshold,budget.exceededandbudget.unpricedonrun_events. - Pauses and cancels through the same machinery as Pause and resume and Cancellation.
- Costs come from Cost and pricing; child usage from
RunStore.get_subtree(see RunStore).