JuliusBrussee/caveman · error

cave_subagent_concurrency_limit

cave_subagent_concurrency_limit

Error message

cave_subagent_concurrency_limit

What it means

This library enforces per-invocation-state concurrency ledgers for subagent dispatch. The admitSubagent gate throws cave_subagent_concurrency_limit when the number of currently active subagents equals the configured maxConcurrent cap, refusing admission until an earlier subagent releases its slot. It is a deliberate back-pressure mechanism to prevent unbounded parallel subagent fan-out.

Solutions

  1. Reduce the number of subagent tool calls dispatched in parallel in a single turn
  2. Raise the maxConcurrent limit in the runtime/subagent configuration
  3. Wait for previously dispatched subagents to finish before dispatching more (their release() decrements ledger.active)
  4. Check for leaked admission slots: if peakActive never decrements, ensure subagent runs always release via the returned release function, including on error

Example fix

// before: dispatch all subagents at once
await Promise.all(tasks.map(t => dispatchSubagent(t)));
// after: limit in-flight subagents
await pLimit(maxConcurrent, tasks.map(t => () => dispatchSubagent(t)));
Defensive patterns

Strategy: try-catch

Validate before calling

const ledger = getInvocationLedger(executionContext.invocationState);
if (ledger.maxConcurrent !== undefined && ledger.active >= ledger.maxConcurrent) {
  throw new Error("would exceed subagent concurrency limit");
}

Try / catch

try {
  await dispatchSubagent({ task });
} catch (e) {
  if (e.message === "cave_subagent_concurrency_limit") {
    await waitForSubagentSlot(); // then retry
  } else throw e;
}

Prevention

When it happens

Trigger: Calling a subagent tool while ledger.active >= ledger.maxConcurrent for the current invocationState — e.g. dispatching more parallel subagent tool calls in one assistant turn than the runtime's maxConcurrent subagent setting allows.

Common situations: Orchestrator prompts that spawn N subagents in parallel where N exceeds the configured cap; a stuck subagent that never released its admission slot; shared invocationState reused across turns so slots are still held; misconfigured maxConcurrent left at a small default.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/f6bedbb2fa10de21. Report an issue: GitHub.

Appendix: source

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

  const body = appliedPlan.bodies.get(segment.bodyHandle);
  if (!body) throw new Error(`cave_context_body_missing:tool.${definition.name}`);
  const parsed = JSON.parse(new TextDecoder().decode(body)) as unknown;
  if (!isRecord(parsed) || parsed.name !== definition.name ||
      typeof parsed.description !== "string" || !isRecord(parsed.input)) {
    throw new Error(`cave_tool_schema_invalid:${definition.name}`);
  }
  return { description: parsed.description, input: parsed.input as TSchema };
}

function admitSubagent(state: InvocationState): () => void {
  const ledger = state.ledger;
  if (ledger.maxInvocations !== undefined && ledger.admitted >= ledger.maxInvocations) {
    ledger.invocationRejections++;
    throw new Error("cave_subagent_invocation_limit");
  }
  if (ledger.maxConcurrent !== undefined && ledger.active >= ledger.maxConcurrent) {
    ledger.concurrencyRejections++;
    throw new Error("cave_subagent_concurrency_limit");
  }
  ledger.admitted++;
  ledger.active++;
  ledger.peakActive = Math.max(ledger.peakActive, ledger.active);
  let released = false;
  return () => {
    if (released) return;
    released = true;
    ledger.active = Math.max(0, ledger.active - 1);
  };
}

function childInvocationTrace(parent: InvocationTrace): InvocationTrace {
  return Object.freeze({
    traceId: parent.traceId,
    spanId: randomBytes(8).toString("hex"),
    parentSpanId: parent.spanId,
  });

View on GitHub (pinned to 3ee70a1026)