JuliusBrussee/caveman · error · Error
caveman agent: run ended without terminal evidence
Error message
caveman agent: run ended without terminal evidence
What it means
Thrown on the promise-returning path (runtime.ts:1001) when the run's own event stream finishes without ever emitting run_end or run_error. Every well-formed run terminates with exactly one of those events; their absence means the stream was cut short — almost always a caller-supplied streamFn/transport that ends the stream without a terminal event, and otherwise a framework bug worth reporting.
Source
Thrown at packages/agent/src/runtime.ts:1001
options: InternalRunOptions,
executionContext: InternalExecutionContext,
): Promise<RunResult> {
let final: RunResult | undefined;
for await (const event of streamAgentWithOptions(
definition,
input,
options,
executionContext,
)) {
if (event.type === "run_end") final = event.result;
if (event.type === "run_error") {
// The ledger is not lost on the throwing path either: the partial receipt
// rides on a typed `cause` so a caller that only awaits the promise can
// still read what was spent before the failure.
throw new CavemanRunError(event.code, event.message, event.receipt);
}
}
if (!final) throw new Error("caveman agent: run ended without terminal evidence");
return final;
}
export function streamAgent(
definition: AgentDefinition,
input: string,
options: RunOptions = {},
): AsyncGenerator<CavemanRunEvent> {
rejectInternalRunOptions(options);
return streamAgentWithOptions(
definition,
input,
options,
rootExecutionContext(definition, options.maxCostUsd, options),
);
}
function streamAgentWithOptions(View on GitHub (pinned to 766dce6b13)
Solutions
- Audit any custom streamFn: it must forward the terminal run_end/run_error event unchanged
- Temporarily remove the custom streamFn/onPayload options and re-run to confirm the stock path terminates correctly
- If it reproduces with no customized options, capture the partial receipt and report a framework issue
Example fix
// before — test double ends the stream without a terminal event
const fakeStream: StreamFn = async function* () { yield partialEvent; };
// after — always forward/emit the terminal event
const fakeStream: StreamFn = async function* (selected, context, streamOptions) {
for await (const ev of realStream(selected, context, streamOptions)) yield ev;
}; Defensive patterns
Strategy: try-catch
Try / catch
try {
const result = await runAgent(def, input, opts);
} catch (err) {
if (err instanceof Error && err.message === 'caveman agent: run ended without terminal evidence') {
// Almost certainly your custom streamFn dropped the terminal event.
// Log the options in use, remove custom streamFn/onPayload, and re-run.
// If it reproduces with stock options, report it upstream with the run options (no secrets).
}
throw err;
} Prevention
- Custom streamFn wrappers must forward every event, especially run_end/run_error — write them as pass-through for-await loops
- In test doubles, always terminate the fake stream with the same terminal event shape the real transport emits
- Pin the exact framework version when customizing transports; event shapes are versioned with the exact-pinned upstream
When it happens
Trigger: A custom RunOptions.streamFn wrapper (mocking, proxying, telemetry) that returns or completes the underlying stream without forwarding Pi's terminal event; an onPayload/stream adapter that swallows the final chunk; an abort path that closes the generator early. Bare runAgent with no custom options hitting this indicates a runtime bug — file it with the receipt.
Common situations: Writing a fake streamFn for tests that stops after N chunks and forgets the closing event; inserting a middleware that filters events too aggressively; version drift between the exact-pinned Pi core and a custom transport written against an older event shape.
Related errors
AI-assisted analysis of JuliusBrussee/caveman@766dce6b13 (2026-08-18).
Data as JSON: /api/errors/50b217e204af87cd.
Report an issue: GitHub.