JuliusBrussee/caveman · error

cave_budget_controller_without_budget

cave_budget_controller_without_budget

Error message

cave_budget_controller_without_budget

What it means

Thrown at run start when RunOptions.budgetController is supplied without RunOptions.budget. A controller exists only to release additional tranches up to a budget's max at developer-controlled checkpoints; with no budget meter bound it would be a silent no-op at exactly the checkpoint that expected it to matter, so the run fails closed.

Source

Thrown at packages/agent/src/runtime.ts:1098

  const budgetMeter = executionContext.budgetMeter ?? (options.budget === undefined
    ? undefined
    : new BudgetMeter(normalizeRunBudget(options.budget)));
  if (options.deadlineMs !== undefined &&
      (!Number.isSafeInteger(options.deadlineMs) || options.deadlineMs <= 0)) {
    throw new Error("cave_run_deadline_invalid");
  }
  if (options.maxSubagentDepth !== undefined &&
      (!Number.isSafeInteger(options.maxSubagentDepth) || options.maxSubagentDepth <= 0 ||
        options.maxSubagentDepth > ABSOLUTE_SUBAGENT_DEPTH_LIMIT)) {
    throw new Error("cave_subagent_depth_limit_invalid");
  }
  const deadlineAt = executionContext.deadlineAt ?? (options.deadlineMs === undefined
    ? undefined
    : performance.now() + options.deadlineMs);
  // A controller with nothing to release would be a silent no-op at the
  // checkpoint that expected it to matter.
  if (options.budgetController !== undefined && budgetMeter === undefined) {
    throw new Error("cave_budget_controller_without_budget");
  }
  const breakers = options.breakers === undefined
    ? undefined
    : new BreakerState(normalizeRunBreakers(options.breakers, budgetMeter !== undefined));
  const efficiencyPlan = options.lockedBuild?.selected_plan ?? options.candidatePlan;
  const buildIdentity = options.lockedBuild === undefined
    ? undefined
    : {
      buildSha256: options.lockedBuild.build_sha256,
      planSha256: options.lockedBuild.plan_sha256,
    };
  const runId = crypto.randomUUID();
  yield { type: "run_start", runId, agentId: definition.id };
  let conversation: ConversationTransaction | undefined;
  let activeAbort: (() => void) | undefined;
  let activeExecution: Promise<void> | undefined;
  let sandboxSourceSnapshot: SandboxSourceSnapshot | undefined;
  const pendingSpendReservations: SpendReservation[][] = [];

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Add the budget the controller releases into: budget: { maxUsd: X, initialUsd: Y } (or maxTokens/initialTokens)
  2. Or remove budgetController when running without a budget
  3. Keep controller and budget sourced from the same config branch so they are set or unset together

Example fix

// before
await agent.run(input, { budgetController: controller });

// after
await agent.run(input, {
  budget: { maxUsd: 10, initialUsd: 2 },
  budgetController: controller,
});
Defensive patterns

Strategy: validation

Validate before calling

const budgeted = config.budget !== undefined;
const opts = budgeted
  ? { budget: config.budget, budgetController: controller }
  : {}; // controller only when budget is set

Type guard

const controllerIsValid = (o: { budget?: unknown; budgetController?: unknown }): boolean =>
  o.budgetController === undefined || o.budget !== undefined;

Prevention

When it happens

Trigger: Passing { budgetController: createBudgetController(...) } while omitting budget, or setting budget only in some code paths (e.g. only when a flag is set) while always setting the controller.

Common situations: Refactoring staged budgeting: the controller wiring survives but the budget field is dropped during cleanup; conditionally disabling the budget via destructuring while leaving the controller in the options object.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/2c4f49489f6994fd. Report an issue: GitHub.