JuliusBrussee/caveman · error

caveman agent: value is not canonically serializable

Error message

caveman agent: value is not canonically serializable

What it means

Thrown by stableStringify in packages/agent/src/context-ir.ts when a value cannot be represented in the canonical JSON form used for hashing/signing. JSON.stringify returns undefined for undefined, functions, and symbols, so such values cannot take part in a deterministic canonical record (e.g. contractSHA256).

Solutions

  1. Remove or omit undefined/function/symbol values from the object before hashing — use JSON.parse(JSON.stringify(...)) semantics or explicit null.
  2. Replace functions with serializable descriptors (e.g. the function's name or source string).
  3. Ensure optional fields are deleted (delete obj.key) rather than set to undefined when building canonical records.

Example fix

// before
const record = { name: "tool", handler: myFn, notes: undefined };
contractSHA256(record);
// after
const record = { name: "tool", handlerName: myFn.name };
contractSHA256(record);
Defensive patterns

Strategy: validation

Validate before calling

function isCanonicallySerializable(value: unknown): boolean {
  if (value === null) return true;
  if (typeof value !== "object") return typeof value !== "function" && typeof value !== "symbol" && value !== undefined;
  if (Array.isArray(value)) return value.every(isCanonicallySerializable);
  return Object.values(value).every(isCanonicallySerializable);
}
if (!isCanonicallySerializable(record)) throw new Error("record contains undefined/function/symbol");

Type guard

function isPlainValue(v: unknown): v is string | number | boolean | null | CanonObject | CanonArray {
  return v === null || ["string", "number", "boolean"].includes(typeof v) ||
    (Array.isArray(v) && v.every(isPlainValue)) ||
    (typeof v === "object" && Object.values(v).every(isPlainValue));
}

Try / catch

try {
  const hash = contractSHA256(record);
} catch (e) {
  if (e instanceof Error && e.message.includes("not canonically serializable")) {
    record = JSON.parse(JSON.stringify(record, (_k, v) => v === undefined ? null : (typeof v === "function" ? String(v.name) : v)));
  } else throw e;
}

Prevention

When it happens

Trigger: Passing an object graph containing undefined, a function, or a symbol as a top-level scalar (e.g. stableStringify(undefined) or stableStringify(() => {})) to any API that computes a canonical hash: contractSHA256, canonicalRecord, signature, callSignature, evalSuiteSha256, or run.

Common situations: A tool parameter defaulted to undefined, a callback accidentally included in a contract object, a class instance with method fields, or missing optional fields that serialize as undefined instead of being omitted.

Understand the failure class

Background: "JSON serialization failed", "not JSON serializable", "Failed to serialize": why JSON marshaling errors happen and how to fix them — this error's family across 46 libraries.

Related errors


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

Appendix: source

Thrown at packages/agent/src/context-ir.ts:337

async function sourceBytes(source: string | FileSource, rootDir: string): Promise<Uint8Array> {
  if (typeof source === "string") return new TextEncoder().encode(source);
  const fullPath = resolve(rootDir, source.path);
  const relativePath = relative(rootDir, fullPath);
  if (relativePath === ".." || relativePath.startsWith("../") ||
      relativePath.startsWith("..\\") || isAbsolute(relativePath)) {
    throw new Error("caveman agent: file source escapes project root");
  }
  return new Uint8Array(await readFile(fullPath));
}

function encodeCanonical(value: unknown): Uint8Array {
  return new TextEncoder().encode(stableStringify(value));
}

export function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") {
    const encoded = JSON.stringify(value);
    if (encoded === undefined) throw new Error("caveman agent: value is not canonically serializable");
    return encoded;
  }
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
  const object = value as Record<string, unknown>;
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
}

export function sha256(value: Uint8Array | string): string {
  return createHash("sha256").update(value).digest("hex");
}

export function opaquePayload(input: Uint8Array): boolean {
  const value = new TextDecoder().decode(input).trim();
  if (/^[-]{5}BEGIN (?:PGP SIGNED MESSAGE|[A-Z ]+ SIGNATURE)[-]{5}/.test(value) ||
      /^[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}$/.test(value) ||
      /\b(?:x-amz-signature|signature)=([0-9a-f]{32,}|[A-Za-z0-9_-]{32,})\b/i.test(value)) {
    return true;
  }

View on GitHub (pinned to 3ee70a1026)