{"record":{"id":"1c94bb9c1dc6f5fe","repo":"JuliusBrussee/caveman","slug":"cave-breaker-retry-requires-budget","errorCode":"cave_breaker_retry_requires_budget","errorMessage":"cave_breaker_retry_requires_budget","messagePattern":"cave_breaker_retry_requires_budget","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/breakers.ts","lineNumber":89,"sourceCode":"  hasBudget: boolean,\n): NormalizedBreakers {\n  const repeatedToolCalls = breakers.repeatedToolCalls ?? DEFAULT_REPEATED_TOOL_CALLS;\n  const repeatedToolCallWindowTurns = breakers.repeatedToolCallWindowTurns ??\n    DEFAULT_REPEATED_TOOL_CALL_WINDOW_TURNS;\n  const noProgressTurns = breakers.noProgressTurns ?? DEFAULT_NO_PROGRESS_TURNS;\n  const maxToolCallsPerTurn = breakers.maxToolCallsPerTurn ?? DEFAULT_MAX_TOOL_CALLS_PER_TURN;\n  for (const value of [\n    repeatedToolCalls,\n    repeatedToolCallWindowTurns,\n    noProgressTurns,\n    maxToolCallsPerTurn,\n  ]) {\n    if (!Number.isSafeInteger(value) || value <= 0) {\n      throw new Error(\"cave_breaker_threshold_invalid\");\n    }\n  }\n  if (breakers.retry !== undefined && !hasBudget) {\n    throw new Error(\"cave_breaker_retry_requires_budget\");\n  }\n  if (breakers.retry !== undefined &&\n      (!Number.isFinite(breakers.retry.maxSpend) || breakers.retry.maxSpend <= 0)) {\n    throw new Error(\"cave_breaker_retry_spend_invalid\");\n  }\n  const retryBackoffMs = breakers.retry?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;\n  if (!Number.isSafeInteger(retryBackoffMs) || retryBackoffMs < 0) {\n    throw new Error(\"cave_breaker_retry_backoff_invalid\");\n  }\n  return Object.freeze({\n    repeatedToolCalls,\n    repeatedToolCallWindowTurns,\n    noProgressTurns,\n    maxToolCallsPerTurn,\n    retryMaxSpend: breakers.retry?.maxSpend,\n    retryBackoffMs,\n  });\n}","sourceCodeStart":71,"sourceCodeEnd":107,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/breakers.ts#L71-L107","documentation":"normalizeRunBreakers validates run breaker configuration. A `retry` breaker inherently spends more budget (repeated model calls), so it is only meaningful when a budget is configured. If `breakers.retry` is provided but no budget (hasBudget) exists on the run, the library throws this sentinel error to prevent an unbounded retry loop with no spending cap.","triggerScenarios":"Passing a breakers object with `retry: { maxSpend, backoffMs, ... }` set while the run configuration has no budget (no maxUsd/maxTokens) — e.g. calling breakers normalization with retry options on a run lacking normalizeRunBudget.","commonSituations":"Developers copy a retry-tuning snippet from docs but forget the budget section; an existing config gains retry settings while budget stays unset; a CLI/UI lets users tune retries independently of the spend cap.","solutions":["Add a budget to the run config (maxUsd or maxTokens) alongside the retry breaker.","Remove the `retry` breaker if you intentionally want an uncapped run.","Ensure the budget is passed to the same normalize/validate call so hasBudget is true."],"exampleFix":"// before\nbreakers({ retry: { maxSpend: 0.5 } })\n// after\nbreakers({ budget: { maxUsd: 2 }, retry: { maxSpend: 0.5 } })","handlingStrategy":"validation","validationCode":"function assertRetryNeedsBudget(breakers) {\n  if (breakers?.retry && (breakers.budget === undefined || (breakers.budget.maxUsd === undefined && breakers.budget.maxTokens === undefined))) {\n    throw new Error('cave_breaker_retry_requires_budget: retry requires a budget');\n  }\n}","typeGuard":null,"tryCatchPattern":"try { normalizeRunBreakers(cfg); } catch (e) {\n  if (e.message === 'cave_breaker_retry_requires_budget') {\n    cfg = { ...cfg, budget: { maxUsd: cfg.budget?.maxUsd ?? DEFAULT_BUDGET_USD } };\n  } else throw e;\n}","preventionTips":["Always pair retry breakers with an explicit budget in the same config object.","Lint configs: any `retry:` key must be sibling to a budget key.","Document retry spend implications so configs are not tuned independently."],"tags":["config","validation","retry","budget"],"backgroundTag":"conflicting-config-options","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}