dendrux
v0.4.0Get started →

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:

on_exceedAt the limit
"warn" (default)Events only. The run keeps going. This is the original, advisory behavior.
"pause"The run pauses durably (status=paused, pause_reason="budget"). A person raises the limit and calls submit_resume.
"cancel"The run ends cancelled with failure_reason="budget_exceeded".

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:

ArgMeaningDefault
max_tokensToken cap: input plus output tokens across all LLM calls. Positive integer, or None.None
max_cost_usdDollar cap. Positive number, or None.None
warn_atFractions of each limit at which budget.threshold fires, each in (0, 1) exclusive.(0.5, 0.75, 0.9)
on_exceed"warn", "pause" or "cancel"."warn"
include_childrenCount delegated descendant runs' usage.True

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

Things to read out of the trace:

  1. 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.
  2. 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.
  3. Payloads say which metric. metric is "tokens" or "cost_usd"; used and max are in that metric's unit (dollars as a float). budget.exceeded also carries action: the on_exceed that applies (warn, pause or cancel).
  4. 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 recorded llm_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_children cannot 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 use on_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): pass

Out-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.BudgetTracker after every LLM call and at the top of every iteration.
  • Emits budget.threshold, budget.exceeded and budget.unpriced on run_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).