{"record":{"id":"beb1304df299df8c","repo":"JuliusBrussee/caveman","slug":"cave-budget-escalation-result-invalid","errorCode":"cave_budget_escalation_result_invalid","errorMessage":"cave_budget_escalation_result_invalid","messagePattern":"cave_budget_escalation_result_invalid","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/runtime.ts","lineNumber":1632,"sourceCode":"        restorableBytes: restorableRequestBytes(\n          conversationOriginals,\n          instructions,\n          originalInstructions,\n        ),\n      });\n      let decided: NextCallDecision;\n      try {\n        decided = plan();\n        // Escalation gets exactly one attempt per exhaustion: the handler either\n        // funds the next call or it does not, and a handler that keeps releasing\n        // slivers must not turn one stop into an unbounded loop.\n        if (decided.action === \"stop\" && decided.reason === \"budget_exhausted\" &&\n            budgetMeter !== undefined && typeof options.onBudgetExhausted === \"function\") {\n          const outcome = await options.onBudgetExhausted(budgetExhaustionContext(budgetMeter));\n          if (outcome !== \"stop\") {\n            if (!isRecord(outcome) || typeof outcome.release !== \"number\" ||\n                typeof outcome.reason !== \"string\") {\n              throw new Error(\"cave_budget_escalation_result_invalid\");\n            }\n            budgetMeter.release(outcome.release, outcome.reason);\n            decided = plan();\n          }\n        }\n      } catch (error) {\n        // Pi answers a thrown streamFn with a synthesized terminal error turn,\n        // which would otherwise replace this cause with a generic provider\n        // failure. Record it first so the run reports what actually went wrong.\n        ladderFailure ??= error instanceof Error ? error : new Error(String(error));\n        throw ladderFailure;\n      }\n      if (decided.action === \"stop\") {\n        // Pi answers a thrown streamFn with a synthesized zero-usage error turn,\n        // so record the refusal before throwing or the stop becomes the usage\n        // failure that turn produces.\n        stopReason = decided.reason;\n        refusalPending = true;","sourceCodeStart":1614,"sourceCodeEnd":1650,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/runtime.ts#L1614-L1650","documentation":"Thrown when a RunOptions.onBudgetExhausted handler is invoked on budget exhaustion (exactly one escalation attempt per exhaustion) and returns an invalid outcome. The contract accepts \"stop\" or an object { release: number, reason: string }; anything else — undefined, a string other than \"stop\", a missing/Non-number release, a missing reason — is rejected. A handler that keeps releasing slivers is also bounded to one attempt, so malformed responses must fail loudly rather than loop.","triggerScenarios":"An async handler whose return path is missing (returns undefined); returning { release: \"5\" } (string) or { release: 5 } without reason; returning true or \"continue\".","commonSituations":"A handler that asks a human or another system for approval and forwards its answer unvalidated; a handler that conditionally forgets to return; refactoring from a boolean-based hook to the outcome-object contract.","solutions":["Return exactly \"stop\" or { release: <finite number>, reason: \"<string>\" } from every code path of onBudgetExhausted","Default to \"stop\" when the escalation logic errors or cannot decide","Validate external approvals before returning them: typeof r.release === \"number\" && typeof r.reason === \"string\""],"exampleFix":"// before\nconst result = await agent.run(input, {\n  budget: { maxUsd: 5 },\n  onBudgetExhausted: async () => {\n    const ok = await askHuman();\n    if (ok) return { release: 5 }; // missing reason\n  }, // falls through to undefined\n});\n\n// after\nconst result = await agent.run(input, {\n  budget: { maxUsd: 5 },\n  onBudgetExhausted: async () => {\n    const ok = await askHuman();\n    return ok ? { release: 5, reason: \"human approved top-up\" } : \"stop\";\n  },\n});","handlingStrategy":"type-guard","validationCode":"type EscalationOutcome = \"stop\" | { release: number; reason: string };\nfunction validOutcome(v: unknown): EscalationOutcome {\n  if (v === \"stop\") return v;\n  if (typeof v === \"object\" && v !== null &&\n      typeof (v as any).release === \"number\" &&\n      typeof (v as any).reason === \"string\") {\n    return v as { release: number; reason: string };\n  }\n  return \"stop\"; // fail safe: malformed escalation stops rather than throwing\n}","typeGuard":"const isEscalationOutcome = (v: unknown): v is EscalationOutcome =>\n  v === \"stop\" ||\n  (typeof v === \"object\" && v !== null &&\n   typeof (v as { release?: unknown }).release === \"number\" &&\n   typeof (v as { reason?: unknown }).reason === \"string\");","tryCatchPattern":null,"preventionTips":["Wrap handler bodies so every path returns \"stop\" or a validated object","Validate outputs of humans/external systems before forwarding as escalation outcomes","Remember the handler gets exactly one attempt per exhaustion — return a decisive answer"],"tags":["budget","escalation","callbacks","validation"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}