JuliusBrussee/caveman · error

cave_breaker_retry_spend_invalid

cave_breaker_retry_spend_invalid

Error message

cave_breaker_retry_spend_invalid

What it means

normalizeRunBreakers requires that when a retry breaker is configured, its `maxSpend` is a finite positive number. maxSpend caps the additional budget the retry loop may consume; a missing, non-finite (NaN/Infinity), zero, or negative value makes the retry cap meaningless, so this sentinel error is thrown.

Solutions

  1. Set retry.maxSpend to a finite number > 0.
  2. If you want retries without extra spend allowance, remove the retry breaker or reconsider the value — 0 is rejected.
  3. Validate config values are parsed to numbers before constructing breakers.

Example fix

// before
retry: { maxSpend: 0 }
// after
retry: { maxSpend: 0.25 }
Defensive patterns

Strategy: validation

Validate before calling

function validMaxSpend(v) { return typeof v === 'number' && Number.isFinite(v) && v > 0; }
if (breakers.retry && !validMaxSpend(breakers.retry.maxSpend)) throw new Error('cave_breaker_retry_spend_invalid: need finite maxSpend > 0');

Type guard

const isPositiveFinite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0;

Try / catch

try { normalizeRunBreakers(cfg); } catch (e) {
  if (e.message === 'cave_breaker_retry_spend_invalid') console.error('retry.maxSpend must be a finite number > 0, got', cfg.breakers?.retry?.maxSpend);
  else throw e;
}

Prevention

When it happens

Trigger: Setting `breakers.retry` to an object where maxSpend is undefined, NaN, Infinity, <= 0 (e.g. `retry: { maxSpend: 0 }` or `retry: {}`), while a valid budget exists.

Common situations: Computing maxSpend from another value that is NaN (e.g. parsing a config string); defaulting maxSpend to 0 intending 'no extra spend'; JSON config where maxSpend was omitted and TS types didn't catch it.

Understand the failure class

Background: "Invalid value" and "allowed values are" config errors: what your library rejected and how to fix it — this error's family across 41 libraries.

Related errors


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

Appendix: source

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

    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,
  });
}

/** One breaker decision, recorded on the receipt so a break is never silent. */
export interface BreakerEvent {
  readonly kind: "loop_detected" | "no_progress" | "fan_out_blocked" | "retry_attempted" | "retry_exhausted";

View on GitHub (pinned to 3ee70a1026)