JuliusBrussee/caveman · error
cave_budget_release_reason_required
cave_budget_release_reason_required
Error message
cave_budget_release_reason_required
What it means
`BudgetMeter.release(amount, reason)` requires a non-empty reason for every released tranche. The library throws `cave_budget_release_reason_required` when `reason` is not a string or is empty/whitespace-only. Reasons are persisted on the frozen tranche and appear in the run receipt's tranche history, so a tranche without a reason would be unauditable — the ledger refuses to record one. This is pre-flight validation at a developer-controlled checkpoint, so the caller asked for something the contract cannot grant.
Solutions
- Pass a non-empty descriptive string as the second argument, e.g. `meter.release(1.0, "checkpoint: analysis phase complete")`.
- If the reason is computed, guard it: `const r = cfg.reason?.trim(); if (r) meter.release(amount, r);`
- For whitespace-only strings, trim or substitute a default: `meter.release(amount, reason.trim() || "unspecified-checkpoint")`.
- Inspect the budget controller / escalation handler call site to confirm the reason field is wired through and not lost in a config object.
Example fix
// before meter.release(0.5); // after meter.release(0.5, "checkpoint: stage-1 retrieval finished");
Defensive patterns
Strategy: validation
Validate before calling
function canReleaseReason(reason: unknown): reason is string {
return typeof reason === "string" && reason.trim() !== "";
}
// before calling: if (canReleaseReason(reason)) meter.release(amount, reason); Type guard
const isNonEmptyString = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
Try / catch
try {
meter.release(amount, reason);
} catch (e) {
if (e instanceof Error && e.message === "cave_budget_release_reason_required") {
meter.release(amount, "unspecified-checkpoint");
} else throw e;
} Prevention
- Always pass a descriptive reason string as the second argument to release().
- Wrap dynamic reasons in a helper that trims and substitutes a default when empty.
- Type the controller/checkpoint config so the reason field is required, not optional.
- Add a unit test asserting every release call site in your code supplies a non-empty reason.
When it happens
Trigger: Calling `release(amount)` with the second argument omitted; passing `undefined`, `null`, `""`, or a whitespace-only string like `" "`; building the reason dynamically (e.g. from a template or config lookup) and having it resolve to an empty string; calling `release` from a `createBudgetController` checkpoint or an `onBudgetExhausted` handler where the reason field was never populated.
Common situations: Wiring up a staged budget (`budget.initialUsd`/`initialTokens`) and forgetting the reason argument in the checkpoint callback; refactoring code so a reason constant was removed or renamed; deserializing controller config from JSON where the reason field is missing; copy-pasting an example that only showed the amount argument.
Understand the failure class
Background: "must not be empty", "cannot be empty" — required-field validation errors across open-source libraries — this error's family across 41 libraries.
Related errors
- at least one report path and the canonical span fixture…
- cave_breaker_retry_requires_budget
- cave_budget_denomination_ambiguous
- cave_budget_initial_invalid
- cave_budget_max_invalid
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/aa95c42236ca0657.
Report an issue: GitHub.
Appendix: source
Thrown at packages/agent/src/budget.ts:331
/** Budget that could still be released without breaching `max`. */
releasable(): number {
return Math.max(0, this.max - this.releasedAmount);
}
/**
* Release a further tranche. Throws when it would breach `max`: this is
* pre-flight validation at a developer-controlled checkpoint, so the caller
* asked for something the contract cannot grant.
*/
release(amount: number, reason: string): BudgetTranche {
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("cave_budget_release_invalid");
}
if (this.denomination === "tokens" && !Number.isSafeInteger(amount)) {
throw new Error("cave_budget_release_invalid");
}
if (typeof reason !== "string" || reason.trim() === "") {
throw new Error("cave_budget_release_reason_required");
}
if (this.revokedFlag) throw new Error("cave_budget_revoked");
// A breached ledger is dead. Releasing into it would record a tranche and
// raise an escalation for money that can never be spent, and would read on
// the receipt as a run that was still being funded after it went past cap.
if (this.breachedFlag) throw new Error("cave_budget_cap_breached");
if (amount > this.releasable()) throw new Error("cave_budget_release_exceeds_max");
this.releasedAmount += amount;
const tranche: BudgetTranche = Object.freeze({
amount,
reason,
atCall: this.callIndex,
});
this.trancheLog.push(tranche);
return tranche;
}
/**View on GitHub (pinned to 3ee70a1026)