JuliusBrussee/caveman · error · Error
cave_breaker_retry_backoff_invalid
Error message
cave_breaker_retry_backoff_invalid
What it means
The retry backoff (`breakers.retry.backoffMs`, default 250ms — first backoff, doubled each attempt, deliberately no jitter) must be a safe integer ≥ 0. `normalizeRunBreakers` throws `cave_breaker_retry_backoff_invalid` for fractional milliseconds, negatives, `NaN`, `Infinity`, or unsafe-large integers. Zero is allowed and means the first retry is immediate.
Source
Thrown at packages/agent/src/breakers.ts:97
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";
readonly tool: string | undefined;
/** Repeats for a loop, identical turns for no-progress, blocked calls for fan-out, attempt number for a retry. */
readonly count: number;
/** The call hash for a loop break, so the offending window is identifiable. */View on GitHub (pinned to 27d5a3981a)
Solutions
- Round to a whole number: `backoffMs: Math.round(1000 / 3)` → `333`.
- Use 0 only if you intentionally want no initial delay.
- Omit `backoffMs` entirely to accept the 250ms default.
Example fix
// before
breakers: { retry: { maxSpend: 1, backoffMs: 1000 / 3 } } // 333.333...
// after
breakers: { retry: { maxSpend: 1, backoffMs: Math.round(1000 / 3) } } // 333 Defensive patterns
Strategy: validation
Validate before calling
const backoffMs = options.breakers?.retry?.backoffMs ?? 250; // default
if (!Number.isSafeInteger(backoffMs) || backoffMs < 0) {
throw new Error("breakers.retry.backoffMs must be a safe integer >= 0");
} Type guard
function isValidBackoffMs(v: unknown): v is number {
return Number.isSafeInteger(v) && (v as number) >= 0;
} Try / catch
try {
normalizeRunBreakers(breakers, true);
} catch (err) {
if (err instanceof Error && err.message === "cave_breaker_retry_backoff_invalid") {
// round the computed backoff, or omit the field to take the 250ms default
}
} Prevention
- Round computed backoff values with Math.round before passing them.
- Omit backoffMs to accept the deterministic 250ms default.
- Remember 0 is valid (immediate first retry); fractional and negative values are not.
When it happens
Trigger: Setting `backoffMs: 250.5` (fraction), `backoffMs: -1`, or `backoffMs: Infinity`; deriving backoff from a division (`1000 / 3`) without rounding; parsing from a string config.
Common situations: Computing backoff from environment-scaled formulas; passing a float-typed config field through JSON; assuming any number works because the default is optional.
Related errors
- cave_breaker_threshold_invalid
- cave_breaker_retry_requires_budget
- cave_breaker_retry_spend_invalid
- caveman agent: unknown sandbox mode ${JSON.stringify(sandbox
- caveman agent: subagent maxInputChars must be a positive int
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/f9f75912fdf40ec7.
Report an issue: GitHub.