diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 6a126ff47..17214a165 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -59,8 +59,8 @@ import { import { normalizeToolCallId } from "../utils/tool-call-id.ts"; import { isForcedToolChoiceUnsupportedError, omitToolChoiceParam } from "../utils/tool-choice-fallback.ts"; import { resolveRootObjectSchema } from "../utils/tool-schema-compat.ts"; -import { demotedToolCallText, demotedToolResultText } from "../utils/unavailable-tool-text.ts"; import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.ts"; +import { demoteUnavailableToolReferences } from "./anthropic-tool-references.ts"; import { resolveCloudflareBaseUrl } from "./cloudflare.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; @@ -861,51 +861,6 @@ function sanitizeUnsupportedNativeTools( return changed ? (sanitized as MessageCreateParamsStreaming) : params; } -/** - * Anthropic validates that every tool referenced by the message history is - * available in the same request — defined in `tools` or discovered through a - * `tool_reference` block — and rejects the whole request otherwise - * ("Tool reference '' not found in available tools"). Sessions outlive - * their tools: an MCP server can be absent after a resume, an extension can - * stop registering a tool, or a payload hook can strip a definition while the - * history still carries the call. Demote those references to plain text so - * the turn can proceed; the matching tool_result is demoted in lockstep so no - * orphan pairing error replaces the original one. - * - * Availability is decided by the request's `tools` array alone. A discovered - * name never stands in for a missing definition: a `tool_reference` without a - * definition is itself rejected, so it cannot keep a later `tool_use` alive. - * - * Native tool search results (`tool_search_tool_result`) replay verbatim on the - * same model, and the wire path can hand their references back under a gateway - * namespace (`mcp____`) that senpi never defined and that does not - * survive across requests. Those references are folded back to the request's - * own tool names; a reference that still does not resolve is dropped, and a - * search pair left with no references is demoted to text. - */ -const GATEWAY_TOOL_NAMESPACE = /^mcp__[^_]+__(.+)$/; - -function resolveAvailableToolName(name: string, definedNames: ReadonlySet): string | undefined { - if (definedNames.has(name)) return name; - const namespaced = GATEWAY_TOOL_NAMESPACE.exec(name); - if (namespaced?.[1] !== undefined && definedNames.has(namespaced[1])) return namespaced[1]; - return undefined; -} - -function isNativeToolSearchResultBlock(block: unknown): block is Record & { - type: "tool_search_tool_result"; - tool_use_id: string; - content: Record & { tool_references: unknown[] }; -} { - return ( - isRecord(block) && - block.type === "tool_search_tool_result" && - typeof block.tool_use_id === "string" && - isRecord(block.content) && - Array.isArray(block.content.tool_references) - ); -} - /** Numeric HTTP status carried by an SDK error (Anthropic APIError.status), if any. */ function httpStatusOfError(error: unknown): number | undefined { if (!isRecord(error)) return undefined; @@ -913,190 +868,6 @@ function httpStatusOfError(error: unknown): number | undefined { return typeof status === "number" && Number.isInteger(status) && status >= 100 && status < 600 ? status : undefined; } -function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming { - const messages = params.messages; - if (!Array.isArray(messages) || messages.length === 0) return params; - - const definedNames = new Set(); - if (Array.isArray(params.tools)) { - for (const tool of params.tools) { - if (isRecord(tool) && typeof tool.name === "string") definedNames.add(tool.name); - } - } - const resolve = (name: string): string | undefined => resolveAvailableToolName(name, definedNames); - - const demotedCallNames = new Map(); - const renamedCallNames = new Map(); - for (const message of messages) { - if (message.role !== "assistant" || !Array.isArray(message.content)) continue; - for (const block of message.content) { - if (!isRecord(block) || block.type !== "tool_use" || typeof block.name !== "string") continue; - const resolved = resolve(block.name); - if (resolved === undefined) demotedCallNames.set(block.id, block.name); - else if (resolved !== block.name) renamedCallNames.set(block.id, resolved); - } - } - - let changed = false; - const availableToolNames = [...definedNames]; - const seenDemotedCallNames = new Set(); - const rewrittenMessages: MessageParam[] = []; - for (const message of messages) { - if (!Array.isArray(message.content)) { - rewrittenMessages.push(message); - continue; - } - let messageChanged = false; - // A native search pair whose every reference stopped resolving is demoted - // as a unit: the result decides, and its `server_tool_use` follows. - const droppedSearchUseIds = new Set(); - const droppedSearchNames = new Map(); - if (message.role === "assistant") { - for (const block of message.content) { - if (!isNativeToolSearchResultBlock(block)) continue; - const names = block.content.tool_references - .filter((item): item is Record => isRecord(item) && item.type === "tool_reference") - .map((item) => (typeof item.tool_name === "string" ? item.tool_name : "")); - if (names.length > 0 && names.every((name) => resolve(name) === undefined)) { - droppedSearchUseIds.add(block.tool_use_id); - droppedSearchNames.set(block.tool_use_id, names); - } - } - } - const content: ContentBlockParam[] = []; - for (const block of message.content) { - if (message.role === "assistant" && isRecord(block) && block.type === "tool_use") { - const demotedName = demotedCallNames.get(block.id); - if (demotedName !== undefined) { - messageChanged = true; - const firstOccurrence = !seenDemotedCallNames.has(demotedName); - seenDemotedCallNames.add(demotedName); - content.push({ - type: "text", - text: demotedToolCallText(demotedName, availableToolNames, firstOccurrence), - }); - continue; - } - const renamedName = renamedCallNames.get(block.id); - if (renamedName !== undefined) { - messageChanged = true; - content.push({ ...block, name: renamedName } as ContentBlockParam); - continue; - } - } - if (message.role === "assistant" && isRecord(block) && block.type === "server_tool_use") { - if (typeof block.id === "string" && droppedSearchUseIds.has(block.id)) { - messageChanged = true; - continue; - } - } - if (message.role === "assistant" && isNativeToolSearchResultBlock(block)) { - const omitted = droppedSearchNames.get(block.tool_use_id); - if (omitted !== undefined) { - messageChanged = true; - content.push({ type: "text", text: `Tool reference unavailable: ${[...new Set(omitted)].join(", ")}` }); - continue; - } - const rewritten = rewriteToolReferenceItems(block.content.tool_references, resolve); - if (rewritten !== undefined) { - messageChanged = true; - content.push({ - ...block, - content: { ...block.content, tool_references: rewritten.kept }, - } as ContentBlockParam); - continue; - } - } - if (isRecord(block) && block.type === "tool_result") { - const demotedName = demotedCallNames.get(block.tool_use_id); - if (demotedName !== undefined) { - messageChanged = true; - content.push({ type: "text", text: demotedToolResultText(demotedName, toolResultText(block.content)) }); - continue; - } - if (Array.isArray(block.content)) { - const rewritten = rewriteToolReferenceItems(block.content, resolve); - if (rewritten !== undefined) { - messageChanged = true; - const nextContent = - rewritten.kept.length > 0 - ? rewritten.kept - : [ - { - type: "text", - text: `Tool reference unavailable: ${[...new Set(rewritten.omitted)].join(", ")}`, - }, - ]; - content.push({ ...block, content: nextContent } as ContentBlockParam); - continue; - } - } - } - content.push(block); - } - if (content.length === 0) { - changed = true; - continue; - } - if (messageChanged) { - changed = true; - rewrittenMessages.push({ ...message, content }); - continue; - } - rewrittenMessages.push(message); - } - - if (!changed) return params; - return { ...params, messages: rewrittenMessages }; -} - -/** - * Folds every `tool_reference` item in `items` onto the request's own tool - * name and drops the ones that still do not resolve. Returns undefined when - * nothing changed so callers can keep the original block identity. - */ -function rewriteToolReferenceItems( - items: readonly unknown[], - resolve: (name: string) => string | undefined, -): { kept: unknown[]; omitted: string[] } | undefined { - const kept: unknown[] = []; - const omitted: string[] = []; - let rewritten = false; - for (const item of items) { - if (!isRecord(item) || item.type !== "tool_reference" || typeof item.tool_name !== "string") { - kept.push(item); - continue; - } - const resolved = resolve(item.tool_name); - if (resolved === undefined) { - omitted.push(item.tool_name); - rewritten = true; - continue; - } - if (resolved !== item.tool_name) { - kept.push({ ...item, tool_name: resolved }); - rewritten = true; - continue; - } - kept.push(item); - } - return rewritten ? { kept, omitted } : undefined; -} - -function toolResultText(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const item of content) { - if (!isRecord(item)) continue; - if (item.type === "text" && typeof item.text === "string") parts.push(item.text); - else if (typeof item.type === "string") parts.push(`[${item.type}]`); - } - if (parts.length > 0) return parts.join("\n"); - } - return "Tool output unavailable."; -} - function sanitizeAdaptiveThinkingPayload( model: Model<"anthropic-messages">, params: MessageCreateParamsStreaming, diff --git a/packages/ai/src/api/anthropic-tool-references.ts b/packages/ai/src/api/anthropic-tool-references.ts new file mode 100644 index 000000000..7908d74c6 --- /dev/null +++ b/packages/ai/src/api/anthropic-tool-references.ts @@ -0,0 +1,274 @@ +import type { + BetaContentBlockParam as ContentBlockParam, + MessageCreateParamsStreaming, + BetaMessageParam as MessageParam, +} from "@anthropic-ai/sdk/resources/beta/messages/messages.js"; +import { demotedToolCallText, demotedToolResultText } from "../utils/unavailable-tool-text.ts"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Anthropic validates that every tool referenced by the message history is + * available in the same request — defined in `tools` or discovered through a + * `tool_reference` block — and rejects the whole request otherwise + * ("Tool reference '' not found in available tools"). Sessions outlive + * their tools: an MCP server can be absent after a resume, an extension can + * stop registering a tool, or a payload hook can strip a definition while the + * history still carries the call. Demote those references to plain text so + * the turn can proceed; the matching tool_result is demoted in lockstep so no + * orphan pairing error replaces the original one. + * + * Availability is decided by the request's `tools` array alone. A discovered + * name never stands in for a missing definition: a `tool_reference` without a + * definition is itself rejected, so it cannot keep a later `tool_use` alive. + * + * Native tool search results (`tool_search_tool_result`) replay verbatim on the + * same model, and the wire path can hand their references back under a gateway + * namespace (`mcp____`) that senpi never defined and that does not + * survive across requests. Those references are folded back to the request's + * own tool names; a reference that still does not resolve is dropped, and a + * search pair left with no references is demoted to text. + * + * The same wire path can also recase the tool it namespaces (`memory` comes + * back as `mcp__a4e6__Memory`, `lsp_symbols` as `mcp__a4e6__LspSymbols`), so + * the suffix alone no longer matches the request's tool name byte for byte. + * Names are therefore compared with case and `_`/`-` separators folded away, + * and a folded key resolves only when exactly one request tool owns it: the + * fold never guesses between two candidates. + */ +const GATEWAY_TOOL_NAMESPACE = /^mcp__[^_]+__(.+)$/; + +interface AvailableToolNames { + readonly defined: ReadonlySet; + readonly folded: ReadonlyMap; +} + +function foldToolNameKey(name: string): string { + return name.toLowerCase().replaceAll(/[-_]/g, ""); +} + +function collectAvailableToolNames(tools: unknown): AvailableToolNames { + const defined = new Set(); + if (Array.isArray(tools)) { + for (const tool of tools) { + if (isRecord(tool) && typeof tool.name === "string") defined.add(tool.name); + } + } + const folded = new Map(); + const ambiguous = new Set(); + for (const name of defined) { + const key = foldToolNameKey(name); + if (folded.has(key)) ambiguous.add(key); + else folded.set(key, name); + } + for (const key of ambiguous) folded.delete(key); + return { defined, folded }; +} + +function resolveAvailableToolName(name: string, available: AvailableToolNames): string | undefined { + const suffix = GATEWAY_TOOL_NAMESPACE.exec(name)?.[1]; + const candidates = suffix === undefined ? [name] : [name, suffix]; + for (const candidate of candidates) { + if (available.defined.has(candidate)) return candidate; + } + for (const candidate of candidates) { + const folded = available.folded.get(foldToolNameKey(candidate)); + if (folded !== undefined) return folded; + } + return undefined; +} + +function isNativeToolSearchResultBlock(block: unknown): block is Record & { + type: "tool_search_tool_result"; + tool_use_id: string; + content: Record & { tool_references: unknown[] }; +} { + return ( + isRecord(block) && + block.type === "tool_search_tool_result" && + typeof block.tool_use_id === "string" && + isRecord(block.content) && + Array.isArray(block.content.tool_references) + ); +} + +export function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming { + const messages = params.messages; + if (!Array.isArray(messages) || messages.length === 0) return params; + + const available = collectAvailableToolNames(params.tools); + const resolve = (name: string): string | undefined => resolveAvailableToolName(name, available); + + const demotedCallNames = new Map(); + const renamedCallNames = new Map(); + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (!isRecord(block) || block.type !== "tool_use" || typeof block.name !== "string") continue; + const resolved = resolve(block.name); + if (resolved === undefined) demotedCallNames.set(block.id, block.name); + else if (resolved !== block.name) renamedCallNames.set(block.id, resolved); + } + } + + let changed = false; + const availableToolNames = [...available.defined]; + const seenDemotedCallNames = new Set(); + const rewrittenMessages: MessageParam[] = []; + for (const message of messages) { + if (!Array.isArray(message.content)) { + rewrittenMessages.push(message); + continue; + } + let messageChanged = false; + // A native search pair whose every reference stopped resolving is demoted + // as a unit: the result decides, and its `server_tool_use` follows. + const droppedSearchUseIds = new Set(); + const droppedSearchNames = new Map(); + if (message.role === "assistant") { + for (const block of message.content) { + if (!isNativeToolSearchResultBlock(block)) continue; + const names = block.content.tool_references + .filter((item): item is Record => isRecord(item) && item.type === "tool_reference") + .map((item) => (typeof item.tool_name === "string" ? item.tool_name : "")); + if (names.length > 0 && names.every((name) => resolve(name) === undefined)) { + droppedSearchUseIds.add(block.tool_use_id); + droppedSearchNames.set(block.tool_use_id, names); + } + } + } + const content: ContentBlockParam[] = []; + for (const block of message.content) { + if (message.role === "assistant" && isRecord(block) && block.type === "tool_use") { + const demotedName = demotedCallNames.get(block.id); + if (demotedName !== undefined) { + messageChanged = true; + const firstOccurrence = !seenDemotedCallNames.has(demotedName); + seenDemotedCallNames.add(demotedName); + content.push({ + type: "text", + text: demotedToolCallText(demotedName, availableToolNames, firstOccurrence), + }); + continue; + } + const renamedName = renamedCallNames.get(block.id); + if (renamedName !== undefined) { + messageChanged = true; + content.push({ ...block, name: renamedName } as ContentBlockParam); + continue; + } + } + if (message.role === "assistant" && isRecord(block) && block.type === "server_tool_use") { + if (typeof block.id === "string" && droppedSearchUseIds.has(block.id)) { + messageChanged = true; + continue; + } + } + if (message.role === "assistant" && isNativeToolSearchResultBlock(block)) { + const omitted = droppedSearchNames.get(block.tool_use_id); + if (omitted !== undefined) { + messageChanged = true; + content.push({ type: "text", text: `Tool reference unavailable: ${[...new Set(omitted)].join(", ")}` }); + continue; + } + const rewritten = rewriteToolReferenceItems(block.content.tool_references, resolve); + if (rewritten !== undefined) { + messageChanged = true; + content.push({ + ...block, + content: { ...block.content, tool_references: rewritten.kept }, + } as ContentBlockParam); + continue; + } + } + if (isRecord(block) && block.type === "tool_result") { + const demotedName = demotedCallNames.get(block.tool_use_id); + if (demotedName !== undefined) { + messageChanged = true; + content.push({ type: "text", text: demotedToolResultText(demotedName, toolResultText(block.content)) }); + continue; + } + if (Array.isArray(block.content)) { + const rewritten = rewriteToolReferenceItems(block.content, resolve); + if (rewritten !== undefined) { + messageChanged = true; + const nextContent = + rewritten.kept.length > 0 + ? rewritten.kept + : [ + { + type: "text", + text: `Tool reference unavailable: ${[...new Set(rewritten.omitted)].join(", ")}`, + }, + ]; + content.push({ ...block, content: nextContent } as ContentBlockParam); + continue; + } + } + } + content.push(block); + } + if (content.length === 0) { + changed = true; + continue; + } + if (messageChanged) { + changed = true; + rewrittenMessages.push({ ...message, content }); + continue; + } + rewrittenMessages.push(message); + } + + if (!changed) return params; + return { ...params, messages: rewrittenMessages }; +} + +/** + * Folds every `tool_reference` item in `items` onto the request's own tool + * name and drops the ones that still do not resolve. Returns undefined when + * nothing changed so callers can keep the original block identity. + */ +function rewriteToolReferenceItems( + items: readonly unknown[], + resolve: (name: string) => string | undefined, +): { kept: unknown[]; omitted: string[] } | undefined { + const kept: unknown[] = []; + const omitted: string[] = []; + let rewritten = false; + for (const item of items) { + if (!isRecord(item) || item.type !== "tool_reference" || typeof item.tool_name !== "string") { + kept.push(item); + continue; + } + const resolved = resolve(item.tool_name); + if (resolved === undefined) { + omitted.push(item.tool_name); + rewritten = true; + continue; + } + if (resolved !== item.tool_name) { + kept.push({ ...item, tool_name: resolved }); + rewritten = true; + continue; + } + kept.push(item); + } + return rewritten ? { kept, omitted } : undefined; +} + +function toolResultText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const item of content) { + if (!isRecord(item)) continue; + if (item.type === "text" && typeof item.text === "string") parts.push(item.text); + else if (typeof item.type === "string") parts.push(`[${item.type}]`); + } + if (parts.length > 0) return parts.join("\n"); + } + return "Tool output unavailable."; +} diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 44ce8915a..0193b105f 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,23 @@ +## 2026-09-08 - Recased gateway-namespaced tool references fold onto the request's tool names + +### What changed + +- `packages/ai/src/api/anthropic-tool-references.ts` (new): the Anthropic tool-reference integrity pass (`demoteUnavailableToolReferences` and its helpers) moved out of `packages/ai/src/api/anthropic-messages.ts` into its own module, mirroring `anthropic-tool-pairs.ts`. `packages/ai/src/api/anthropic-messages.ts` only imports the pass now (and keeps `httpStatusOfError`, which #1487 added beside it). +- `resolveAvailableToolName` compares names with case and `_`/`-` separators folded away (`foldToolNameKey`) after the literal and namespace-stripped literal lookups fail. `collectAvailableToolNames` builds the folded index from the request's `tools` array once per request and drops any folded key that two request tools share, so the fold never guesses between candidates; such a reference stays unresolved and is dropped like before. +- `packages/ai/test/anthropic-tool-reference-integrity.test.ts`: three cases pin the fold (recased native search references `mcp__a4e6__Memory` / `LspSymbols` / `XSearch` plus a hyphenated literal fold onto `memory` / `lsp_symbols` / `x_search` / the literal; a recased namespaced history `tool_use` is renamed; an ambiguous fold is dropped). + +### Why + +- Live 2026-09-08 (omo 5.0.0-0.beta.48 / senpi 2026.9.7-2, session 01a08016, claude-fable-5-1 through ccapi): a native tool search returned its references as `mcp__a4e6__Memory`, `mcp__a4e6__LspSymbols`, `mcp__a4e6__XSearch`, `mcp__a4e6__Eval` — namespaced AND recased. Every later Anthropic request failed with `Tool reference 'mcp__a4e6__Memory' not found in available tools` and the session fell back to another model each turn. The shipped engine predates #1480, so it replayed the block verbatim; on main, #1480's exact-suffix fold would have turned `Memory` into a dropped reference (no 400, but the discovery was lost and the search pair demoted) because `Memory !== memory`. + +### Why an extension could not handle it + +- Same seam as #1480: the repair runs against the final `tools` array right before the SDK call, on provider-native blocks the provider assembles from history. + +### Expected merge conflict zones + +- LOW: `anthropic-messages.ts` loses a fork-only block (the pass was fork-only since `5ecb30463`), so future upstream merges touch it less; the new module is fork-only. + ## 2026-09-08 - Deliver provider HTTP status on rejected Anthropic requests (senpi #1481) diff --git a/packages/ai/test/anthropic-tool-reference-harness.ts b/packages/ai/test/anthropic-tool-reference-harness.ts new file mode 100644 index 000000000..d3700b13d --- /dev/null +++ b/packages/ai/test/anthropic-tool-reference-harness.ts @@ -0,0 +1,201 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import { Type } from "typebox"; +import { getModel } from "../src/compat.ts"; +import { streamAnthropic } from "../src/providers/anthropic.ts"; +import { fauxAssistantMessage } from "../src/providers/faux.ts"; +import type { AssistantMessage, Context, Tool, ToolResultMessage, UserMessage } from "../src/types.ts"; + +/** + * Shared harness for the Anthropic tool-reference integrity suites: a fake + * Anthropic client that captures the outgoing request, message fixtures, and + * block selectors over the captured payload. + */ +export interface CapturedRequest { + params: Record; +} + +export function createSseResponse(events: Array<{ event: string; data: string }>): Response { + const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +export function finalTextResponse(): Response { + return createSseResponse([ + { + event: "message_start", + data: JSON.stringify({ + type: "message_start", + message: { + id: "msg_test", + usage: { input_tokens: 3, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + }, + { + event: "content_block_start", + data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + }, + { + event: "content_block_delta", + data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }), + }, + { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 1 }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]); +} + +export function createFakeAnthropicClient(captured: CapturedRequest): Anthropic { + return { + beta: { + messages: { + create: (params: unknown) => { + captured.params = params as Record; + return { asResponse: async () => finalTextResponse() }; + }, + }, + }, + } as Anthropic; +} + +export function userMessage(content: string): UserMessage { + return { role: "user", content, timestamp: Date.now() }; +} + +export function toolResultMessage( + toolCallId: string, + toolName: string, + text: string, + addedToolNames?: string[], +): ToolResultMessage { + return { + role: "toolResult", + toolCallId, + toolName, + content: [{ type: "text", text }], + isError: false, + timestamp: Date.now(), + ...(addedToolNames ? { addedToolNames } : {}), + }; +} + +export function makeTool(name: string): Tool { + return { + name, + description: `Test tool ${name}`, + parameters: Type.Object({ input: Type.Optional(Type.String()) }), + }; +} + +export async function captureParams( + context: Context, + onPayload?: (payload: unknown) => unknown, + modelId: "claude-haiku-4-5" | "claude-sonnet-4-6" = "claude-haiku-4-5", +): Promise> { + const captured: CapturedRequest = { params: {} }; + const model = getModel("anthropic", modelId); + const s = streamAnthropic(model, context, { + apiKey: "fake-key", + client: createFakeAnthropicClient(captured), + ...(onPayload ? { onPayload: (payload) => onPayload(payload) as never } : {}), + }); + await s.result(); + return captured.params; +} + +export interface Block { + type: string; + id?: string; + name?: string; + tool_use_id?: string; + text?: string; + content?: unknown; +} + +export function messagesOf(params: Record): Array<{ role: string; content: unknown }> { + return params.messages as Array<{ role: string; content: unknown }>; +} + +export function blocksOf(message: { content: unknown }): Block[] { + return Array.isArray(message.content) ? (message.content as Block[]) : []; +} + +export function allBlocks(params: Record): Block[] { + return messagesOf(params).flatMap((message) => blocksOf(message)); +} + +export function toolUseBlocks(params: Record): Block[] { + return allBlocks(params).filter((block) => block.type === "tool_use"); +} + +export function toolResultBlocks(params: Record): Block[] { + return allBlocks(params).filter((block) => block.type === "tool_result"); +} + +export function textBlocks(params: Record): Block[] { + return allBlocks(params).filter((block) => block.type === "text"); +} + +export function toolNamesIn(params: Record): string[] { + const tools = (params.tools ?? []) as Array<{ name: string }>; + return tools.map((tool) => tool.name); +} + +/** + * A same-model assistant turn that ran Anthropic's native tool search. The + * result block replays verbatim on the next request, so the names it references + * must still resolve against that request's `tools` array. + */ +export function nativeSearchTurn(referenceNames: string[], useId = "srvtoolu_search"): AssistantMessage { + return { + ...fauxAssistantMessage("native search"), + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-6", + content: [ + { + type: "providerNative", + subtype: "server_tool_use", + raw: { type: "server_tool_use", id: useId, name: "tool_search_tool_bm25", input: { query: "memory" } }, + }, + { + type: "providerNative", + subtype: "tool_search_tool_result", + raw: { + type: "tool_search_tool_result", + tool_use_id: useId, + content: { + type: "tool_search_tool_search_result", + tool_references: referenceNames.map((tool_name) => ({ type: "tool_reference", tool_name })), + }, + }, + }, + ], + }; +} + +export function nativeSearchResultBlocks( + params: Record, +): Array<{ tool_use_id?: string; content?: unknown }> { + return allBlocks(params).filter((block) => block.type === "tool_search_tool_result") as Array<{ + tool_use_id?: string; + content?: unknown; + }>; +} + +export function nativeSearchReferenceNames(params: Record): string[] { + return nativeSearchResultBlocks(params).flatMap((block) => { + const content = block.content as { tool_references?: Array<{ tool_name?: string }> } | undefined; + return (content?.tool_references ?? []).map((reference) => reference.tool_name ?? ""); + }); +} diff --git a/packages/ai/test/anthropic-tool-reference-integrity.test.ts b/packages/ai/test/anthropic-tool-reference-integrity.test.ts index ce4885ff1..9440c621a 100644 --- a/packages/ai/test/anthropic-tool-reference-integrity.test.ts +++ b/packages/ai/test/anthropic-tool-reference-integrity.test.ts @@ -1,10 +1,17 @@ -import type Anthropic from "@anthropic-ai/sdk"; -import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/compat.ts"; -import { streamAnthropic } from "../src/providers/anthropic.ts"; import { fauxAssistantMessage, fauxToolCall } from "../src/providers/faux.ts"; -import type { AssistantMessage, Context, Tool, ToolResultMessage, UserMessage } from "../src/types.ts"; +import type { Context } from "../src/types.ts"; +import { + captureParams, + makeTool, + messagesOf, + textBlocks, + toolNamesIn, + toolResultBlocks, + toolResultMessage, + toolUseBlocks, + userMessage, +} from "./anthropic-tool-reference-harness.ts"; /** * Anthropic rejects a request whose message history references a tool that is @@ -20,194 +27,6 @@ import type { AssistantMessage, Context, Tool, ToolResultMessage, UserMessage } * turn can proceed instead of failing the whole request. */ -interface CapturedRequest { - params: Record; -} - -function createSseResponse(events: Array<{ event: string; data: string }>): Response { - const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n"); - return new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); -} - -function finalTextResponse(): Response { - return createSseResponse([ - { - event: "message_start", - data: JSON.stringify({ - type: "message_start", - message: { - id: "msg_test", - usage: { input_tokens: 3, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - }, - }), - }, - { - event: "content_block_start", - data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), - }, - { - event: "content_block_delta", - data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }), - }, - { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) }, - { - event: "message_delta", - data: JSON.stringify({ - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 1 }, - }), - }, - { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, - ]); -} - -function createFakeAnthropicClient(captured: CapturedRequest): Anthropic { - return { - beta: { - messages: { - create: (params: unknown) => { - captured.params = params as Record; - return { asResponse: async () => finalTextResponse() }; - }, - }, - }, - } as Anthropic; -} - -function userMessage(content: string): UserMessage { - return { role: "user", content, timestamp: Date.now() }; -} - -function toolResultMessage( - toolCallId: string, - toolName: string, - text: string, - addedToolNames?: string[], -): ToolResultMessage { - return { - role: "toolResult", - toolCallId, - toolName, - content: [{ type: "text", text }], - isError: false, - timestamp: Date.now(), - ...(addedToolNames ? { addedToolNames } : {}), - }; -} - -function makeTool(name: string): Tool { - return { - name, - description: `Test tool ${name}`, - parameters: Type.Object({ input: Type.Optional(Type.String()) }), - }; -} - -async function captureParams( - context: Context, - onPayload?: (payload: unknown) => unknown, - modelId: "claude-haiku-4-5" | "claude-sonnet-4-6" = "claude-haiku-4-5", -): Promise> { - const captured: CapturedRequest = { params: {} }; - const model = getModel("anthropic", modelId); - const s = streamAnthropic(model, context, { - apiKey: "fake-key", - client: createFakeAnthropicClient(captured), - ...(onPayload ? { onPayload: (payload) => onPayload(payload) as never } : {}), - }); - await s.result(); - return captured.params; -} - -interface Block { - type: string; - id?: string; - name?: string; - tool_use_id?: string; - text?: string; - content?: unknown; -} - -function messagesOf(params: Record): Array<{ role: string; content: unknown }> { - return params.messages as Array<{ role: string; content: unknown }>; -} - -function blocksOf(message: { content: unknown }): Block[] { - return Array.isArray(message.content) ? (message.content as Block[]) : []; -} - -function allBlocks(params: Record): Block[] { - return messagesOf(params).flatMap((message) => blocksOf(message)); -} - -function toolUseBlocks(params: Record): Block[] { - return allBlocks(params).filter((block) => block.type === "tool_use"); -} - -function toolResultBlocks(params: Record): Block[] { - return allBlocks(params).filter((block) => block.type === "tool_result"); -} - -function textBlocks(params: Record): Block[] { - return allBlocks(params).filter((block) => block.type === "text"); -} - -function toolNamesIn(params: Record): string[] { - const tools = (params.tools ?? []) as Array<{ name: string }>; - return tools.map((tool) => tool.name); -} - -/** - * A same-model assistant turn that ran Anthropic's native tool search. The - * result block replays verbatim on the next request, so the names it references - * must still resolve against that request's `tools` array. - */ -function nativeSearchTurn(referenceNames: string[], useId = "srvtoolu_search"): AssistantMessage { - return { - ...fauxAssistantMessage("native search"), - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-6", - content: [ - { - type: "providerNative", - subtype: "server_tool_use", - raw: { type: "server_tool_use", id: useId, name: "tool_search_tool_bm25", input: { query: "memory" } }, - }, - { - type: "providerNative", - subtype: "tool_search_tool_result", - raw: { - type: "tool_search_tool_result", - tool_use_id: useId, - content: { - type: "tool_search_tool_search_result", - tool_references: referenceNames.map((tool_name) => ({ type: "tool_reference", tool_name })), - }, - }, - }, - ], - }; -} - -function nativeSearchResultBlocks(params: Record): Array<{ tool_use_id?: string; content?: unknown }> { - return allBlocks(params).filter((block) => block.type === "tool_search_tool_result") as Array<{ - tool_use_id?: string; - content?: unknown; - }>; -} - -function nativeSearchReferenceNames(params: Record): string[] { - return nativeSearchResultBlocks(params).flatMap((block) => { - const content = block.content as { tool_references?: Array<{ tool_name?: string }> } | undefined; - return (content?.tool_references ?? []).map((reference) => reference.tool_name ?? ""); - }); -} - describe("Anthropic tool-reference integrity", () => { it("demotes history tool calls whose tool is no longer available", async () => { const context: Context = { @@ -346,55 +165,6 @@ describe("Anthropic tool-reference integrity", () => { if (Array.isArray(block.content)) expect(block.content.length).toBeGreaterThan(0); } }); - it("normalizes gateway-namespaced native search references to the request's tool names", async () => { - // Live 2026-09-08: the native search result replayed - // `mcp__925c__memory` while the request defined `memory`; the namespace - // belongs to the wire path, not to senpi, and it does not survive across - // requests, so the next turn 400ed with "Tool reference 'mcp__925c__memory' - // not found in available tools". - const context: Context = { - messages: [userMessage("find a tool"), nativeSearchTurn(["mcp__925c__memory"]), userMessage("done")], - tools: [makeTool("tool_search"), makeTool("memory")], - }; - - const params = await captureParams(context, undefined, "claude-sonnet-4-6"); - - expect(toolNamesIn(params)).toContain("memory"); - expect(nativeSearchReferenceNames(params)).toEqual(["memory"]); - expect(allBlocks(params).some((block) => block.type === "server_tool_use")).toBe(true); - }); - - it("keeps literal native search references and drops only the ones that no longer resolve", async () => { - const context: Context = { - messages: [ - userMessage("find a tool"), - nativeSearchTurn(["memory", "mcp__925c__gone", "mcp__925c__todo"]), - userMessage("done"), - ], - tools: [makeTool("tool_search"), makeTool("memory"), makeTool("todo")], - }; - - const params = await captureParams(context, undefined, "claude-sonnet-4-6"); - - expect(nativeSearchReferenceNames(params)).toEqual(["memory", "todo"]); - }); - - it("drops a native search pair whose every reference stopped resolving", async () => { - const context: Context = { - messages: [userMessage("find a tool"), nativeSearchTurn(["mcp__925c__gone"]), userMessage("done")], - tools: [makeTool("tool_search"), makeTool("memory")], - }; - - const params = await captureParams(context, undefined, "claude-sonnet-4-6"); - - expect(nativeSearchResultBlocks(params)).toHaveLength(0); - expect(allBlocks(params).some((block) => block.type === "server_tool_use")).toBe(false); - // The assistant turn survives as text so the transcript keeps its shape. - const assistant = messagesOf(params).filter((message) => message.role === "assistant"); - expect(assistant).toHaveLength(1); - expect(blocksOf(assistant[0]!).every((block) => block.type === "text")).toBe(true); - expect(JSON.stringify(params)).not.toContain('"tool_name":"mcp__925c__gone"'); - }); it("renames a gateway-namespaced history tool call to the request's tool name", async () => { const context: Context = { @@ -450,4 +220,24 @@ describe("Anthropic tool-reference integrity", () => { expect(JSON.stringify(params)).not.toContain('"tool_name":"mcp_computer_use_drag"'); expect(textBlocks(params).some((block) => block.text?.includes("mcp_computer_use_drag"))).toBe(true); }); + + it("renames a recased gateway-namespaced history tool call to the request's tool name", async () => { + const context: Context = { + messages: [ + userMessage("search x"), + fauxAssistantMessage(fauxToolCall("mcp__a4e6__XSearch", { input: "omo" }, { id: "call_x" }), { + stopReason: "toolUse", + }), + toolResultMessage("call_x", "mcp__a4e6__XSearch", "3 posts"), + userMessage("done"), + ], + tools: [makeTool("x_search")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(toolUseBlocks(params).map((block) => block.name)).toEqual(["x_search"]); + expect(toolResultBlocks(params).map((block) => block.tool_use_id)).toEqual(["call_x"]); + expect(textBlocks(params).some((block) => block.text?.includes("no longer available"))).toBe(false); + }); }); diff --git a/packages/ai/test/anthropic-tool-reference-native-search.test.ts b/packages/ai/test/anthropic-tool-reference-native-search.test.ts new file mode 100644 index 000000000..0326e5fd3 --- /dev/null +++ b/packages/ai/test/anthropic-tool-reference-native-search.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { Context } from "../src/types.ts"; +import { + allBlocks, + blocksOf, + captureParams, + makeTool, + messagesOf, + nativeSearchReferenceNames, + nativeSearchResultBlocks, + nativeSearchTurn, + toolNamesIn, + userMessage, +} from "./anthropic-tool-reference-harness.ts"; + +/** + * Anthropic's native tool search (`tool_search_tool_bm25`) hands the model a + * `tool_search_tool_result` block whose `tool_reference` items name the tools it + * found. Those blocks replay verbatim on the same model, and a gateway on the + * wire path can hand the names back under an opaque namespace (`mcp____`) + * and recased (`Memory` for `memory`). Every replayed reference must resolve + * against the request's own `tools` array before the request is sent, or + * Anthropic rejects it with "Tool reference '' not found in available + * tools". + */ + +describe("Anthropic native tool-search reference integrity", () => { + it("normalizes gateway-namespaced native search references to the request's tool names", async () => { + // Live 2026-09-08: the native search result replayed + // `mcp__925c__memory` while the request defined `memory`; the namespace + // belongs to the wire path, not to senpi, and it does not survive across + // requests, so the next turn 400ed with "Tool reference 'mcp__925c__memory' + // not found in available tools". + const context: Context = { + messages: [userMessage("find a tool"), nativeSearchTurn(["mcp__925c__memory"]), userMessage("done")], + tools: [makeTool("tool_search"), makeTool("memory")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(toolNamesIn(params)).toContain("memory"); + expect(nativeSearchReferenceNames(params)).toEqual(["memory"]); + expect(allBlocks(params).some((block) => block.type === "server_tool_use")).toBe(true); + }); + + it("keeps literal native search references and drops only the ones that no longer resolve", async () => { + const context: Context = { + messages: [ + userMessage("find a tool"), + nativeSearchTurn(["memory", "mcp__925c__gone", "mcp__925c__todo"]), + userMessage("done"), + ], + tools: [makeTool("tool_search"), makeTool("memory"), makeTool("todo")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(nativeSearchReferenceNames(params)).toEqual(["memory", "todo"]); + }); + + it("drops a native search pair whose every reference stopped resolving", async () => { + const context: Context = { + messages: [userMessage("find a tool"), nativeSearchTurn(["mcp__925c__gone"]), userMessage("done")], + tools: [makeTool("tool_search"), makeTool("memory")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(nativeSearchResultBlocks(params)).toHaveLength(0); + expect(allBlocks(params).some((block) => block.type === "server_tool_use")).toBe(false); + // The assistant turn survives as text so the transcript keeps its shape. + const assistant = messagesOf(params).filter((message) => message.role === "assistant"); + expect(assistant).toHaveLength(1); + expect(blocksOf(assistant[0]!).every((block) => block.type === "text")).toBe(true); + expect(JSON.stringify(params)).not.toContain('"tool_name":"mcp__925c__gone"'); + }); + + it("folds a recased gateway-namespaced native search reference onto the request's tool name", async () => { + // Live 2026-09-08 (session 01a08016): the search result came back as + // mcp__a4e6__Memory / mcp__a4e6__LspSymbols / mcp__a4e6__XSearch for the + // request tools memory / lsp_symbols / x_search; a hyphenated MCP tool kept + // its literal name under the namespace. + const context: Context = { + messages: [ + userMessage("find a tool"), + nativeSearchTurn([ + "mcp__a4e6__Memory", + "mcp__a4e6__LspSymbols", + "mcp__a4e6__XSearch", + "mcp__a4e6__cloudflare-docs_search_cloudflare_documentation", + ]), + userMessage("done"), + ], + tools: [ + makeTool("tool_search"), + makeTool("memory"), + makeTool("lsp_symbols"), + makeTool("x_search"), + makeTool("cloudflare-docs_search_cloudflare_documentation"), + ], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(nativeSearchReferenceNames(params)).toEqual([ + "memory", + "lsp_symbols", + "x_search", + "cloudflare-docs_search_cloudflare_documentation", + ]); + expect(allBlocks(params).some((block) => block.type === "server_tool_use")).toBe(true); + }); + + it("drops a recased reference when two request tools fold onto the same name", async () => { + const context: Context = { + messages: [ + userMessage("find a tool"), + nativeSearchTurn(["mcp__a4e6__XSearch", "mcp__a4e6__Memory"]), + userMessage("done"), + ], + tools: [makeTool("tool_search"), makeTool("x_search"), makeTool("x-search"), makeTool("memory")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + expect(nativeSearchReferenceNames(params)).toEqual(["memory"]); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 45ab1fe63..51d3dd23c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,8 @@ - A native tool-search 400 no longer demotes the session to a weaker model: the turn is retried once in place on the same model with native injection already disabled for the session, and only a second rejection consults the fallback chain (senpi #1482). +- Anthropic requests no longer lose (or, on the shipped 2026.9.7-2 engine, fail on) native tool-search references that a gateway hands back both namespaced and recased, such as `mcp__a4e6__Memory` for the request tool `memory` or `mcp__a4e6__LspSymbols` for `lsp_symbols`: the reference repair now folds case and `_`/`-` separators when matching a reference to the request's own tools, and only when exactly one request tool matches, so the discovered tools stay callable instead of being dropped or hard-erroring the turn into a fallback model. + - `/gpt-account add` now shows the OpenAI Codex login-method chooser as a real selector (`Browser login (default)` / `Device code login (headless)`) instead of an empty text input that failed with `Unknown OpenAI Codex login method:` on Enter. The device-code flow prints the user code next to the verification URL, the browser flow opens the browser in the terminal UI and still prints the URL, and the paste-the-code dialog closes by itself once the local callback completes the login. `/claude-account add` shares the same prompt relay ([#1485](https://github.com/code-yeongyu/senpi/issues/1485)). - Anthropic requests no longer fail with `Tool reference '' not found in available tools` after a native tool search: references that come back under a gateway namespace (`mcp____`) are folded onto the request's own tool names before the request is sent, references that no longer resolve are dropped, and a search result left with no references is demoted to text instead of being replayed verbatim. A history tool call whose only justification was such a dangling reference is demoted like any other unavailable call, so one stale native search result can no longer hard-error the model and force a fallback.