From e93078ca694dcfaadeb72819552a98274871fe6b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:40:13 +0200 Subject: [PATCH 1/3] fix(codex): defer Cursor code-mode tool catalog --- src/codex/catalog/parsing.ts | 9 ++++----- src/codex/catalog/sync.ts | 9 ++++----- structure/03_catalog-and-subagents.md | 22 ++++++++++++---------- tests/catalog-cursor-search.test.ts | 10 ++++++---- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 11f94b1e7e..7775f0af89 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -462,16 +462,15 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = // 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. + // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So every routed + // code-mode row advertises deferred discovery. Cursor still omits hosted web-search metadata below, + // but disabling this separate exposure bit can inflate `exec` past Cursor's 120 KB wire cap (#1830). if (isCursorEntry) { delete entry.web_search_tool_type; } else { entry.web_search_tool_type = "text_and_image"; } - entry.supports_search_tool = !isCursorEntry; + entry.supports_search_tool = true; // 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 a5d22ca56f..1275dbe2e6 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -358,10 +358,9 @@ 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. + // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog + // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). + // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar. const isCursorFallback = isRouted && model?.provider === "cursor"; const entry: RawEntry = { slug, display_name: routedDisplayName(slug), description: desc, @@ -369,7 +368,7 @@ export function deriveEntry( priority, base_instructions: "You are a helpful coding assistant.", ...(isRouted ? isCursorFallback - ? { supports_search_tool: false } + ? { supports_search_tool: true } : { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), }; diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 8de3610b33..fe347602d4 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -209,24 +209,26 @@ Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent ## Routed tool discovery and hosted search -Non-Cursor routed catalog rows advertise `supports_search_tool: true` together with +All 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. +turn-1 payload regression (96,699 → 258,929 chars). For Cursor this can also make the unified +`exec` exceed the 120,000-byte serialized `McpTools` ceiling; the budget then drops `exec` and +its companion `wait` (#1830). Hosted search remains independent: non-Cursor routes keep +`web_search_tool_type: "text_and_image"`, while Cursor omits it because runTurn bypasses the +search sidecar. [Decision Log] -- 목적과 의도: 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. +- 목적과 의도: keep routed plugin/MCP tools reachable without paying the full-catalog turn-1 payload tax or starving Cursor's unified execution bridge. +- 기존 구현 및 제약 조건: #1596 restored deferred discovery only for non-Cursor rows because Cursor bypasses the hosted-search sidecar; codex-rs treats deferred exposure and hosted search as separate capabilities, and Cursor independently enforces a 120,000-byte serialized tool-catalog limit. +- 검토한 주요 대안: keep Cursor opted out, raise/disable Cursor's transport ceiling, synthesize another execution bridge, or enable Cursor-native local exec only when the bridge disappears. +- 선택한 방식: enable Codex deferred exposure for Cursor code-mode rows too, while continuing to omit Cursor's hosted `web_search_tool_type`. +- 다른 대안 대신 이 방식을 선택한 이유: it removes the known exec-description inflation before Cursor budgeting without weakening the measured transport limit, inventing caller tools, or turning bridge absence into local-execution authority. +- 장점, 단점 및 영향: Cursor keeps a compact Responses-owned `exec` path under rich tool catalogs and hosted-search behavior remains unchanged; the existing Cursor budget and native-local-exec fail-closed policy remain authoritative. ## Ultra reasoning level diff --git a/tests/catalog-cursor-search.test.ts b/tests/catalog-cursor-search.test.ts index ba0b746261..8016770df6 100644 --- a/tests/catalog-cursor-search.test.ts +++ b/tests/catalog-cursor-search.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test"; 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)", () => { + test("cursor entries enable deferred discovery without advertising hosted web search", () => { const entry = normalizeRoutedCatalogEntry({ slug: "cursor/auto" } as never) as Record; - expect(entry.supports_search_tool).toBe(false); + expect(entry.tool_mode).toBe("code_mode_only"); + expect(entry.supports_search_tool).toBe(true); expect(entry.web_search_tool_type).toBeUndefined(); expect(entry.supports_parallel_tool_calls).toBe(true); }); @@ -37,13 +38,14 @@ describe("routed catalog search advertising", () => { 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)", () => { + test("cursor template-less fallback rows keep deferred discovery without 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?.tool_mode).toBe("code_mode_only"); + expect(routed?.supports_search_tool).toBe(true); expect(routed?.web_search_tool_type).toBeUndefined(); }); }); From 4424138a3e2a357dfd4b2c77dd84d498dfff53b8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:54:57 +0200 Subject: [PATCH 2/3] test(codex): cover inherited Cursor search metadata --- tests/catalog-cursor-search.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/catalog-cursor-search.test.ts b/tests/catalog-cursor-search.test.ts index 8016770df6..72990c3bc2 100644 --- a/tests/catalog-cursor-search.test.ts +++ b/tests/catalog-cursor-search.test.ts @@ -3,7 +3,10 @@ import { buildCatalogEntries, normalizeRoutedCatalogEntry } from "../src/codex/c describe("routed catalog search advertising", () => { test("cursor entries enable deferred discovery without advertising hosted web search", () => { - const entry = normalizeRoutedCatalogEntry({ slug: "cursor/auto" } as never) as Record; + const entry = normalizeRoutedCatalogEntry({ + slug: "cursor/auto", + web_search_tool_type: "text_and_image", + } as never) as Record; expect(entry.tool_mode).toBe("code_mode_only"); expect(entry.supports_search_tool).toBe(true); expect(entry.web_search_tool_type).toBeUndefined(); From 19186815f581d2a53ec4f7fe6e52f23d83454725 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 01:01:02 +0900 Subject: [PATCH 3/3] test(cursor): prove the wire-budget consequence behind the search flag #1832 flips `supports_search_tool` for Cursor on the reasoning that deferred discovery keeps the MCP catalog out of `exec.description`, and that inlining it can push the advertised catalog past Cursor's 120KB serialized limit. The catalog-projection tests assert the flag; nothing asserted the consequence. These measure it on the real serializer. A deferred catalog stays inside `CURSOR_TOOL_BYTES_LIMIT` and keeps both `exec` and `wait`; the same two tools exceed the cap once the nested catalog is inlined into `exec.description`. The second test pins #1830's actual symptom -- a child whose advertised catalog contains no Responses execution tool at all. Whatever else the budget drops, `exec` and `wait` must be what survives, and the trimmed result must fit. Note on scope: the flag lives in routed catalog metadata consumed by the Codex client, so it is not serialized into a Cursor turn request. Asserting it inside a protobuf test would prove nothing, which is why the two concerns stay in two files. --- tests/cursor-request-builder.test.ts | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index efec3468b1..be977c2b90 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -455,6 +455,73 @@ describe("Cursor request builder", () => { expect(budget.tools.length).toBeLessThanOrEqual(CURSOR_TOOL_COUNT_LIMIT); }); + + test("a deferred Cursor catalog stays inside the wire budget while a nested one does not (#1830)", () => { + // Why #1832 flips supports_search_tool for Cursor: with deferred discovery OFF, Codex + // inlines the whole MCP catalog into `exec.description` instead of leaving it callable + // through tool_search. This asserts the consequence in bytes, on the real serializer, + // rather than trusting the flag alone. + const nestedCatalogText = Array.from({ length: 120 }, (_, index) => + `mcp__server_${index}__tool: ${"d".repeat(1_200)}`).join("\n"); + + const execDeferred = { + name: "exec", + namespace: "opencodex-responses", + description: "Run JavaScript. Discover tools with tool_search.", + parameters: { type: "object", properties: { input: { type: "string" } } }, + freeform: true, + }; + const execInlined = { ...execDeferred, description: `${execDeferred.description}\n${nestedCatalogText}` }; + const wait = { + name: "wait", + namespace: "opencodex-responses", + description: "Resume a running call", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + + // Deferred: the advertised catalog serializes well inside the cap and keeps both tools. + expect(cursorMcpToolsEncodedSize([execDeferred, wait], "auto")).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + const deferred = applyCursorToolBudget([execDeferred, wait], "auto"); + expect(deferred.tools).toContain(execDeferred); + expect(deferred.tools).toContain(wait); + expect(deferred.omitted).toHaveLength(0); + + // Inlined: the same two tools blow the cap purely because the catalog moved into exec. + expect(cursorMcpToolsEncodedSize([execInlined, wait], "auto")).toBeGreaterThan(CURSOR_TOOL_BYTES_LIMIT); + }); + + test("the Responses execution bridge survives the budget even when it must be trimmed (#1830)", () => { + // #1830's symptom is a child whose advertised catalog has no Responses execution tool at + // all. Whatever else the budget drops, exec and wait have to be what is left. + const filler = Array.from({ length: 60 }, (_, index) => ({ + name: `mcp_tool_${index}`, + namespace: `mcp__server_${index}`, + description: "z".repeat(4_000), + parameters: { type: "object", properties: {} }, + })); + const exec = { + name: "exec", + namespace: "opencodex-responses", + description: "Run JavaScript", + parameters: { type: "object", properties: { input: { type: "string" } } }, + freeform: true, + }; + const wait = { + name: "wait", + namespace: "opencodex-responses", + description: "Resume", + parameters: { type: "object", properties: { id: { type: "string" } } }, + }; + + const catalog = [...filler, exec, wait]; + expect(cursorMcpToolsEncodedSize(catalog, "auto")).toBeGreaterThan(CURSOR_TOOL_BYTES_LIMIT); + + const budget = applyCursorToolBudget(catalog, "auto"); + expect(budget.tools).toContain(exec); + expect(budget.tools).toContain(wait); + expect(cursorMcpToolsEncodedSize(budget.tools, "auto")).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + expect(budget.omitted.length).toBeGreaterThan(0); + }); test("pins namespaced opencodex-responses exec ahead of filler", () => { const filler = Array.from({ length: 80 }, (_, index) => ({ name: `filler_${index}`,