JuliusBrussee/caveman · error

engine_changed_bytes_without_recovery

Error message

engine_changed_bytes_without_recovery

What it means

Thrown after running a compression engine when the engine's output bytes differ from the input but it returned no recovery_handle. The contract is strict: the engine may either pass bytes through unchanged, or change them AND provide a handle so cave_retrieve can recover the original. Changing bytes without a handle is unrecoverable and fails immediately.

Source

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

async function engineCompress(
  input: Uint8Array,
  engineBin: string | undefined,
  contentType = "text",
  signal?: AbortSignal,
): Promise<{ output: Uint8Array; handle?: string; tokensBefore?: number; tokensAfter?: number }> {
  const result = await runEngine(engineBin, ["compress", "--type", contentType], input, signal);
  const lastLine = result.stderr.trim().split("\n").pop() ?? "{}";
  const report = JSON.parse(lastLine) as {
    recovery_handle?: unknown;
    tokens_before?: unknown;
    tokens_after?: unknown;
  };
  const handle = typeof report.recovery_handle === "string" && report.recovery_handle.length > 0
    ? report.recovery_handle
    : undefined;
  if (!handle && !bytesEqual(result.stdout, input)) {
    throw new Error("engine_changed_bytes_without_recovery");
  }
  const tokensBefore = validEngineTokenCount(report.tokens_before);
  const tokensAfter = validEngineTokenCount(report.tokens_after);
  return {
    output: result.stdout,
    ...(handle === undefined ? {} : { handle }),
    ...(tokensBefore === undefined ? {} : { tokensBefore }),
    ...(tokensAfter === undefined ? {} : { tokensAfter }),
  };
}

function engineContentType(transformID: string): string {
  const match = /^caveman\.engine\.([a-z0-9-]+)\.v1$/.exec(transformID);
  if (!match) throw new Error(`cave_unknown_transform:${transformID}`);
  return match[1]!;
}

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Verify the engine binary version matches the runtime version (engineBin path)
  2. Reproduce with a direct engine invocation: run `<engineBin> compress --type text` and inspect the last stderr JSON line for recovery_handle
  3. If using a custom engine, ensure it either outputs identical bytes or emits a valid recovery_handle in the report
  4. Update both runtime and engine together from the same release
Defensive patterns

Strategy: try-catch

Validate before calling

// Smoke-test the engine before the run:
const input = new TextEncoder().encode("probe");
const result = await runEngine(engineBin, ["compress", "--type", "text"], input);
const report = JSON.parse(result.stderr.trim().split("\n").pop() ?? "{}");
const changed = !bytesEqual(result.stdout, input);
if (changed && !report.recovery_handle) { /* engine broken: fail fast */ }

Type guard

function isEngineChangedWithoutRecovery(e: unknown): e is Error {
  return e instanceof Error && e.message === "engine_changed_bytes_without_recovery";
}

Try / catch

try {
  await run(engineBin, ["compress", "--type", contentType], input, signal);
} catch (e) {
  if (isEngineChangedWithoutRecovery(e)) {
    // fall back to the original bytes (fail open) or abort the run
  } else throw e;
}

Prevention

When it happens

Trigger: runEngine('compress') returns stdout that differs from input, and the stderr JSON report has no non-empty recovery_handle string.

Common situations: A buggy or modified caveman engine binary; version mismatch between runtime expectations and engine report format (recovery_handle field renamed/dropped); an engine crash producing partial output; someone swapping in a custom engine that compresses without registering recovery state.

Related errors


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