Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,18 +358,17 @@ 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,
shell_type: "shell_command", visibility: "list", supported_in_api: true,
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 }
: {}),
};
Expand Down
22 changes: 12 additions & 10 deletions structure/03_catalog-and-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions tests/catalog-cursor-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ 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)", () => {
const entry = normalizeRoutedCatalogEntry({ slug: "cursor/auto" } as never) as Record<string, unknown>;
expect(entry.supports_search_tool).toBe(false);
test("cursor entries enable deferred discovery without advertising hosted web search", () => {
const entry = normalizeRoutedCatalogEntry({
slug: "cursor/auto",
web_search_tool_type: "text_and_image",
} as never) as Record<string, unknown>;
expect(entry.tool_mode).toBe("code_mode_only");
expect(entry.supports_search_tool).toBe(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(entry.web_search_tool_type).toBeUndefined();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(entry.supports_parallel_tool_calls).toBe(true);
});
Expand Down Expand Up @@ -37,13 +41,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<Record<string, unknown>>;
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();
});
});
67 changes: 67 additions & 0 deletions tests/cursor-request-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
Loading