JuliusBrussee/caveman · error

merged config is not JSON

Error message

merged config is not JSON

What it means

After merging the base and overlay configs, the wrapper serializes the merged object with JSON.stringify and asserts the result is not undefined. JSON.stringify returns undefined only for non-JSON values (undefined, functions, symbols) at the top level, so this throw is an internal invariant check that the merge produced a JSON-serializable object.

Solutions

  1. Check that the base and overlay config objects passed into the merge are plain JSON values before merging.
  2. Ensure cloneJsonValue is used for all injected values so no functions/symbols/undefined reach the merged object.
  3. If you patched the CLI, revert the change and rebuild; this path is an invariant, not a user-config error.
  4. Report the exact profile inputs that reproduce it as a CLI bug if it occurs on a stock build.

Example fix

// before
const merged = { ...base, ...overlay };
// after (guard non-JSON values)
const merged = { ...base, ...cloneJsonValue(overlay) };
Defensive patterns

Strategy: type-guard

Validate before calling

function isJsonSerializable(v: unknown): boolean {
  return v !== undefined && typeof v !== "function" && typeof v !== "symbol";
}
if (!isJsonSerializable(merged)) throw new Error("merged config must be a JSON value before stringify");

Type guard

function isPlainJsonValue(v: unknown): v is string | number | boolean | null | object {
  if (v === null) return true;
  const t = typeof v;
  return t === "string" || t === "number" || t === "boolean" || (t === "object" && !Array.isArray(v) === true || Array.isArray(v));
}

Try / catch

try {
  wrapAgent(agent, env);
} catch (e) {
  if ((e as Error).message === "merged config is not JSON") {
    // invariant violation: inspect base/overlay inputs for non-JSON values
  }
}

Prevention

When it happens

Trigger: The merged config object is not JSON-serializable — practically only if the merge logic changes to return undefined (e.g. merging undefined into a plain object path or a code regression), since profile values pass through cloneJsonValue.

Common situations: Almost exclusively hit during development/modification of the wrapper itself; end users should not see it unless running a patched or corrupted CLI build.

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/15984c828a064c50. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:9157

      rawOverlay = deepMerge(rawOverlay, {
        mcp: { excluded: [...new Set([...preserved, "caveman"])] },
      });
    }
  }
  const overlay = (mcpMode === "auto" ? rawOverlay : withoutCavemanMcpServer(rawOverlay as JsonObject)) as JsonObject;
  const merged = deepMerge(baseConfig, overlay) as JsonObject;
  if (agent.id === "qwen") {
    // Qwen treats these as whole-object REPLACE settings. Our generic merge is
    // intentionally conservative for other agents, but retaining a sibling
    // provider here would make it selectable at runtime outside /w/qwen.
    for (const key of ["modelProviders", "providerProtocol"] as const) {
      const value = overlay[key];
      if (value === undefined) throw new Error(`Qwen routed profile is missing ${key}`);
      merged[key] = cloneJsonValue(value);
    }
  }
  const rendered = JSON.stringify(merged, null, 2);
  if (rendered === undefined) throw new Error("merged config is not JSON");
  const outDir = mkdtempSync(join(tmpdir(), "caveman-wrap-"));
  const outPath = join(outDir, `${agent.id}.json`);
  writeFileSync(outPath, rendered + "\n", { mode: 0o600 });
  wrapTempDirs.add(outDir);
  env[inj.env_var] = outPath;
}

function cleanupWrapTempDirs() {
  for (const dir of [...wrapTempDirs]) {
    try {
      rmSync(dir, { recursive: true, force: true });
    } catch {
      // Temp cleanup is best-effort; wrap must never fail after child exit.
    } finally {
      wrapTempDirs.delete(dir);
    }
  }
}

View on GitHub (pinned to 3ee70a1026)