JuliusBrussee/caveman · error · Error

cave_breaker_retry_requires_budget

Error message

cave_breaker_retry_requires_budget

What it means

A retry policy (`RunOptions.breakers.retry`) declares a worst-case spend in the run budget's denomination, and every retry takes a real hold from the run's BudgetMeter. Without a budget there is no denomination to reserve against, so `normalizeRunBreakers` throws `cave_breaker_retry_requires_budget` when `breakers.retry` is set and the run has no budget. This is a deliberate fail-closed design: retries without a metered wallet could spend without bound.

Source

Thrown at packages/agent/src/breakers.ts:89

  hasBudget: boolean,
): NormalizedBreakers {
  const repeatedToolCalls = breakers.repeatedToolCalls ?? DEFAULT_REPEATED_TOOL_CALLS;
  const repeatedToolCallWindowTurns = breakers.repeatedToolCallWindowTurns ??
    DEFAULT_REPEATED_TOOL_CALL_WINDOW_TURNS;
  const noProgressTurns = breakers.noProgressTurns ?? DEFAULT_NO_PROGRESS_TURNS;
  const maxToolCallsPerTurn = breakers.maxToolCallsPerTurn ?? DEFAULT_MAX_TOOL_CALLS_PER_TURN;
  for (const value of [
    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
  ]) {
    if (!Number.isSafeInteger(value) || value <= 0) {
      throw new Error("cave_breaker_threshold_invalid");
    }
  }
  if (breakers.retry !== undefined && !hasBudget) {
    throw new Error("cave_breaker_retry_requires_budget");
  }
  if (breakers.retry !== undefined &&
      (!Number.isFinite(breakers.retry.maxSpend) || breakers.retry.maxSpend <= 0)) {
    throw new Error("cave_breaker_retry_spend_invalid");
  }
  const retryBackoffMs = breakers.retry?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
  if (!Number.isSafeInteger(retryBackoffMs) || retryBackoffMs < 0) {
    throw new Error("cave_breaker_retry_backoff_invalid");
  }
  return Object.freeze({
    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
    retryMaxSpend: breakers.retry?.maxSpend,
    retryBackoffMs,
  });
}

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Add a budget alongside retry: `run(agent, { budget: { maxUsd: 5 }, breakers: { retry: { maxSpend: 1 } } })`.
  2. Or drop `breakers.retry` if you don't need cost-bounded automatic retries.

Example fix

// before
run(agent, { breakers: { retry: { maxSpend: 1 } } }); // no budget

// after
run(agent, {
  budget: { maxUsd: 5 },
  breakers: { retry: { maxSpend: 1 } },
});
Defensive patterns

Strategy: validation

Validate before calling

if (options.breakers?.retry !== undefined && options.budget === undefined) {
  throw new Error("breakers.retry requires RunOptions.budget — add budget: { maxUsd } or { maxTokens }");
}

Try / catch

try {
  run(agent, options);
} catch (err) {
  if (err instanceof Error && err.message === "cave_breaker_retry_requires_budget") {
    // add a budget or remove retry — a config fix, not a transient failure
  }
}

Prevention

When it happens

Trigger: Calling `run(agent, { breakers: { retry: { maxSpend: 1 } } })` without also setting `RunOptions.budget` (`{ maxUsd: ... }` or `{ maxTokens: ... }`); adding retry config to an existing run call that never had a budget; combining `retry` with `maxCostUsd` only (the older cap is not a budget).

Common situations: Copying a breakers snippet from docs into a run that uses no budget; migrating from `maxCostUsd` to breakers and assuming the cap satisfies the requirement.

Related errors


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