Skip to content

Budgets and termination

Every rulvar run can carry a hard USD ceiling. Enforcement is one budget path shared by all three orchestration modes: the same three layers guard a hand-written workflow, a planned script, and a dynamic orchestrator. This page covers the three layers, what happens at the ceiling, the integer counters that make termination a proof rather than a hope, and how to size all of it.

The immutable run ceiling

Set the ceiling per run with budgetUsd:

ts
import { createEngine, defineWorkflow } from "@rulvar/core";
import { anthropic } from "@rulvar/anthropic";

const engine = createEngine({ adapters: [anthropic()] });

const review = defineWorkflow(
  { name: "review" },
  async (ctx, args: { pr: number }) => {
    return ctx.agent(`Review PR ${args.pr} and summarize the risks.`, {
      agentType: "reviewer",
    });
  }
);

const handle = engine.run(review, { pr: 42 }, { budgetUsd: 20 });
const outcome = await handle.result;

The ceiling (call it B0) is immutable after start. No API raises it: not the run handle, not an operator resolution, not a human-in-the-loop decision. Restarting the process with a bigger number in config does not work either: resume accepts no budget parameter, and in adaptive runs the ceiling frozen in the journal wins, the mismatch producing only a config-drift telemetry event. If a run needs more money, that is a new run, decided by the host.

A run without budgetUsd has no USD ceiling: ctx.budget.remaining() returns null and only the structural bounds apply (the engine lifetime cap of 500 spawns per run, the nesting depth limit, and per-agent UsageLimits). For anything that spawns real models against a real account, set a ceiling.

The three layers

Each layer answers a different question at a different moment:

LayerWhenQuestion
1. AdmissionBefore a spawnCan this run afford to start the call at all?
2. Turn guardBefore every agent turnCan this agent afford one more turn?
3. Stream cutWhile tokens streamHas the ceiling been crossed mid-turn?

Layer 1: admission before spawn

A spawn is blocked when spent + committedReserve >= ceiling on any account in its ancestor chain. Because a call's true cost is unknown before it runs, admission works with a reserve, resolved in this order:

text
reserve = opts.estCost
       ?? profile.estCost
       ?? price(countTokens(input) + caps.maxOutputTokens)
       ?? 0.50 USD   (engine flat default, budgetDefaults.flatReserveUsd)

Reserves ride the journaled admission decision entry, so on resume they are recovered from the journal, never re-estimated: a price-table change between crash and resume does not move an already-committed number (see Durability).

You can tighten admission per call or per profile with an estCost hint:

ts
// A short classification call should not reserve a full maxOutputTokens
// worth of budget.
const label = await ctx.agent("Classify: build failure on main after merge", {
  agentType: "classifier",
  estCost: 0.05,
});

Layer 2: the per-turn guard

Before every agent turn the runtime checks the agent's own sub-account. A turn that would cross the sub-account ceiling is never dispatched; the blocked primitive throws the typed BudgetExhaustedError (error code budget_exhausted). Nothing is sent to a provider, so a blocked turn costs zero.

Layer 3: cutting live streams at the ceiling

Layers 1 and 2 work on estimates; only layer 3 sees actual spend as it happens. When a ceiling is crossed while responses are streaming, the engine severs the live streams with an AbortSignal. The usage accumulated from stream deltas up to the cut is written to the journal with usageApprox: true: the partial spend is counted, and the flag records that the number came from a severed stream rather than a provider's final usage report.

Bounded overshoot: one turn, and why not less

The worst-case overshoot past the ceiling is at most one turn per in-flight agent, and that bound is the tightest achievable. Once a turn has been dispatched, the provider bills the tokens it streams whether or not you read the stream to its end; cutting mid-stream stops the meter as early as physically possible, but the tokens already generated are owed. A tighter bound would require knowing a turn's output length before dispatching it, which is exactly the estimate layers 1 and 2 already apply.

Practical consequence: the worst case scales with concurrency. At the default per-run concurrency of 12, up to 12 agents can be mid-turn when the ceiling is crossed, so size B0 with roughly one turn of headroom per concurrent agent.

Sub-accounts and the account tree

Budget accounts form a tree with the run root at the top. A child workflow started through ctx.workflow gets its own sub-account holding a fraction of the parent's remainder (childBudgetFraction, default 0.3, computed after subtracting the parent's finalize reserve). A dynamic orchestrator gets its own account too (below). Spend in any account propagates upward to every ancestor, so the root ceiling remains the single true invariant no matter how deep the tree grows.

Workflows can read their own account at any time:

ts
const spent = ctx.budget.spent();       // { usd, usage, agentsSpawned }
const left = ctx.budget.remaining();    // null when the run has no USD ceiling

if (left !== null && left.usd < 2) {
  ctx.log("warn", "budget low, skipping the deep-analysis pass", {
    usd: left.usd,
  });
}

Sandbox dialect

Inside the worker sandbox used for planner-generated scripts the same reads are asynchronous: await budget.spent(). A synchronous cross-thread read does not exist.

Exhaustion is an outcome, not an exception

At the ceiling, every ctx primitive throws BudgetExhaustedError. You normally let it unwind: the engine recognizes it and reports the run outcome 'exhausted', overriding 'error'.

ts
const outcome = await handle.result;

switch (outcome.status) {
  case "ok":
    console.log(outcome.value);
    break;
  case "exhausted":
    // Paid partial work is preserved and addressable.
    console.log(`spent ${outcome.cost.totalUsd} USD before the ceiling`);
    console.log(`${outcome.dropped.length} calls dropped`);
    console.log(`${outcome.pending.length} externals still open`);
    break;
}

Exhaustion is never a bare null. The outcome always carries the full cost report, the dropped list (every loss with its error and scope path), and the pending list of open suspensions. Under onError: 'null' a blocked call yields null at the call site with a recorded drop, and the run continues until the ceiling blocks everything; the terminal outcome is still 'exhausted'. In an adaptive run that hits the orchestrator cap, the exhausted outcome carries a deterministically synthesized partial value (next sections). And because everything paid is journaled, the partial work stays addressable after the run settles and is never paid twice.

The termination account

Dollars bound spend, but dollars alone do not bound iteration: an adaptive run replanning in tiny cheap steps could loop for a very long time inside its budget, and test runs against fake adapters cost zero dollars entirely. Adaptive PlanRunner runs therefore add a per-run termination account: integer counters frozen at start, spent and never refilled.

At run admission the frozen limits vector is written into the journal as a termination.init entry:

LimitDefaultWhat it bounds
maxRevisionsPerRun32Plan revisions: minus 1 per journaled revision, regardless of diff size
maxTotalSpawns128Admitted spawns of any origin
maxEscalationsPerLogicalTask2Escalations per logical task, counted across respawns via lineage
maxDepth1 (hard ceiling 4)Nesting depth
kMaxderivedThe longest declared model ladder in the profile registry snapshot
runBudgetUsdCeilinghost-setB0 itself, frozen alongside the counters
orchestratorCapUsd, finalizeReserveUsdderivedThe orchestrator budget (next section), frozen in the same vector

The account is debit-only by construction: no credit operation exists in the API, no journal entry kind carries a credit, and the frozen vector cannot be edited after start. Growing the plan does not grow the revision budget; abandoning work reclaims dollars but never returns counters.

Wakeups need no counter of their own: every orchestrator wake is a paid turn against the capped orchestrator sub-account, so the number of wakes is bounded by the usable cap (cap minus the finalize reserve) divided by the minimal cost of one turn.

This yields the termination guarantee: every edge of the composite escalate-replan-retier loop carries exactly one debiting decision entry, and each debit strictly decreases a finite variant over the remaining units. The loop therefore makes finitely many iterations, and every run settles to a terminal outcome in a finite number of live calls, at a spend no higher than B0 plus the bounded overshoot. The integer counters give termination even at zero model cost; dollars remain an independent safety ceiling, never the only argument.

When a counter would go below zero the debit is not executed: the engine journals the denial and surfaces a typed error (for example revision_budget_exhausted) to the orchestrator as an ordinary tool error. Denials never tear the run down, and a denied call does not debit, so spamming a denied tool costs turns, not counters.

Freeze the knobs per run through the plan options:

ts
import { orchestratePlanned } from "@rulvar/plan";

const handle = orchestratePlanned(engine, "Migrate the API surface to v2", {
  budget: { capUsd: 4, finalizeTurns: 2 },
  plan: {
    maxRevisionsPerRun: 16,
    limits: { maxTotalSpawns: 64, maxEscalationsPerLogicalTask: 2 },
  },
});

Runs without the PlanRunner extension (modes a and b, and plain dynamic orchestration) write no termination entry and carry only the engine lifetime cap (default 500 spawns), the depth limit, and the three budget layers.

The orchestrator budget sub-account

The orchestrator agent of a dynamic run spends money too: every one of its turns is an LLM call. It therefore gets its own sub-account with a hard cap:

text
effectiveCap = min(capUsd, capFraction x B0)     // capFraction default 0.2

Configure it through the budget option of orchestrate or ctx.orchestrate:

ts
const research = defineWorkflow(
  { name: "research" },
  async (ctx, goal: string) => {
    return ctx.orchestrate(goal, {
      budget: { capFraction: 0.15, atCap: "finish-with-partial" },
    });
  }
);

const handle = engine.run(research, "Map the dependency risks", {
  budgetUsd: 20,
});

An unresolvable cap (a run with no USD ceiling and no explicit capUsd) or a cap smaller than the finalize reserve refuses to start with the typed OrchestratorCapConfigError, before the first LLM call and before any journal entry. Opting out of the cap is explicit only: capFraction: 1.0, with a telemetry warning. A nested orchestrator is additionally clamped by the parent account's remainder minus the parent's finalize reserve.

Two details make the cap safe rather than merely present:

  • The finalize reserve. At admission the engine journals a decision entry fixing finalizeReserveUsd in absolute dollars (explicit, or finalizeTurns times the estimated turn cost; default 2 turns). The reserve is registered as committed simultaneously in the orchestrator account and the run root, so no child spawn can ever eat the money needed to finish, even when the working part of the run ends exhausted.
  • The at-cap protocol. Crossing the soft boundary journals one cap decision, then (default atCap: 'finish-with-partial'): running children finish (killing them would overpay), new plan revisions become impossible, and at quiescence the orchestrator gets one final wake, paid from the reserve, with a single finish tool. A successful finish yields outcome ok with a forcedFinish mark in the cost report. If even that fails, the engine synthesizes a deterministic partial result from the journaled plan state with zero LLM calls, and the run ends exhausted with a non-null value. The sole alternative is atCap: 'fail-run'.

The orchestrator is never woken up about its own spend (waking it would cost more of it); instead every wake digest carries a passive budget block with run and orchestrator spend, the cap, the reserve, and a soft-warning flag at 80 percent of the usable cap. Run-level budget_threshold wake triggers fire at 50 and 80 percent of B0 (fixed in v1). Admission stays accurate here too: a capped orchestrator reserves exactly its effective cap, and the forced-finish agent reserves exactly the finalize reserve, so a small run ceiling is not starved by an oversized default reserve.

Cost reports

Every settled run, whatever its status, carries a complete CostReport in outcome.cost:

FieldContents
totalUsdTotal priced spend of the run
byModelKeyed by canonical adapterId:model
byPhaseBuckets by ctx.phase name (innermost enclosing phase)
byAgentTypeBuckets by agent profile
byRoleBuckets by invocation role (loop, plan, orchestrate, extract, finalize, summarize)
orchestratorspentUsd, share, wakes, forcedFinish, reserveUsedUsd; all-zero in runs without a dynamic orchestrator
unpricedUsage on models absent from the price table; surfaced, never a silent zero

The report is a pure fold over the usage of terminal journal entries in spawn order: wall clock participates nowhere, entries under abandoned subtrees are excluded (their spend is tracked separately in the abandoned-spend ledger the orchestrator sees), and a replayed run reports the same numbers byte-for-byte. Live budget telemetry and the event stream are covered in Observability.

Practical sizing

  • Always set budgetUsd. It is the only dollar bound; without it the budget layers cannot bind in USD and only spawn counts protect you. Adaptive runs refuse to start without a resolvable orchestrator cap anyway.
  • Leave overshoot headroom. Worst case is one turn per in-flight agent: at the default concurrency of 12 and roughly 0.10 USD per worker turn, budget about 1 to 2 USD of slack above what the work itself needs.
  • Give hot profiles an estCost. The default reserve prices the model's full maxOutputTokens (or falls back to 0.50 USD flat), which is far above a typical short call. On small ceilings, oversized reserves starve admission long before real money runs out; a realistic hint per profile fixes that.
  • Bound turns before dollars. Per-agent UsageLimits (default maxTurns 32) end a runaway agent with the paid-partial limit status long before it dents the run ceiling; the run keeps going. Prefer tight per-spawn limits plus a generous B0 over the reverse.
  • Let the defaults carry adaptive runs. 128 spawns, 32 revisions, and 2 escalations per task terminate long before a well-shaped goal needs them. Lower them for narrow goals to convert runaway risk into an early, typed denial instead of spend.
  • Treat exhausted as a result. Read cost, dropped, and pending, then decide at the host level whether to start a follow-up run; paid work is already in the journal and is never paid twice.

For the full API shapes see the core reference and the plan reference; for how budget entries interact with replay, see The journal.