diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index fd141ccfb8..4162921339 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -24,7 +24,7 @@ import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; import { identifyRoutedModel } from "./identity"; import { redactSecretString } from "../lib/redact"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; @@ -615,6 +615,7 @@ function messagesToAnthropicFormat( parsed.context.tools, parsed.options.toolChoice, tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)), + effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt), ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; const system = systemParts.length diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 156ba5130a..2ab7c7012b 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -10,7 +10,7 @@ import { readBoundedResponseBody } from "../lib/bounded-body"; import { configuredReasoningEfforts } from "../reasoning-effort"; import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; import { identifyRoutedModel } from "./identity"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's @@ -454,7 +454,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code"); const cwd = currentWorkingDirectory(); const tools = visibleTools(parsed); - const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice); + const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice, undefined, effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt)); const choiceInstruction = toolChoiceInstruction(parsed); const system = identifyRoutedModel([ ...(parsed.context.systemPrompt ?? []), diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9043d1f929..006233faae 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -32,7 +32,7 @@ import { retainTranslatedEventBatch, type TranslatorBudget, } from "../lib/translator-budget"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between @@ -150,7 +150,7 @@ function messagesToGeminiFormat( ): { systemInstruction?: unknown; contents: unknown[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. - const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice); + const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice, undefined, effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt)); const systemText = identifyRoutedModel([ ...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : []), diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b93222d6ac..7abfe3116c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -10,7 +10,7 @@ import { redactSecretString } from "../lib/redact"; import { contentPartsToText } from "./image"; import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; -import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText, isCanonicalNativeOpenAIRoute, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { canForwardForeignServiceTierForChatModel, @@ -545,13 +545,7 @@ function developerSystemText(message: OcxMessage): string | undefined { return message.content.map(part => (part as OcxTextContent).text).join(""); } -function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { - try { - return new URL(provider.baseUrl).hostname === "api.openai.com"; - } catch { - return false; - } -} +const isNativeOpenAIChatTarget = isCanonicalNativeOpenAIRoute; /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" @@ -634,7 +628,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon const nativeOpenAI = isNativeOpenAIChatTarget(provider); const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) - ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) + ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice, undefined, effectiveInstructionText(context.messages, context.systemPrompt)) : undefined; const developerSystemParts = nativeOpenAI ? [] diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 9325125691..28882410c8 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -4,8 +4,24 @@ import { type OcxRequestOptions, type OcxTool, type OcxProviderConfig, + type OcxMessage, } from "../types"; +/** Collect authoritative system/developer text plus only the latest user turn. */ +export function effectiveInstructionText(messages: readonly OcxMessage[] | undefined, system?: readonly string[]): string[] { + const out = [...(system ?? [])]; + let latestUserText: string[] = []; + for (const message of messages ?? []) { + if (message.role !== "developer" && message.role !== "user") continue; + const text = typeof message.content === "string" + ? [message.content] + : message.content.filter(part => part.type === "text").map(part => part.text); + if (message.role === "developer") out.push(...text); + else latestUserText = text; + } + return [...out, ...latestUserText]; +} + // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one // here tells a routed model not to call it unless this turn's catalog really lists it. // @@ -57,21 +73,64 @@ function uniqueNames(names: readonly string[]): string[] { return [...new Set(names.filter(name => name.trim().length > 0))]; } -function isOpenAIOrChatGPTHost(hostname: string): boolean { - return hostname === "openai.com" - || hostname.endsWith(".openai.com") - || hostname === "chatgpt.com" - || hostname.endsWith(".chatgpt.com"); +function isOpenAIBrandedDestination(hostname: string): boolean { + // A custom endpoint containing an OpenAI/ChatGPT DNS label is ambiguous: it is not canonical + // native OpenAI, but injecting an aggressive non-OpenAI tool policy would be unsafe too. + const labels = hostname.toLowerCase().split("."); + return labels.includes("openai") || labels.includes("chatgpt"); +} + +function declaredToolsBlock(description: string): string | undefined { + const declaration = /(?:declare\s+)?const\s+tools\s*:\s*\{/i.exec(description); + if (!declaration) return undefined; + const open = declaration.index + declaration[0].lastIndexOf("{"); + let depth = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (let index = open; index < description.length; index += 1) { + const character = description[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = undefined; + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "{") depth += 1; + else if (character === "}" && --depth === 0) return description.slice(open + 1, index); + } + return undefined; } -export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick): boolean { +export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick & Partial>): boolean { try { - return !isOpenAIOrChatGPTHost(new URL(provider.baseUrl).hostname); + const host = new URL(provider.baseUrl).hostname; + return !isOpenAIBrandedDestination(host); } catch { return true; } } +/** True only for the two routes on which OpenAI's native tool contract is authoritative. */ +export function isCanonicalNativeOpenAIRoute( + provider: Pick, +): boolean { + if (provider.adapter !== "openai-chat" && provider.adapter !== "openai-responses") return false; + try { + const url = new URL(provider.baseUrl); + if (url.port || url.search || url.hash || url.username || url.password) return false; + const auth = provider.authMode; + const openai = url.protocol === "https:" && url.hostname === "api.openai.com" && url.pathname.replace(/\/$/, "") === "/v1"; + const chatgpt = url.protocol === "https:" && url.hostname === "chatgpt.com" && url.pathname.replace(/\/$/, "") === "/backend-api/codex"; + return (openai && (auth === undefined || auth === "key")) || (chatgpt && provider.adapter === "openai-responses" && auth === "forward"); + } catch { + return false; + } +} + /** * Codex code mode is a SEMANTIC property, not a name. * @@ -120,7 +179,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( "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.", verifiedCodeModeExecName - ? "`" + verifiedCodeModeExecName + "` 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 `" + verifiedCodeModeExecName + "`'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." + ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'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." @@ -131,9 +190,10 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( } export function buildNonOpenAIToolCatalogNudgeForTools( - tools: readonly Pick[] | undefined, + tools: readonly Pick[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name), + effectiveInstructions?: readonly string[], ): string | undefined { const visible = tools?.filter(toolChoiceToolPredicate(toolChoice)); const visibleNames = visible?.map(toWireName); @@ -146,9 +206,21 @@ export function buildNonOpenAIToolCatalogNudgeForTools( ? toWireName(codeModeExecTool) : undefined; // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool. - return buildNonOpenAIToolCatalogNudgeFromNames( + const base = buildNonOpenAIToolCatalogNudgeFromNames( visibleNames, name => toWireName({ name }), codeModeExecName, ); + if (!base || !codeModeExecTool) return base; + const description = codeModeExecTool.description ?? ""; + const helperDeclarations = declaredToolsBlock(description); + const hasApplyPatch = !!helperDeclarations && /\bapply_patch\s*\(\s*input\s*:\s*string\s*\)/i.test(helperDeclarations); + if (!hasApplyPatch) return base; + const instructions = (effectiveInstructions ?? []).join("\n"); + if (/\s*#?\s*Collaboration Mode:\s*Plan\b|You are in \*\*Plan Mode\*\*|\b(?:do not|must not|never)\s+(?:make|perform)\s+(?:any\s+)?mutations?\b|\b(?:do not|must not|never)\s+(?:edit|modify|write|use\s+apply_patch)\b|\buse\s+(?:the\s+)?shell\s+for\s+(?:file\s+)?edits\b/i.test(instructions)) return base; + const mentionsExecCommand = !!helperDeclarations && /\bexec_command\s*\(/i.test(helperDeclarations); + const nested = mentionsExecCommand + ? " Use nested `tools.exec_command` for reads, searches, tests, builds, formatters, and genuinely mechanical transformations; do not print a pretend tool call." + : ""; + return base + " For targeted code edits, prefer a focused patch using the nested `tools.apply_patch` helper, for example `await tools.apply_patch(\"*** Begin Patch\\n*** Update File: path\\n@@\\n-old\\n+new\\n*** End Patch\")`. Do not use shell, Node, Python, sed, or heredoc commands for targeted edits; call the helper directly, then wait for its real tool result (a real tool event, not printed text)." + nested; } diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index f764f001fa..f48f4d2500 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; import { buildNonOpenAIToolCatalogNudgeForTools, buildNonOpenAIToolCatalogNudgeFromNames, + effectiveInstructionText, shouldInjectNonOpenAIToolCatalogNudge, + isCanonicalNativeOpenAIRoute, } from "../src/adapters/tool-catalog-nudge"; import type { OcxTool } from "../src/types"; @@ -201,4 +203,49 @@ describe("non-OpenAI tool catalog nudge", () => { expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false); expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://api.kimi.com/coding/v1" })).toBe(true); }); + + test("classifies only canonical native routes", () => { + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com/v1" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "oauth", baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com.proxy/v1" })).toBe(false); + expect(shouldInjectNonOpenAIToolCatalogNudge({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com.proxy/v1" })).toBe(false); + expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://fooopenai.com/v1" })).toBe(true); + }); + + test("preserves structured developer instructions for mutation gating", () => { + const instructions = effectiveInstructionText([ + { role: "developer", timestamp: 1, content: [{ type: "text", text: "Do not modify files." }, { type: "image", imageUrl: "data:image/png;base64,AA==" }] }, + { role: "user", timestamp: 2, content: "stale user text" }, + { role: "user", timestamp: 3, content: [{ type: "text", text: "current user text" }] }, + ], ["system"]); + expect(instructions).toEqual(["system", "Do not modify files.", "current user text"]); + }); + + test("injects contextual patch guidance only for declared nested helpers", () => { + const exec = (description: string): OcxTool => ({ name: "exec", freeform: true, description, parameters: {} }); + const note = buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }")]); + expect(note).toContain("tools.apply_patch"); + expect(note).toContain("@@"); + expect(note).toContain("tools.exec_command"); + const nestedInput = buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { exec_command(input: { cmd: string }): Promise; apply_patch(input: string): Promise }")]); + expect(nestedInput).toContain("targeted code edits"); + expect(nestedInput).toContain("tools.exec_command"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("JavaScript; apply_patch is mentioned in prose")])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("For example, tools.apply_patch({ patch: '...' }) may exist")])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { apply_patch(input: string): Promise }")])).not.toContain("exec_command"); + }); + + test("suppresses contextual guidance for disallowed, planned, structured, and MCP tools", () => { + const exec = { name: "exec", freeform: true, description: "declare const tools: { apply_patch(input: string): Promise }", parameters: {} } as OcxTool; + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], "none") ?? "").not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], { mode: "required", allowedTools: ["other"] }) ?? "").not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["You are in **Plan Mode**"])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["do not make any mutations"])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["# Collaboration Mode: Plan"])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["Do not use apply_patch"])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([{ ...exec, freeform: undefined }])).not.toContain("targeted code edits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([{ ...exec, namespace: "mcp__tools" }])).not.toContain("targeted code edits"); + }); });