Budget
Run-wide observed-usage cost and token stop-losses.
You point a map phase at your repo and ask a scout agent to list the changed files. The scout returns 500 of them. Each one spawns a security reviewer. Four run at once. By the time you notice, you've spent twelve dollars on a flow that was supposed to cost forty cents.
A budget limits the blast radius. It is a run-wide, observed-usage stop-loss on accumulated cost or tokens. After reported usage exceeds the threshold, taskflow admits no new model call, preserves every partial output produced so far, and returns a diagnostic. Calls already in flight may cross the threshold. Set it on any flow that can fan out unboundedly — map, parallel, loop, tournament, race.
Budget enforcement costs zero extra tokens. It reads the usage records every subagent already writes, and it's checked on every retry, every fan-out item, and every DAG layer transition.
Set a budget on every fan-out flow
If your flow contains map, parallel, loop, or tournament, add a budget. That's the whole rule.
{
"name": "summarize-files",
"budget": { "maxUSD": 1.5 },
"concurrency": 4,
"phases": [
{
"id": "discover",
"type": "agent",
"output": "json",
"task": "List files under {args.dir}. Output a JSON array of { path }."
},
{
"id": "summarize",
"type": "map",
"over": "{steps.discover.json}",
"as": "file",
"concurrency": 8,
"task": "Read {file.path} and give a one-sentence summary."
}
]
}Both dimensions are optional. maxUSD watches accumulated reported cost; maxTokens watches accumulated reported input + output tokens. Set one or both. Omitting a dimension disables that stop-loss.
{
"name": "fan-out-audit",
"budget": {
"maxUSD": 2.0,
"maxTokens": 2000000
},
"phases": [ /* ... */ ]
}Pair maxUSD with maxTokens for defense in depth when the host reports both. A model swap or pricing change can shift spend from cost to tokens (or vice versa).
Host support follows the usage data each CLI exposes:
| Host | Supported budget dimensions |
|---|---|
| Pi, Claude Code, OpenCode | maxUSD and/or maxTokens |
| Codex | maxTokens only; maxUSD is rejected because Codex reports no cost |
| Grok Build 0.2.93, Hermes Agent | None; every flow declaring budget is rejected because these quiet-mode runners report neither tokens nor cost |
The schema rejects unknown keys (additionalProperties: false). An empty budget: {} is valid but a no-op — at least one of maxUSD / maxTokens should be set.
What happens when you hit the cap
Once accumulated reported usage exceeds the threshold, the runtime admits no new model calls. The run ends as blocked — not failed — and every partial output already produced is preserved. The active call cannot be interrupted merely because its final usage is not known yet.
| Layer | What happens |
|---|---|
| DAG layers | Remaining phases are recorded as skipped with error: "Budget exceeded: ...". |
In-flight fan-out (map / parallel) | The loop stops spawning. Unstarted items become stopReason: "budget-skipped" with zero usage. |
loop iterations | The next iteration doesn't start. A reflexion loop treats this as a hard stop — the failure is not fed back as reflexion input. |
tournament judge | The judge is skipped; the first surviving variant wins, with a warning. |
| Retries | The retry loop breaks — no further attempts. |
| Run state | status becomes "blocked". finalOutput is "Budget exceeded — run halted." plus the reason and the final phase's partial output. |
A blocked run is not a failed run. Every phase that completed before the cap keeps its output, json, and usage. The finalOutput is composed from the last phase that actually produced output — so a budget-blocked run can still return useful partial work to your conversation.
blocked means the budget or a gate halted the workflow. failed means a non-optional phase errored out. Check state.status and the per-phase error / budgetTruncated fields to tell them apart.
The comparison is strict
The check uses strict >, not >=. A run that lands exactly on the cap is not blocked — only the next cent or token past it triggers the halt. A budget of maxUSD: 2.0 permits a run that costs precisely $2.000.
Ordinary admission limits overshoot; race is the exception
Because providers report usage only after a call returns, no observed-usage budget can guarantee a zero-overshoot ceiling. Even with one active call, that call can move usage from below the threshold to above it.
Ordinary budgeted DAG layers and map / parallel / tournament fan-out use serial model-call admission regardless of normal concurrency. The guard is checked before admitting each call; after a breach is observed, no further call starts. This limits new-call overshoot to the one admitted call. A race must admit its competing branches together to preserve first-success semantics, so every already-active race branch may contribute overshoot. Neither case guarantees a fixed dollar/token maximum or zero overshoot.
How costs accumulate per phase type
Budget accounting reads state.phases[*].usage, which each phase type populates differently:
| Phase type | What counts toward budget |
|---|---|
agent | The subagent's usage on every attempt. Failed retries are summed before the retry decision. |
parallel | The sum of every branch's usage. |
map | The sum of every started item's usage. Budget-skipped items contribute zero. |
loop | The cumulative usage of every iteration, folded in mid-loop (not overwritten), so a breach is observed before another iteration is admitted. |
tournament | Every variant's usage plus the judge's (when the judge runs). |
flow | The sub-flow runs with its own budget (see below). The parent phase records the sub-flow's aggregated usage. |
gate | The gate subagent's usage. |
approval | Zero — no subagent runs. |
script | Zero — no LLM tokens. |
reduce | The reducer subagent's usage. |
Cache hits (cacheHit: "cross-run" | "run-only") report the cached usage but spent no new tokens this run. cacheRead/cacheWrite/contextTokens are not added to the token ceiling — cache reads are nearly free, and contextTokens is a point-in-time gauge, not additive.
Retries and budget
Every retry attempt pushes its usage into an accumulator before the retry decision is made. So a phase that fails three times before succeeding has spent the tokens of all three attempts — and the third attempt may itself push the run over the cap.
Failed retries do count toward budget. There is no refund for failed attempts — the provider already charged for them.
A phase declared idempotent: false disables the implicit transient retry (the runtime safety net), so a flaky provider error won't silently double-charge by re-running a side effect. But an explicit retry: { max } is still honored — and every explicit retry still counts toward budget.
{
"id": "deploy",
"type": "agent",
"idempotent": false,
"task": "Run the production deploy. Report the rollout ID.",
"retry": { "max": 0 }
}Budget in sub-flows
A flow phase runs a sub-taskflow. How the sub-flow's budget interacts with the parent depends on whether the sub-flow was generated inline (def) or loaded by name (use).
Inline def sub-flows — threshold clamp
A generated inline definition cannot raise the parent's cap. The runtime takes min(child, parent) per dimension:
{
"id": "plan-and-execute",
"type": "flow",
"def": {
"name": "inner-plan",
"budget": { "maxUSD": 5.0 },
"phases": [ /* ... */ ]
}
}If the parent flow has budget: { maxUSD: 2.0 }, the inner sub-flow's effective threshold becomes $2.0 regardless of its declared $5.0. It can only declare a tighter threshold than the parent, but an already-running call may still overshoot it. If the clamped result has no finite dimension left, the sub-flow's budget is set to undefined — but the parent's guard still sees the sub-flow's spend because the parent phase records the aggregated sub-phase usage.
Saved use sub-flows — soft inheritance
A sub-flow loaded by use enforces its own declared budget. If it declares none, it inherits the parent's threshold as a best-effort per-flow stop-loss.
This inheritance is best-effort: spend does not cross flow boundaries. The parent's already-spent total is not subtracted from the child's inherited cap, so a child inheriting $2.0 can itself spend up to $2.0 even if the parent already spent $1.50. The parent's own guard still catches the combined total when the sub-flow returns.
Recursion is blocked by a stack check, so a sub-flow cannot infinitely nest to bypass its own budget. The nesting counter is unified across both the flow and inline-spawn (ctx_spawn) recursion axes.
Estimate before you fan out
For expensive per-item work, run a cheap estimator phase first and let its output decide whether to proceed. This converts an unbounded map into a bounded one.
{
"name": "bounded-review",
"budget": { "maxUSD": 3.0, "maxTokens": 3000000 },
"concurrency": 4,
"phases": [
{
"id": "discover",
"type": "agent",
"output": "json",
"task": "List files under src/. Output JSON: { items: [{ path }] }."
},
{
"id": "estimate",
"type": "agent",
"output": "json",
"context": ["{steps.discover.json}"],
"task": "Given these files, estimate tokens to review each. Output JSON: { items: [{ path, estTokens }], totalEstTokens }.",
"expect": {
"type": "object",
"required": ["items", "totalEstTokens"],
"properties": {
"items": { "type": "array", "items": { "type": "object" } },
"totalEstTokens": { "type": "number" }
}
}
},
{
"id": "review",
"type": "map",
"over": "{steps.estimate.json.items}",
"as": "item",
"when": "{steps.estimate.json.totalEstTokens} < 2500000",
"concurrency": 6,
"task": "Review {item.path} for security risks. List concrete findings."
},
{
"id": "fallback",
"type": "agent",
"when": "{steps.estimate.json.totalEstTokens} >= 2500000",
"task": "The estimated token cost ({steps.estimate.json.totalEstTokens}) exceeds half the budget. Summarize src/ at a high level instead of per-file review."
}
]
}The when guard on review skips the fan-out entirely when the estimate exceeds half the budget, leaving headroom for the fallback phase. Even with this guard, budget still protects against an underestimate.
Give loops and tournaments their own headroom
A reflexion loop can spend many iterations refining the same output. A tournament runs N variants plus a judge. Set the budget high enough for the intended iteration or variant count, then let the guard catch runaway cases.
{
"name": "pick-best-title",
"budget": { "maxUSD": 0.80 },
"phases": [
{
"id": "compete",
"type": "tournament",
"variants": 4,
"task": "Write a title for: {args.topic}.",
"judge": "Pick the single best title. Output only the winner."
}
]
}Inspecting budget usage
Every run record persisted to .pi/taskflows/runs/<flow>/<runId>.json exposes what you need to audit spend:
| Field | What it tells you |
|---|---|
def.budget | The observed-usage threshold that was in effect ({ maxUSD?, maxTokens? }). |
status | "blocked" if the budget was exceeded; "completed" / "failed" / "paused" otherwise. |
phases.<id>.usage | { input, output, cacheRead, cacheWrite, cost, contextTokens, turns } for that phase. input + output is what the token ceiling sums. |
phases.<id>.budgetTruncated | true when a map / parallel fan-out was cut short by the cap (or by the MAX_DYNAMIC_MAP_ITEMS safety limit). |
phases.<id>.attempts | Total subagent attempts including retries. When > 1, a retry happened — and every attempt's spend is in usage. |
phases.<id>.cacheHit | "cross-run" or "run-only" if served from cache (no new tokens this run). |
phases.<id>.status | "skipped" with error: "Budget exceeded: ..." for phases that never ran. |
The terminal finalOutput for a budget-blocked run reads:
Budget exceeded — run halted.
Reason: cost $2.143 exceeded cap $2.000
<final phase's partial output>To get the run-wide total without summing phases yourself, read the value returned by executeTaskflow — result.totalUsage is the post-run aggregate across all phases. Run records don't store a top-level totalUsage; the TUI computes it on read.
Common errors
| Error | Cause | Fix |
|---|---|---|
Budget exceeded — run halted. Reason: cost $X exceeded cap $Y | Accumulated cost passed maxUSD. | Raise maxUSD, reduce fan-out (concurrency / over size), or add a when guard to skip expensive phases. |
Budget exceeded — run halted. Reason: tokens N exceeded cap M | Accumulated input + output passed maxTokens. | Raise maxTokens, switch to a cheaper model, or pre-read less context (contextLimit). |
N item(s) skipped: budget exceeded | A map / parallel fan-out was cut short. | Inspect phases.<id>.budgetTruncated in the run record. Raise the budget or reduce concurrency. |
judge skipped: run aborted or budget exceeded | A tournament reached the judge step after the cap was hit. | The first surviving variant was selected as winner. Raise the budget or reduce variants. |
Run status blocked but finalOutput looks complete | The final phase completed before the cap, but a later (non-final) phase pushed the total over. | Reorder so the expensive phase runs before the final phase, or mark a cheaper phase final: true. |
A blocked run is not a failed run. blocked means the budget or a gate halted the workflow; failed means a non-optional phase errored out. Check state.status and the per-phase error / budgetTruncated fields to distinguish them.
Last updated on
Was this helpful?
Help us improve the docs or ask a question in the community.