JuliusBrussee/caveman · error

cave_${field}_invalid

Error message

cave_${field}_invalid

What it means

Dynamic template error: field is "max_model_calls" or "max_tool_calls", producing cave_max_model_calls_invalid / cave_max_tool_calls_invalid. Caller-supplied ceilings (RunOptions.maxModelCalls, RunOptions.maxToolCalls) fail closed: any value that is not an integer >= 1 is a malformed cap, not 'no cap', and is rejected before the first provider call. Defaults are derived (64 calls without a plan; plan-derived with one) when the options are omitted.

Source

Thrown at packages/agent/src/runtime.ts:1460

    let modelCalls = 0;
    let compactionsUsed = 0;
    // Incremented the moment a compaction takes a reservation, so a paid
    // attempt counts even when its summary is later discarded.
    let compactionsSpent = 0;
    let previousSummary: ContextSummary | undefined;
    // Provider-reported, never assumed: the last call either read a cached
    // prefix or it did not. Before the first call there is nothing to report.
    let lastCallCacheState: "warm" | "cold" | "unknown" = "unknown";
    const toolCalls: string[] = [];
    let turnStateChanged = false;
    const startedAt = performance.now();
    // Caller-supplied ceilings override the derived defaults. They
    // fail closed: a non-positive or non-integer value is a malformed cap, not
    // "no cap", so it is rejected before the first provider call.
    const callCeilingOverride = (value: number | undefined, field: string): number | undefined => {
      if (value === undefined) return undefined;
      if (!Number.isInteger(value) || value < 1) {
        throw new Error(`cave_${field}_invalid`);
      }
      return value;
    };
    const maxModelCalls = callCeilingOverride(options.maxModelCalls, "max_model_calls") ??
      (efficiencyPlan === undefined
        ? 64
        : 1 + Math.max(1, Math.ceil(efficiencyPlan.budgets.retry_cascade_reserve / 256)));
    const maxToolCalls = callCeilingOverride(options.maxToolCalls, "max_tool_calls") ??
      (efficiencyPlan === undefined
        ? 64
        : Math.max(1, definition.tools.length) * Math.max(1, maxModelCalls - 1));
    const accountedAssistantMessages = new WeakSet<object>();
    const accountProviderMessage = (message: AssistantMessage): void => {
      // beforeToolCall runs after the provider message but before its tools.
      // Account there so a call that consumed the deadline or wallet cannot
      // launch fresh side effects. turn_end calls this too for tool-free turns;
      // object identity makes the operation exactly once on either path.
      if (accountedAssistantMessages.has(message)) {

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Pass an integer >= 1, e.g. maxModelCalls: 8, or omit to use the derived default
  2. For 'exactly one model call', use maxModelCalls: 1, never 0
  3. Sanitize computed values: Math.max(1, Math.floor(n))

Example fix

// before
await agent.run(input, { maxModelCalls: calls }); // calls = 0 from unset config

// after
const calls = Number(process.env.MAX_CALLS);
await agent.run(input, {
  ...(Number.isInteger(calls) && calls >= 1 ? { maxModelCalls: calls } : {}),
});
Defensive patterns

Strategy: validation

Validate before calling

function callCeiling(v: unknown): number | undefined {
  return typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : undefined;
}
const opts = {
  ...(callCeiling(config.maxModelCalls) !== undefined ? { maxModelCalls: callCeiling(config.maxModelCalls)! } : {}),
  ...(callCeiling(config.maxToolCalls) !== undefined ? { maxToolCalls: callCeiling(config.maxToolCalls)! } : {}),
};

Type guard

const isCallCeiling = (v: unknown): v is number =>
  typeof v === "number" && Number.isInteger(v) && v >= 1;

Try / catch

try { await agent.run(input, opts); } catch (e) {
  if (e instanceof Error && /^cave_(max_model_calls|max_tool_calls)_invalid$/.test(e.message)) {
    // config bug: fix the ceiling value
  } else throw e;
}

Prevention

When it happens

Trigger: Passing maxModelCalls: 0, a negative number, a float like 8.5, or NaN; same for maxToolCalls. Computed ceilings from config that default to 0 are the usual source.

Common situations: Config-driven caps where an unset value coerces to 0; trying to express 'one shot only' with 0 (should be 1); dividing budgets into call counts and passing a fractional result.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/30b6ae8ad442806f. Report an issue: GitHub.