JuliusBrussee/caveman · error · Error

cave_budget_conflicting_cap

cave_budget_conflicting_cap

Error message

cave_budget_conflicting_cap

What it means

maxCostUsd (the older error-terminating cap) and options.budget (the reserve-and-clamp budget contract that returns a planned partial result) are two different stop-semantics contracts for the same money. Carrying both would leave the run's own stop behavior undecided, so streamAgentInternal rejects the combination at start, before any spend.

Solutions

  1. Delete maxCostUsd and express the cap via budget: { maxUsd } (or maxTokens) — this gives reserve-and-clamp semantics.
  2. Alternatively keep maxCostUsd for hard error-terminating behavior and remove budget.
  3. Audit config merge code to ensure only one cap style survives the merge.

Example fix

// before
runAgent(def, input, { maxCostUsd: 5, budget: { maxUsd: 10 } });
// after
runAgent(def, input, { budget: { maxUsd: 10 } });
Defensive patterns

Strategy: validation

Validate before calling

if (opts.maxCostUsd !== undefined && opts.budget !== undefined) {
  throw new Error("maxCostUsd and budget are mutually exclusive");
}

Type guard

function hasSingleCostCap(o) {
  return !(o.maxCostUsd !== undefined && o.budget !== undefined);
}

Try / catch

try {
  await runAgent(def, input, opts);
} catch (err) {
  if (err.message === "cave_budget_conflicting_cap") {
    const { maxCostUsd, ...rest } = opts;
    await runAgent(def, input, rest); // prefer budget semantics
  } else throw err;
}

Prevention

When it happens

Trigger: Calling runAgent/streamAgent with both options.maxCostUsd and options.budget defined in the same RunOptions object.

Common situations: Merging an options object that sets maxCostUsd with a newer config layer that sets budget; incremental migration from the legacy maxCostUsd API to the budget API where the old field was never removed.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/f03f488a80f2401a. Report an issue: GitHub.

Appendix: source

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

      (!Number.isSafeInteger(options.maxSubagentInvocations) || options.maxSubagentInvocations <= 0 ||
        options.maxSubagentInvocations > ABSOLUTE_SUBAGENT_INVOCATION_LIMIT)) {
    throw new Error("cave_subagent_invocation_limit_invalid");
  }
  if (options.maxConcurrentSubagents !== undefined &&
      (!Number.isSafeInteger(options.maxConcurrentSubagents) || options.maxConcurrentSubagents <= 0 ||
        options.maxConcurrentSubagents > ABSOLUTE_SUBAGENT_INVOCATION_LIMIT)) {
    throw new Error("cave_subagent_concurrency_limit_invalid");
  }
  if (options.lockedBuild !== undefined && options.candidatePlan !== undefined) {
    throw new Error("cave_execution_authorization_ambiguous");
  }
  // Budget shape is settled before anything else happens: an ambiguous or
  // unbounded budget must fail at run() start, not after the first dollar.
  // maxCostUsd and budget are two different contracts for the same money —
  // one terminates with an error, the other returns a planned partial result —
  // so carrying both would leave the run's own stop semantics undecided.
  if (options.budget !== undefined && options.maxCostUsd !== undefined) {
    throw new Error("cave_budget_conflicting_cap");
  }
  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.

View on GitHub (pinned to 3ee70a1026)