JuliusBrussee/caveman · error · Error

cave_budget_controller_unbound

Error message

cave_budget_controller_unbound

What it means

Thrown by BudgetController accessors (denomination, tranches, and every other method that needs the meter) when the controller has no bound BudgetMeter. Controllers are created unbound via createBudgetController() and only become usable inside a run that binds them with the package-internal bindBudgetController(). Using one outside a run — before the run starts or after it ends and unbinds — throws this error.

Source

Thrown at packages/agent/src/budget.ts:830

  get released(): number {
    return this.meter().released;
  }

  get max(): number {
    return this.meter().max;
  }

  get denomination(): BudgetDenomination {
    return this.meter().denomination;
  }

  get tranches(): readonly BudgetTranche[] {
    return this.meter().tranches;
  }

  private meter(): BudgetMeter {
    const bound = controllerMeters.get(this);
    if (bound === undefined) throw new Error("cave_budget_controller_unbound");
    return bound;
  }
}

export function createBudgetController(): BudgetController {
  return new BudgetController();
}

/** Package-internal. Binds a controller to the run that owns its budget. */
export function bindBudgetController(
  controller: BudgetController,
  meter: BudgetMeter,
): void {
  if (controllerMeters.has(controller)) throw new Error("cave_budget_controller_in_use");
  controllerMeters.set(controller, meter);
}

/** Package-internal. Releases the binding when the run ends. */

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Only touch the controller within the run callback that owns it — capture it from the run's context, not from global state.
  2. If you need controller data before a run (e.g. to display limits), read it from the RunBudget config you passed in, not from the controller.
  3. Create a fresh controller per run via createBudgetController() rather than reusing one across runs.

Example fix

// before
const controller = createBudgetController();
console.log(controller.denomination); // throws: not yet bound to any run
await runAgent({ budget, controller });

// after
const controller = createBudgetController();
console.log(budget.maxUsd !== undefined ? "usd" : "tokens"); // read config instead
await runAgent({ budget, controller });
Defensive patterns

Strategy: validation

Validate before calling

// Read budget facts from your config before the run; touch the controller only inside the run.
function describeBudget(budget: RunBudget): string {
  return budget.maxUsd !== undefined ? `usd cap ${budget.maxUsd}` : `token cap ${budget.maxTokens}`;
}

Try / catch

try {
  controller.release(100, "top-up");
} catch (e) {
  if (e instanceof Error && e.message === "cave_budget_controller_unbound") {
    throw new Error("controller used outside its owning run — obtain it from the run context");
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling controller.release()/controller.tranches/etc. before the run that owns the budget has started; storing a controller and using it after the run finished (unbindBudgetController removed the binding); creating a controller manually with new BudgetController() instead of the factory and never binding it.

Common situations: Exporting a controller from a module and reading it in startup code to display budget config; caching controllers across runs for reuse without re-binding; tests that instantiate a controller directly and call methods without setting up a run.

Related errors


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