diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 5b1797788e..442d1378dc 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -394,17 +394,21 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = delete entry.supports_reasoning_summaries; const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/"); // `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted - // web-search capability. OpenCodex can round-trip tool_search when a client sends it, but routed - // providers have no provider/model proof that Codex App plugins work through that deferred - // surface. Advertising it unconditionally hides the App's compatible direct MCP tools (#1522), - // so routed rows fail closed to direct discovery. The sidecar-backed hosted web-search metadata - // remains advertised independently for non-Cursor routes. + // web-search capability. Routed rows also carry tool_mode=code_mode_only (below), and under code + // mode DEFERRED MCP tools remain callable through exec's `tools` global / ALL_TOOLS without any + // tool_search round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed + // kimi/k3 called tools.mcp__node_repl__js → isError:false). Stamping false here instead forces + // every MCP declaration into exec.description — a measured 2.7x turn-1 payload regression + // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So non-Cursor routed + // rows advertise deferred discovery; the #1522 reachability concern is covered by the code-mode + // path, not by paying the full-catalog tax. Cursor stays false: its runTurn transport bypasses + // the web-search sidecar and has no proven deferred path. if (isCursorEntry) { delete entry.web_search_tool_type; } else { entry.web_search_tool_type = "text_and_image"; } - entry.supports_search_tool = false; + entry.supports_search_tool = !isCursorEntry; // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events. // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI. // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 751e4296ef..5afa06c106 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -310,11 +310,20 @@ export function deriveEntry( }); } // Fallback when no template is available (best-effort; strict parser may need more). + // Cursor fallback rows mirror normalizeRoutedCatalogEntry: no deferred discovery, no hosted + // web-search metadata (runTurn transport bypasses the sidecar). Non-Cursor routed fallbacks + // advertise deferred discovery — code mode keeps deferred MCP callable (devlog + // 260813_tool_catalog_deferral/010+020); search=false costs a measured 2.7x turn-1 payload. + const isCursorFallback = isRouted && model?.provider === "cursor"; const entry: RawEntry = { slug, display_name: routedDisplayName(slug), description: desc, shell_type: "shell_command", visibility: "list", supported_in_api: true, priority, base_instructions: "You are a helpful coding assistant.", - ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: false } : {}), + ...(isRouted + ? isCursorFallback + ? { supports_search_tool: false } + : { web_search_tool_type: "text_and_image", supports_search_tool: true } + : {}), }; if (isRouted) { applyRoutedCodexToolMode(entry); diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index f2d63bafcd..54bfaaf1cb 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -149,21 +149,24 @@ the flag and thread count decide what the native runtime allows. ## Routed tool discovery and hosted search -Routed catalog rows advertise `supports_search_tool: false` by default. That field selects Codex's -deferred tool-discovery surface; it does not describe the hosted web-search sidecar. OpenCodex still -round-trips an explicit `tool_search` request, but it does not claim that every routed provider/model -can discover Codex App plugins through that surface. The conservative catalog value keeps direct MCP -tools visible in Codex App. Non-Cursor routed rows independently keep -`web_search_tool_type: "text_and_image"` for the OpenCodex search sidecar; Cursor advertises neither -because its transport bypasses that sidecar. +Non-Cursor routed catalog rows advertise `supports_search_tool: true` together with +`tool_mode: "code_mode_only"` — the pair is load-bearing. The field selects Codex's deferred +tool-discovery surface; it does not describe the hosted web-search sidecar. Under code mode, +deferred MCP tools remain callable through exec's `tools` global / `ALL_TOOLS` without a +`tool_search` round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed +kimi/k3 executed `tools.mcp__node_repl__js`, devlog `260813_tool_catalog_deferral/010+020`). +Stamping `false` instead forces every MCP declaration into `exec.description` — a measured 2.7x +turn-1 payload regression (96,699 → 258,929 chars). Non-Cursor routed rows independently keep +`web_search_tool_type: "text_and_image"` for the OpenCodex search sidecar; Cursor advertises +neither flag because its runTurn transport bypasses that sidecar and has no proven deferred path. [Decision Log] -- 목적과 의도: routed models must not hide direct Codex App plugin tools behind an unverified deferred discovery capability. -- 기존 구현 및 제약 조건: every non-Cursor row advertised `supports_search_tool: true`; the parser and bridge can still relay explicit `tool_search` calls. -- 검토한 주요 대안: keep the blanket flag, disable both deferred discovery and hosted search, or add a future evidence-backed provider/model opt-in. -- 선택한 방식: default routed deferred discovery to false while preserving the independent non-Cursor hosted-search metadata. -- 다른 대안 대신 이 방식을 선택한 이유: it fixes plugin availability without removing the existing web-search sidecar or deleting runtime protocol support. -- 장점, 단점 및 영향: direct MCP tools remain available; a routed model cannot use Codex's deferred discovery solely from generated catalog metadata until a verified opt-in exists. +- 목적과 의도: keep routed plugin/MCP tools reachable without paying the full-catalog turn-1 payload tax. +- 기존 구현 및 제약 조건: #1529 stamped `supports_search_tool: false` on all routed rows to fix #1522-era plugin invisibility; routed rows already carry `tool_mode: code_mode_only` (f60dd981d), and codex-rs keeps Deferred-exposure tools callable inside the exec isolate. +- 검토한 주요 대안: keep the blanket false (2.7x payload regression), per-provider opt-in flags, or hybrid `direct_only_tool_namespaces` allowlists. +- 선택한 방식: non-Cursor routed rows advertise deferred discovery again, paired with code-mode-only; Cursor stays opted out; a dual-seam regression test pins the pair on both the template and the template-less fallback paths. +- 다른 대안 대신 이 방식을 선택한 이유: WP2 measurement (devlog `260813_tool_catalog_deferral/010`) showed the search=true code-mode profile is the cheapest shape (~97K vs ~259K chars turn-1), and the live canary showed reachability rides the code-mode isolate, not the tool_search round-trip — so the fail-closed flag paid the tax without buying the safety. +- 장점, 단점 및 영향: turn-1 payload stays at the measured minimum and deferred tools stay reachable; residual risk is model compliance (a weak routed model may not use `exec` well) — the mechanism itself is client-side and model-independent. #1522's exact DeepSeek-compatible pairing remains unverified on this machine and is documented in the PR. ## Ultra reasoning level diff --git a/tests/catalog-cursor-search.test.ts b/tests/catalog-cursor-search.test.ts index c3d554f2f8..ba0b746261 100644 --- a/tests/catalog-cursor-search.test.ts +++ b/tests/catalog-cursor-search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { normalizeRoutedCatalogEntry } from "../src/codex/catalog"; +import { buildCatalogEntries, normalizeRoutedCatalogEntry } from "../src/codex/catalog"; describe("routed catalog search advertising", () => { test("cursor entries do not advertise the hosted search tool (runTurn bypasses the sidecar)", () => { @@ -9,10 +9,41 @@ describe("routed catalog search advertising", () => { expect(entry.supports_parallel_tool_calls).toBe(true); }); - test("non-cursor routed entries keep hosted web search without opting into deferred discovery", () => { + test("non-cursor routed entries advertise deferred discovery alongside hosted web search", () => { const entry = normalizeRoutedCatalogEntry({ slug: "opencode-go/glm-5.2" } as never) as Record; - expect(entry.supports_search_tool).toBe(false); + expect(entry.supports_search_tool).toBe(true); expect(entry.web_search_tool_type).toBe("text_and_image"); expect(entry.supports_parallel_tool_calls).toBe(false); }); + + // Pair fence: code_mode_only + supports_search_tool=true must move together. Deferral is only + // safe BECAUSE code mode keeps deferred MCP callable via exec/ALL_TOOLS (live canary 2026-08-13), + // and search=false under code mode inflates exec.description 17K → 176K chars — a measured 2.7x + // turn-1 payload regression (devlog/_plan/260813_tool_catalog_deferral/010). Neither field may + // regress independently. + test("non-cursor routed entries pin the code-mode + deferred-discovery pair (template path)", () => { + const entry = normalizeRoutedCatalogEntry({ slug: "opencode-go/glm-5.2" } as never) as Record; + expect(entry.tool_mode).toBe("code_mode_only"); + expect(entry.supports_search_tool).toBe(true); + }); + + test("template-less fallback rows pin the same pair (deriveEntry path)", () => { + const entries = buildCatalogEntries(null, [], [ + { provider: "local", id: "qwen3-coder" }, + ]) as Array>; + const routed = entries.find(e => e.slug === "local/qwen3-coder"); + expect(routed?.tool_mode).toBe("code_mode_only"); + expect(routed?.supports_search_tool).toBe(true); + expect(routed?.web_search_tool_type).toBe("text_and_image"); + }); + + test("cursor template-less fallback rows stay opted out (no deferred discovery, no hosted search)", () => { + const entries = buildCatalogEntries(null, [], [ + { provider: "cursor", id: "auto" }, + ]) as Array>; + const routed = entries.find(e => typeof e.slug === "string" && (e.slug as string).startsWith("cursor/")); + expect(routed).toBeDefined(); + expect(routed?.supports_search_tool).toBe(false); + expect(routed?.web_search_tool_type).toBeUndefined(); + }); }); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 5c4d00c59f..d3e0c431d5 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -325,7 +325,7 @@ describe("combo catalog capability intersection", () => { expect(row).not.toHaveProperty("model_messages"); expect(row.tool_mode).toBe("code_mode_only"); expect(row.web_search_tool_type).toBe("text_and_image"); - expect(row.supports_search_tool).toBe(false); + expect(row.supports_search_tool).toBe(true); } }); @@ -2246,7 +2246,7 @@ describe("Codex catalog routed normalization", () => { expect(entry).not.toHaveProperty("service_tiers"); expect(entry).not.toHaveProperty("default_service_tier"); expect(entry.web_search_tool_type).toBe("text_and_image"); - expect(entry.supports_search_tool).toBe(false); + expect(entry.supports_search_tool).toBe(true); }); test("buildCatalogEntries strips routed entries cloned from native templates", () => { @@ -2268,7 +2268,7 @@ describe("Codex catalog routed normalization", () => { expect(routed).not.toHaveProperty("service_tiers"); expect(routed).not.toHaveProperty("default_service_tier"); expect(routed?.web_search_tool_type).toBe("text_and_image"); - expect(routed?.supports_search_tool).toBe(false); + expect(routed?.supports_search_tool).toBe(true); expect(routed?.supports_reasoning_summaries).toBe(false); expect(routed?.base_instructions).not.toBe(nativeTemplate().base_instructions); expect(routed?.base_instructions).toContain("claude-sonnet-4-6"); @@ -2723,14 +2723,14 @@ describe("Codex catalog routed normalization", () => { expect(off.find(e => e.slug === "anthropic/claude-sonnet-4-6")).not.toHaveProperty("supports_websockets"); }); - test("fallback routed entries keep hosted search metadata but use direct MCP discovery", () => { + test("fallback routed entries keep hosted search metadata and deferred discovery", () => { const entries = buildCatalogEntries(null, [], [ { provider: "local", id: "qwen3-coder" }, ]); const routed = entries.find(e => e.slug === "local/qwen3-coder"); expect(routed?.web_search_tool_type).toBe("text_and_image"); - expect(routed?.supports_search_tool).toBe(false); + expect(routed?.supports_search_tool).toBe(true); }); test("liveModels false uses configured provider models without fetching", async () => { diff --git a/tests/e2e-style/phase100-native-parity.test.ts b/tests/e2e-style/phase100-native-parity.test.ts index 97c80b434a..fa60965738 100644 --- a/tests/e2e-style/phase100-native-parity.test.ts +++ b/tests/e2e-style/phase100-native-parity.test.ts @@ -81,7 +81,7 @@ describe("Phase 100 Codex-native parity smoke", () => { const routed = catalog.find(entry => entry.slug === "opencode-go/deepseek-v4-pro"); expect(routed).toMatchObject({ web_search_tool_type: "text_and_image", - supports_search_tool: false, + supports_search_tool: true, context_window: 1_000_000, auto_compact_token_limit: 900_000, });