diff --git a/src/responses/code-mode-helper-compat.ts b/src/responses/code-mode-helper-compat.ts index 726c5dcf19..e0c71ee87f 100644 --- a/src/responses/code-mode-helper-compat.ts +++ b/src/responses/code-mode-helper-compat.ts @@ -3,7 +3,7 @@ import { normalizeApplyPatchDelimiters, unwrapFreeformToolInput, } from "./apply-patch-envelope"; -import { declaresCodeModeExec } from "../types/tools"; +import { declaresCodeModeExec, stripDefaultNamespacePrefix } from "../types/tools"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -80,7 +80,7 @@ export function resolveCodeModeHelperName( namespace?: string, declaredNames?: ReadonlySet, ): string | undefined { - if (codeModeHelperName) return codeModeHelperName; + if (codeModeHelperName) return stripDefaultNamespacePrefix(codeModeHelperName, declaredNames); if (toolName !== "exec" || namespace !== undefined) return undefined; // `exec` is a name, not a guarantee. Without a catalog that is genuinely code mode, a // caller-defined `exec` could legitimately take patch text, and handing it generated diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index cad1b39011..ea5e101e35 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -5,7 +5,8 @@ import { namespacedToolName, normalizeDeclaredToolName, } from "../types"; -import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { hasDeclaredNamespaceTools, stripDefaultNamespacePrefix } from "../types/tools"; +import { sseDataPayload, type SseBlockRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); @@ -319,6 +320,12 @@ function undeclaredNameInItem( dottedAliasIsUnambiguous(item.namespace, name) && declared.has(dottedToolName(item.namespace, name)) ) return undefined; + // A routed provider may attach a default namespace to a bare tool, e.g. a + // 'default' namespace around 'view_image' when only bare 'view_image' was + // declared. Fold it only while that namespace is not genuinely in use. + if ((item.namespace === 'default' || item.namespace === 'functions') + && declared.has(name) + && !hasDeclaredNamespaceTools(declared, item.namespace)) return undefined; return name; } const effectiveName = normalizeDeclaredToolName(name, declared); @@ -412,3 +419,70 @@ export function createUndeclaredToolCallGuardBlockRewrite( return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); }; } + +const DEFAULT_STRIP_CALL_TYPES = new Set(['function_call', 'custom_tool_call']); +const DEFAULT_STRIP_NAMESPACES = new Set(['default', 'functions']); + +function normalizeDefaultPrefixedToolCallNode( + node: unknown, + declared: ReadonlySet, +): { value: unknown; changed: boolean } { + if (Array.isArray(node)) { + let changed = false; + const out = node.map((entry) => { + const result = normalizeDefaultPrefixedToolCallNode(entry, declared); + changed = changed || result.changed; + return result.value; + }); + return changed ? { value: out, changed: true } : { value: node, changed: false }; + } + if (!isPlainObject(node)) return { value: node, changed: false }; + let changed = false; + const out: Record = {}; + for (const key of Object.keys(node)) { + const result = normalizeDefaultPrefixedToolCallNode(node[key], declared); + out[key] = result.value; + changed = changed || result.changed; + } + if (DEFAULT_STRIP_CALL_TYPES.has(out.type as string) && typeof out.name === 'string') { + if (typeof out.namespace === 'string' && DEFAULT_STRIP_NAMESPACES.has(out.namespace)) { + const namespace = out.namespace; + const bare = out.name; + if (!declared.has(namespace + '__' + bare) && !declared.has(namespace + '.' + bare) + && declared.has(bare) + && !hasDeclaredNamespaceTools(declared, namespace)) { + delete out.namespace; + changed = true; + } + } else if (!('namespace' in out)) { + const stripped = stripDefaultNamespacePrefix(out.name, declared); + if (stripped !== out.name && declared.has(stripped)) { + out.name = stripped; + changed = true; + } + } + } + return changed ? { value: out, changed: true } : { value: node, changed: false }; +} + +export function normalizeDefaultNamespacePrefixInJson( + text: string, + declared: ReadonlySet, +): string { + // No substring fast path here: JSON unicode escapes can hide a default-namespace + // prefix from byte matching, so every payload goes through the parser below. + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const result = normalizeDefaultPrefixedToolCallNode(payload, declared); + return result.changed ? JSON.stringify(result.value) : text; +} + +export function createDefaultNamespacePrefixStripRewrite( + declared: ReadonlySet, +): SsePayloadRewrite { + return (payload) => normalizeDefaultNamespacePrefixInJson(payload, declared); +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3f4cfe4345..253bbeecf3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -430,6 +430,8 @@ import { createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, + createDefaultNamespacePrefixStripRewrite, + normalizeDefaultNamespacePrefixInJson, undeclaredToolCallMessage, undeclaredToolCallName, undeclaredToolCallNameInResponse, @@ -5938,6 +5940,10 @@ async function handleResponsesInner( : undefined; // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ + // Fold a provider-added default namespace ('default.view_image' for a + // declared bare 'view_image') back to bare before the undeclared-tool + // guard compares names the client will actually receive. + createDefaultNamespacePrefixStripRewrite(new Set(declaredWireToolNames)), createImageGenCallRestoreRewrite(imageGenCallAliases), // #3217: a call whose namespace repeats its own name is unroutable in codex-rs. createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization), @@ -6220,7 +6226,11 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); - const repaired = normalizeFunctionCompletionJson(restoredToolSearch); + const restoredDefaultPrefix = normalizeDefaultNamespacePrefixInJson( + restoredToolSearch, + declaredWireToolNames, + ); + const repaired = normalizeFunctionCompletionJson(restoredDefaultPrefix); const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; diff --git a/src/types/tools.ts b/src/types/tools.ts index bb91ebe63f..1085825ecc 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -66,22 +66,69 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ */ export const CODE_MODE_EXEC_TOOL_NAME = "exec"; +/** + * Wire prefixes that mean 'no namespace'. + * + * Some routed providers qualify a bare tool with a default namespace on the + * way back (observed: muse-spark via opencode-go emits 'default.view_image' + * for the Codex client bare 'view_image'). 'default' carries no tool identity + * of its own, and the reserved 'functions' group likewise has no wire prefix, + * so fold that spelling back to the bare name, but only while the catalog + * declares no tools under that namespace. A genuinely declared full name + * always wins over the fold, so a real namespaced identity can never be + * misrouted to bare. + */ +const DEFAULT_NAMESPACE_PREFIXES = [ + { namespace: 'default', prefixes: ['default.', 'default__'] }, + { namespace: 'functions', prefixes: ['functions.', 'functions__'] }, +] as const; + +export function hasDeclaredNamespaceTools( + declared: ReadonlySet, + namespace: string, +): boolean { + const dot = namespace + '.'; + const flat = namespace + '__'; + for (const name of declared) { + if (name.startsWith(dot) || name.startsWith(flat)) return true; + } + return false; +} + +export function stripDefaultNamespacePrefix( + name: string, + declared: ReadonlySet | undefined, +): string { + if (!declared || declared.has(name)) return name; + for (const entry of DEFAULT_NAMESPACE_PREFIXES) { + for (const prefix of entry.prefixes) { + if (name.startsWith(prefix) && name.length > prefix.length) { + if (hasDeclaredNamespaceTools(declared, entry.namespace)) return name; + return name.slice(prefix.length); + } + } + } + return name; +} + export function normalizeDeclaredToolName( name: string, declared: ReadonlySet | undefined, ): string { - if (!declared || !declared.has(CODE_MODE_EXEC_TOOL_NAME)) return name; - if (declared.has(name)) return name; - if (name === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; + if (!declared) return name; + const unprefixed = stripDefaultNamespacePrefix(name, declared); + if (!declared.has(CODE_MODE_EXEC_TOOL_NAME)) return unprefixed; + if (declared.has(unprefixed)) return unprefixed; + if (unprefixed === 'apply_patch') return CODE_MODE_EXEC_TOOL_NAME; // When the catalog explicitly declares any legacy shell bridge name, the environment // genuinely exposes that tool — turn normalization off so a call is never mis-routed // to `exec`. if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { - return name; + return unprefixed; } - return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(name) + return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(unprefixed) ? CODE_MODE_EXEC_TOOL_NAME - : name; + : unprefixed; } /** diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 41d8383a6c..c1b3b306bf 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -12,10 +12,13 @@ import { createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, + normalizeDefaultNamespacePrefixInJson, + undeclaredToolCallName, undeclaredToolCallNameInResponse, UNDECLARED_TOOL_CALL_ERROR_CODE, type ProviderExecutedCallType, } from "../../src/server/responses-undeclared-tool-guard"; +import { normalizeDeclaredToolName } from "../../src/types/tools"; import { relaySseWithBlockRewrite } from "../../src/server/sse-payload-rewrite"; import { handleResponses } from "../../src/server/responses"; import { expandPreviousResponseInput } from "../../src/responses/state"; @@ -1840,3 +1843,102 @@ describe("xAI hosted-call authorization through handleResponses", () => { expect(body.error.message).toContain('undeclared client tool "x_keyword_search"'); }); }); + +describe("provider-added default namespace prefix", () => { + const viewImageCatalog = { + tools: [{ type: "function", name: "view_image" }], + }; + + function declaredBareViewImage(): Set { + return collectDeclaredWireToolNames(viewImageCatalog); + } + + test("dotted default prefix resolves to the declared bare tool", () => { + const declared = declaredBareViewImage(); + expect(declared.has("view_image")).toBe(true); + expect( + undeclaredToolCallName( + { + type: "response.output_item.added", + item: { type: "function_call", name: "default.view_image", call_id: "call_1" }, + }, + declared, + ), + ).toBeUndefined(); + expect(normalizeDeclaredToolName("default.view_image", declared)).toBe("view_image"); + expect(normalizeDeclaredToolName("default__view_image", declared)).toBe("view_image"); + expect(normalizeDeclaredToolName("functions.view_image", declared)).toBe("view_image"); + expect(normalizeDeclaredToolName("functions__view_image", declared)).toBe("view_image"); + }); + + test("namespaced default shape resolves to the declared bare tool", () => { + const declared = declaredBareViewImage(); + expect( + undeclaredToolCallName( + { + type: "response.output_item.added", + item: { type: "function_call", name: "view_image", namespace: "default", call_id: "call_2" }, + }, + declared, + ), + ).toBeUndefined(); + }); + + test("genuinely undeclared default-prefixed tools still fail closed", () => { + const declared = declaredBareViewImage(); + expect( + undeclaredToolCallName( + { + type: "response.output_item.added", + item: { type: "function_call", name: "default.apply_patch", call_id: "call_3" }, + }, + declared, + ), + ).toBe("default.apply_patch"); + expect( + normalizeDefaultNamespacePrefixInJson(JSON.stringify({ name: "default.apply_patch" }), declared), + ).toBe(JSON.stringify({ name: "default.apply_patch" })); + }); + + test("no fold while the default namespace is genuinely declared", () => { + const declared = collectDeclaredWireToolNames({ + tools: [{ type: "namespace", name: "default", tools: [{ type: "function", name: "view_image" }] }], + }); + expect(declared.has("default__view_image")).toBe(true); + expect(normalizeDeclaredToolName("default.other", declared)).toBe("default.other"); + }); + + test("payload rewrite restores the bare name before relay", () => { + const declared = declaredBareViewImage(); + const rewritten = normalizeDefaultNamespacePrefixInJson( + JSON.stringify({ + type: "response.output_item.added", + item: { type: "function_call", name: "default.view_image", call_id: "call_1" }, + }), + declared, + ); + const parsed = JSON.parse(rewritten) as { item: { name: string } }; + expect(parsed.item.name).toBe("view_image"); + }); + + test("guard block rewrite relays the folded call without failing", () => { + const declared = declaredBareViewImage(); + const guard = createUndeclaredToolCallGuardBlockRewrite(declared); + const blocks = guard( + frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "default.view_image", + arguments: "{}", + status: "in_progress", + }, + }), + ); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toContain("response.output_item.added"); + expect(blocks[0]).not.toContain("response.failed"); + }); +});