taskflow

Budget

Run-wide cost and token caps.

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 is the one field that prevents this. It's a run-wide ceiling on accumulated cost or tokens. When the cap is exceeded, taskflow halts the run, preserves every partial output produced so far, and returns a diagnostic. Set it on any flow that can fan out unboundedly — map, parallel, loop, tournament — and it becomes the only thing standing between a mis-discovered array and runaway spend.

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.

budget-basic.json
{
  "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 caps accumulated cost; maxTokens caps accumulated input + output tokens. Set one or both. Omitting a dimension disables that ceiling.

budget-both-dimensions.json
{
  "name": "fan-out-audit",
  "budget": {
    "maxUSD": 2.0,
    "maxTokens": 2000000
  },
  "phases": [ /* ... */ ]
}

Pair maxUSD with maxTokens for defense in depth. A model swap or a pricing change can shift spend from cost to tokens (or vice versa). Setting both protects against either ceiling being blown.

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

The moment accumulated spend exceeds the cap, the runtime takes over. No new phases spawn. In-flight fan-out stops spawning items. The run ends as blocked — not failed — and every partial output produced before the cap is preserved.

LayerWhat happens
DAG layersRemaining 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 iterationsThe next iteration doesn't start. A reflexion loop treats this as a hard stop — the failure is not fed back as reflexion input.
tournament judgeThe judge is skipped; the first surviving variant wins, with a warning.
RetriesThe retry loop breaks — no further attempts.
Run statestatus 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.

Bounded overshoot

Within a single DAG layer, phases run concurrently up to concurrency (default 8). The budget check fires after each phase completes, so at most concurrency - 1 extra phases in the same layer may start before the cap is detected.

This bounded overshoot is by design — once budgetBlocked is set, it prevents cascading into subsequent layers, but it cannot revoke spend already in flight. If you need a hard ceiling with zero overshoot, set concurrency: 1 on the flow. Phases then run strictly sequentially and the guard is re-evaluated before each one starts.

How costs accumulate per phase type

Budget accounting reads state.phases[*].usage, which each phase type populates differently:

Phase typeWhat counts toward budget
agentThe subagent's usage on every attempt. Failed retries are summed before the retry decision.
parallelThe sum of every branch's usage.
mapThe sum of every started item's usage. Budget-skipped items contribute zero.
loopThe cumulative usage of every iteration, folded in mid-loop (not overwritten) — so a reflexion loop cannot spend past the ceiling unnoticed between iterations.
tournamentEvery variant's usage plus the judge's (when the judge runs).
flowThe sub-flow runs with its own budget (see below). The parent phase records the sub-flow's aggregated usage.
gateThe gate subagent's usage.
approvalZero — no subagent runs.
scriptZero — no LLM tokens.
reduceThe 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.

idempotent-false-deploy.json
{
  "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 — hard clamp

A generated inline definition cannot raise the parent's cap. The runtime takes min(child, parent) per dimension:

subflow-def-clamped.json
{
  "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 cap becomes $2.0 regardless of its declared $5.0. It can only ever be tighter than the parent. 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 cap as a soft per-flow ceiling.

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.

map-with-per-item-estimation.json
{
  "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.

tournament-with-budget.json
{
  "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:

FieldWhat it tells you
def.budgetThe ceiling 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>.budgetTruncatedtrue when a map / parallel fan-out was cut short by the cap (or by the MAX_DYNAMIC_MAP_ITEMS safety limit).
phases.<id>.attemptsTotal 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:

blocked-final-output
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 executeTaskflowresult.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

ErrorCauseFix
Budget exceeded — run halted. Reason: cost $X exceeded cap $YAccumulated 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 MAccumulated input + output passed maxTokens.Raise maxTokens, switch to a cheaper model, or pre-read less context (contextLimit).
N item(s) skipped: budget exceededA 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 exceededA 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 completeThe 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.

On this page