diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 057399dacf..9ffdd3b527 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -615,7 +615,7 @@ export function buildCursorToolGuidanceSystemNote( // Code mode: shell/edit/MCP live inside freeform `exec` as nested helpers. Without this the // model probes for a top-level shell tool that is not there. codeMode - ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description for the exact nested helpers this turn provides. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\`, \`shell_command\`, or \`apply_patch\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.` + ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description and the isolate global \`ALL_TOOLS\` (not \`tools.ALL_TOOLS\`) for helpers this turn provides; absence from the top-level catalog or from \`exec\`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\` or \`shell_command\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.` : undefined, codeMode ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers." diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 905f3e0835..6d47774a9f 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -16,9 +16,10 @@ import { // `python3` heredoc edits. The sibling list in `./cursor/tool-definitions.ts` never // included it either. const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const; +const CODEX_CODE_MODE_EXEC_TOOL = "exec"; function quoteNames(names: readonly string[]): string { - return names.map(name => `\`${name}\``).join(", "); + return names.map(name => "`" + name + "`").join(", "); } function uniqueNames(names: readonly string[]): string[] { @@ -40,6 +41,16 @@ export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick, + toWireName: (name: string) => string, +): string | undefined { + const wireName = toWireName(CODEX_CODE_MODE_EXEC_TOOL); + if (advertised.has(wireName)) return wireName; + if (advertised.has(CODEX_CODE_MODE_EXEC_TOOL)) return CODEX_CODE_MODE_EXEC_TOOL; + return undefined; +} + export function buildNonOpenAIToolCatalogNudgeFromNames( wireNames: readonly string[] | undefined, toWireName: (name: string) => string = name => name, @@ -50,21 +61,24 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( const advertised = new Set(names); // Compare in the catalog's own coordinate system. `advertised` holds WIRE names, so a // provider that rewrites them (Claude OAuth `custom_`, Anthropic compat `cx_`) would never - // match a bare neighbor name and would forbid tools the turn actually advertises — the + // match a bare neighbor name and would forbid tools the turn actually advertises -- the // catalog would list `custom_apply_patch` while the same sentence banned `apply_patch`. const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter( name => !advertised.has(name) && !advertised.has(toWireName(name)), ); + const codeModeExecName = advertisedCodeModeExecName(advertised, toWireName); return [ "Tool contract: use the current tool catalog as ground truth.", - `Valid tool names for this turn are exactly ${quoteNames(names)}.`, + "Valid tool names for this turn are exactly " + quoteNames(names) + ".", "These listed names are the complete top-level tool-call surface for this turn.", "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.", "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.", - "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.", + codeModeExecName + ? "If `" + codeModeExecName + "` is listed, it is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + codeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names." + : "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.", unavailableNeighborNames.length > 0 - ? `Do not use neighboring-agent tool names ${quoteNames(unavailableNeighborNames)} unless this turn's catalog lists those exact names.` + ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names." : undefined, "If you need shell, file search, file read, edit, or discovery behavior, choose the listed tool that provides that capability.", "Count a tool call only after its tool result returns; batch independent read-only calls when the runtime supports it.", diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 45c674219b..0afd6a0b16 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -447,6 +447,9 @@ describe("Cursor code mode tool guidance", () => { expect(note).toContain("await tools.exec_command({cmd: " + "\"" + "ls" + "\"" + "})"); expect(note).toContain("text(...)"); expect(note).toContain("There is no `require`"); + expect(note).toContain("isolate global `ALL_TOOLS`"); + expect(note).toContain("not `tools.ALL_TOOLS`"); + expect(note).toContain("absence from the top-level catalog"); // The flat-catalog shell-bridge guidance must NOT appear: naming a top-level // `exec_command` in code mode sends the model after a tool that does not exist. @@ -455,6 +458,21 @@ describe("Cursor code mode tool guidance", () => { expect(note).not.toContain("For file read/search/listing, use"); }); + test("does not forbid a separately listed apply_patch in code mode", () => { + const note = buildCursorToolGuidanceSystemNote([ + codeModeExec(), + { name: "apply_patch", description: "Apply a patch", parameters: {}, freeform: true }, + ]); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + + expect(note).toContain("is Codex code mode"); + expect(note).toContain("remains callable at the top level as usual"); + expect(note).toContain("`apply_patch`"); + expect(note).not.toContain("do not call `exec_command`, `shell_command`, or `apply_patch` at the top level here"); + expect(note).toContain("do not call `exec_command` or `shell_command` at the top level here"); + }); + test("keeps other visible top-level tools callable in code mode", () => { // Code mode is about how `exec` works, not a claim that the rest of the catalog is nested. // A turn can advertise freeform `exec` alongside ordinary top-level tools, and describing diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 09a4b61a1c..89189ff287 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -55,10 +55,35 @@ describe("non-OpenAI tool catalog nudge", () => { expect(note).toContain("Valid tool names for this turn are exactly `exec`, `wait`, `request_user_input`"); expect(note).toContain("complete top-level tool-call surface"); expect(note).toContain("nested helper APIs are not additional top-level tools"); - expect(note).toContain("call the listed parent tool"); + expect(note).toContain("If `exec` is listed, it is Codex code mode"); + expect(note).toContain("await tools.(...)"); + expect(note).toContain("await tools.codex_app__list_threads({})"); + expect(note).toContain("isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`"); + expect(note).toContain("Do not skip an available nested helper"); + expect(note).not.toContain("call the listed parent tool and use those helpers only inside that tool's input"); expect(note).not.toContain("apply_patch"); }); + test("keeps the generic nested-helper parent-tool rule when exec is not listed", () => { + const note = buildNonOpenAIToolCatalogNudgeFromNames(["exec_command", "mcp__fs__read_file"]); + + expect(note).toContain("call the listed parent tool and use those helpers only inside that tool's input"); + expect(note).not.toContain("If `exec` is listed, it is Codex code mode"); + expect(note).not.toContain("tools.ALL_TOOLS"); + }); + + test("detects a wire-renamed exec as code mode", () => { + const note = buildNonOpenAIToolCatalogNudgeFromNames( + ["cx_exec", "cx_wait"], + name => `cx_${name}`, + ); + + expect(note).toContain("If `cx_exec` is listed, it is Codex code mode"); + expect(note).toContain("from `cx_exec`'s description is not absence"); + expect(note).toContain("isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`"); + expect(note).not.toContain("If `exec` is listed, it is Codex code mode"); + }); + // `advertised` holds WIRE names. A provider that rewrites them (Claude OAuth `custom_`, // Anthropic compat `cx_`) must not have every neighbor name declared unavailable while the // catalog plainly lists the prefixed form.