Skip to content

Respect model_catalog_json for explicit subagent routing #120

Description

@davidrobertson

Problem Situation

GPT-5.6 MultiAgentV2 must hide agent_type/model/reasoning_effort to match the server-reserved spawn schema. Users who opt into Codex model_catalog_json to make Sol/Terra use V1 can restore explicit child routing, but LazyCodex reads models_cache.json instead and removes the required V1 settings at install and SessionStart.

Reproduction Logs

With hide_spawn_agent_metadata=false on V2, codex exec fails with HTTP 400: Function collaboration.spawn_agent is reserved for use by this model and must match the configured schema. With a custom V1 catalog, the child correctly ran as explorer on gpt-5.4-mini/low, but current LazyCodex migration deleted enabled=false, hide_spawn_agent_metadata=false, and agents.max_threads because models_cache.json still said v2.

Root Cause

Both the installer resolver and SessionStart migration ignore the top-level model_catalog_json path. Codex documents that catalog as a complete replacement for the process, so LazyCodex and Codex resolve different multi-agent versions.

Verified Fix

Read model_catalog_json first in both resolution paths, falling back to models_cache.json. This preserves the existing GPT-5.6 V2 hotfix for users without an explicit replacement catalog.

diff --git a/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs b/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
index 67ce8632..6042f87d 100644
--- a/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
+++ b/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
@@ -98,7 +98,8 @@ export function prefersMultiAgentV2(multiAgentVersion, sessionModel) {
 export function resolveMultiAgentVersionFromConfig(config, options = {}) {
 	const model = normalizeModel(options.sessionModel) || readRootModel(config);
 	if (!model) return null;
-	return resolveMultiAgentVersionForModel(model, options);
+	const modelsCachePath = options.modelsCachePath?.trim() || readRootModelCatalogPath(config) || undefined;
+	return resolveMultiAgentVersionForModel(model, { ...options, modelsCachePath });
 }
 
 /**
@@ -130,6 +131,13 @@ export function readRootModel(config) {
 	return single?.[1] ?? null;
 }
 
+function readRootModelCatalogPath(config) {
+	const double = config.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
+	if (double) return double[1];
+	const single = config.match(/^\s*model_catalog_json\s*=\s*'([^']+)'/m);
+	return single?.[1] ?? null;
+}
+
 function normalizeModel(value) {
 	if (typeof value !== "string") return null;
 	const trimmed = value.trim();
diff --git a/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs b/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
index dbe5d067..4d75477d 100644
--- a/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
+++ b/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
@@ -951,6 +951,51 @@ test("#given gpt-5.6-terra managed disable #when full migration sees models_cach
 	assert.match(content, /max_concurrent_threads_per_session = 1000/);
 });
 
+test("#given custom catalog selects v1 #when models cache says v2 #then migration preserves v1 routing settings", async (t) => {
+	const root = await mkdtemp(join(tmpdir(), "lazycodex-custom-model-catalog-"));
+	t.after(() => rm(root, { recursive: true, force: true }));
+	const codexHome = join(root, "codex-home");
+	await mkdir(codexHome, { recursive: true });
+	const customCatalogPath = join(root, "models.json");
+	await writeFile(
+		customCatalogPath,
+		JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v1" }] }),
+	);
+	const configPath = join(codexHome, "config.toml");
+	await writeFile(
+		configPath,
+		[
+			'model = "gpt-5.6-sol"',
+			`model_catalog_json = "${customCatalogPath}"`,
+			"",
+			"[agents]",
+			"max_threads = 1000",
+			"max_depth = 2",
+			"",
+			"[features.multi_agent_v2]",
+			"enabled = false",
+			"hide_spawn_agent_metadata = false",
+			"max_concurrent_threads_per_session = 1000",
+			"",
+		].join("\n"),
+	);
+	await writeFile(
+		join(codexHome, "models_cache.json"),
+		JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v2" }] }),
+	);
+
+	await migrateCodexConfig({
+		env: { CODEX_HOME: codexHome, LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json") },
+		cwd: root,
+	});
+
+	const content = await readFile(configPath, "utf8");
+	assert.match(content, /^enabled = false$/m);
+	assert.match(content, /^hide_spawn_agent_metadata = false$/m);
+	assert.match(content, /^max_threads = 1000$/m);
+	assert.match(content, /max_depth = 2/);
+});
+
 test("#given config default gpt-5.5 #when SessionStart model is gpt-5.6-terra #then prefers session model and clears disable", () => {
 	const config = [
 		'model = "gpt-5.5"',
diff --git a/packages/omo-codex/src/install/codex-config-toml.test.ts b/packages/omo-codex/src/install/codex-config-toml.test.ts
index e0f0a43d..17b04018 100644
--- a/packages/omo-codex/src/install/codex-config-toml.test.ts
+++ b/packages/omo-codex/src/install/codex-config-toml.test.ts
@@ -487,6 +487,47 @@ describe("codex-config-toml", () => {
     expect(content).toContain("max_concurrent_threads_per_session = 1000")
   })
 
+  test("#given custom catalog selects v1 #when models cache says v2 #then installer keeps v1 routing", async () => {
+    // given
+    const root = await mkdtemp(join(tmpdir(), "omo-codex-config-custom-catalog-v1-"))
+    const customCatalogPath = join(root, "models.json")
+    await writeFile(
+      customCatalogPath,
+      JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v1" }] }),
+    )
+    const configPath = join(root, "config.toml")
+    await writeFile(
+      configPath,
+      [
+        'model = "gpt-5.6-sol"',
+        `model_catalog_json = "${customCatalogPath}"`,
+        "",
+        "[features]",
+        "multi_agent_v2 = false",
+        "",
+      ].join("\n"),
+    )
+    await writeFile(
+      join(root, "models_cache.json"),
+      JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v2" }] }),
+    )
+
+    // when
+    await updateCodexConfig({
+      configPath,
+      repoRoot: "/repo/packages/omo-codex",
+      marketplaceName: "debug",
+      marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" },
+      pluginNames: ["omo"],
+    })
+
+    // then
+    const content = await readFile(configPath, "utf8")
+    expect(content).toContain("max_threads = 1000")
+    expect(content).toMatch(/^enabled = false$/m)
+    expect(content).toContain("max_concurrent_threads_per_session = 1000")
+  })
+
   test("#given gpt-5.6 family model without models_cache #when updating config #then treats it as V2-preferred", async () => {
     // given
     const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-v2-nocache-"))
diff --git a/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts b/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
index 4d4fd32f..6c5ea139 100644
--- a/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
+++ b/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
@@ -63,15 +63,16 @@ export function ensureCodexMultiAgentV2Config(
 }
 
 /**
- * Resolve the configured root model's multi-agent version from the Codex
- * model catalog cache (`models_cache.json` next to `config.toml`).
+ * Resolve the configured root model's multi-agent version from Codex's
+ * configured replacement catalog, falling back to `models_cache.json`.
  * Mirrors `plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs`:
  * catalog wins; a GPT-5.6 family model with no catalog entry counts as V2.
  */
 export function resolveCodexMultiAgentVersion(config: string, configPath: string): CodexMultiAgentVersion {
   const model = readRootModel(config)
   if (model === null) return null
-  const catalogVersion = readCatalogMultiAgentVersion(model, join(dirname(configPath), "models_cache.json"))
+  const catalogPath = readRootString(config, "model_catalog_json") ?? join(dirname(configPath), "models_cache.json")
+  const catalogVersion = readCatalogMultiAgentVersion(model, catalogPath)
   if (catalogVersion !== null) return catalogVersion
   return /^gpt-5\.6\b/i.test(model) ? "v2" : null
 }
@@ -101,9 +102,14 @@ function readCatalogMultiAgentVersion(model: string, cachePath: string): CodexMu
 }
 
 function readRootModel(config: string): string | null {
-  const double = config.match(/^\s*model\s*=\s*"([^"]+)"/m)
+  return readRootString(config, "model")
+}
+
+function readRootString(config: string, key: string): string | null {
+  const escapedKey = escapeRegExp(key)
+  const double = config.match(new RegExp(`^\\s*${escapedKey}\\s*=\\s*"([^"]+)"`, "m"))
   if (double !== null) return double[1] ?? null
-  const single = config.match(/^\s*model\s*=\s*'([^']+)'/m)
+  const single = config.match(new RegExp(`^\\s*${escapedKey}\\s*=\\s*'([^']+)'`, "m"))
   return single?.[1] ?? null
 }
 

Verification

  • RED: both new tests removed the V1 routing settings before the fix.
  • GREEN: node migration suite 50/50; Bun installer suite 25/25; omo-codex typecheck passed.
  • Manual QA: fresh gpt-5.6-sol parent resolved v1 and spawned agent_type=explorer on gpt-5.4-mini with reasoning_effort=low; parent and child returned the expected completion tokens.
  • Isolated Codex QA: local install verification passed; app-server turn completed and sessionStart/userPromptSubmit/stop hooks fired; real ~/.codex/config.toml remained unchanged.

This fix was debugged, implemented, and verified with LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions