JuliusBrussee/caveman · error · Error

cave_budget_denomination_ambiguous

Error message

cave_budget_denomination_ambiguous

What it means

Thrown by normalizeRunBudget when the budget's denomination is ambiguous: exactly one of maxUsd and maxTokens must be set, and this error fires when both are set or neither is. The library fails closed at validation time — before any provider call — rather than silently picking a cap or running unbounded.

Source

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

export interface NormalizedBudget {
  readonly denomination: BudgetDenomination;
  readonly max: number;
  readonly initial: number;
  readonly outputFloorTokens: number;
  readonly onExhausted: "compact" | "stop";
  readonly compaction: NormalizedCompaction;
}

/**
 * Validate a caller-supplied budget. Fails closed: an ambiguous, unbounded, or
 * self-contradicting budget is rejected before the first provider call rather
 * than silently degrading into no cap at all.
 */
export function normalizeRunBudget(budget: RunBudget): NormalizedBudget {
  const usd = budget.maxUsd !== undefined;
  const tokens = budget.maxTokens !== undefined;
  if (usd === tokens) throw new Error("cave_budget_denomination_ambiguous");
  const denomination: BudgetDenomination = usd ? "usd" : "tokens";
  const max = usd ? budget.maxUsd! : budget.maxTokens!;
  if (!Number.isFinite(max) || max <= 0) throw new Error("cave_budget_max_invalid");
  if (denomination === "tokens" && !Number.isSafeInteger(max)) {
    throw new Error("cave_budget_max_invalid");
  }
  const wrongInitial = denomination === "usd" ? budget.initialTokens : budget.initialUsd;
  if (wrongInitial !== undefined) throw new Error("cave_budget_denomination_ambiguous");
  const declaredInitial = denomination === "usd" ? budget.initialUsd : budget.initialTokens;
  const initial = declaredInitial ?? max;
  if (!Number.isFinite(initial) || initial <= 0 || initial > max) {
    throw new Error("cave_budget_initial_invalid");
  }
  if (denomination === "tokens" && !Number.isSafeInteger(initial)) {
    throw new Error("cave_budget_initial_invalid");
  }
  const outputFloorTokens = budget.outputFloorTokens ?? OUTPUT_CLAMP_FLOOR_TOKENS;
  if (!Number.isSafeInteger(outputFloorTokens) || outputFloorTokens <= 0) {

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Set exactly one of maxUsd or maxTokens on the budget object and delete the other.
  2. If merging config layers, explicitly delete the unused denomination key after the merge instead of relying on overrides.
  3. Add a build-time or test-time assertion that calls normalizeRunBudget on the shipped config so ambiguity surfaces in CI, not in production.

Example fix

// before
const budget = { ...defaults, ...userOverrides }; // defaults has maxUsd, userOverrides has maxTokens -> both set

// after
const budget = { ...defaults, ...userOverrides };
if (budget.maxTokens !== undefined) delete budget.maxUsd;
else if (budget.maxUsd === undefined) throw new Error("budget must set maxUsd or maxTokens");
Defensive patterns

Strategy: validation

Validate before calling

function pickDenomination(b: RunBudget): { maxUsd: number } | { maxTokens: number } {
  const usd = b.maxUsd !== undefined;
  const tok = b.maxTokens !== undefined;
  if (usd === tok) throw new Error("set exactly one of maxUsd or maxTokens");
  return usd ? { maxUsd: b.maxUsd! } : { maxTokens: b.maxTokens! };
}

Type guard

function isUnambiguousBudget(b: RunBudget): boolean {
  return (b.maxUsd !== undefined) !== (b.maxTokens !== undefined);
}

Try / catch

try {
  normalizeRunBudget(budget);
} catch (e) {
  if (e instanceof Error && e.message === "cave_budget_denomination_ambiguous") {
    throw new Error(`Budget must set exactly one of maxUsd or maxTokens; got maxUsd=${budget.maxUsd} maxTokens=${budget.maxTokens}`);
  }
  throw e;
}

Prevention

When it happens

Trigger: Passing a RunBudget with both maxUsd and maxTokens defined; passing a RunBudget with neither (an unbounded run); passing an object where one field is explicitly undefined and the other absent, both of which count as unset.

Common situations: Spreading two config objects together (a USD default plus a token override) so both fields end up defined; refactoring a caller from maxUsd to maxTokens and leaving the old field populated; a config file where the budget section is left empty, producing neither field.

Related errors


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