diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2cb3a9241..a2ee092ec 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -871,7 +871,41 @@ function sanitizeUnsupportedNativeTools( * 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) + ); +} + function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming { const messages = params.messages; if (!Array.isArray(messages) || messages.length === 0) return params; @@ -882,37 +916,20 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): if (isRecord(tool) && typeof tool.name === "string") definedNames.add(tool.name); } } - - // `tool_reference` blocks — emitted for deferred tools or replayed from a - // server-side tool search — make their targets available without a - // non-deferred definition. - const discoveredNames = new Set(); - collectToolReferenceNames(messages, discoveredNames); + 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" && - !definedNames.has(block.name) && - !discoveredNames.has(block.name) - ) { - demotedCallNames.set(block.id, block.name); - } + 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); } } - // A `tool_reference` without its definition 400s the same way. - const danglingReferenceNames = new Set(); - for (const name of discoveredNames) { - if (!definedNames.has(name)) danglingReferenceNames.add(name); - } - - if (demotedCallNames.size === 0 && danglingReferenceNames.size === 0) return params; - let changed = false; const availableToolNames = [...definedNames]; const seenDemotedCallNames = new Set(); @@ -923,6 +940,22 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): 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") { @@ -937,6 +970,35 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): }); 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); @@ -945,27 +1007,19 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): content.push({ type: "text", text: demotedToolResultText(demotedName, toolResultText(block.content)) }); continue; } - if (danglingReferenceNames.size > 0 && Array.isArray(block.content)) { - const kept: unknown[] = []; - const omitted: string[] = []; - for (const item of block.content) { - if ( - isRecord(item) && - item.type === "tool_reference" && - typeof item.tool_name === "string" && - danglingReferenceNames.has(item.tool_name) - ) { - omitted.push(item.tool_name); - continue; - } - kept.push(item); - } - if (omitted.length > 0) { + if (Array.isArray(block.content)) { + const rewritten = rewriteToolReferenceItems(block.content, resolve); + if (rewritten !== undefined) { messageChanged = true; const nextContent = - kept.length > 0 - ? kept - : [{ type: "text", text: `Tool reference unavailable: ${[...new Set(omitted)].join(", ")}` }]; + 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; } @@ -989,14 +1043,37 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): return { ...params, messages: rewrittenMessages }; } -function collectToolReferenceNames(value: unknown, names: Set): void { - if (Array.isArray(value)) { - for (const item of value) collectToolReferenceNames(item, names); - return; +/** + * 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); } - if (!isRecord(value)) return; - if (value.type === "tool_reference" && typeof value.tool_name === "string") names.add(value.tool_name); - for (const nested of Object.values(value)) collectToolReferenceNames(nested, names); + return rewritten ? { kept, omitted } : undefined; } function toolResultText(content: unknown): string { diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 99c9d957b..045e166f1 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,4 +1,23 @@ +## 2026-09-08 - Anthropic tool references resolve against the request's own tools (senpi native tool-search 400) + +### What changed + +- `packages/ai/src/api/anthropic-messages.ts`: `demoteUnavailableToolReferences` now decides availability from the final `tools` array alone and repairs every reference site. A `tool_reference` whose `tool_name` carries a gateway namespace (`mcp____`) is folded back to the request's own tool name when that tool is defined (`resolveAvailableToolName`); a reference that still does not resolve is dropped. Replayed native `tool_search_tool_result` blocks are repaired the same way (`rewriteToolReferenceItems`), and a search pair whose every reference stopped resolving is demoted to text together with its `server_tool_use`. A history `tool_use` under a gateway namespace is renamed to the request's tool name; a `tool_use` whose only justification was a dangling discovery is demoted like any other unavailable call. `collectToolReferenceNames` is gone: discovered names no longer stand in for missing definitions. +- `packages/ai/test/anthropic-tool-reference-integrity.test.ts`: five cases pin the invariant (namespaced native reference folded to `memory`; mixed list keeps the resolvable names; emptied search pair demoted; namespaced history `tool_use` renamed; dangling discovery no longer keeps its `tool_use`). + +### Why + +- Live 2026-09-08 (senpi 4adba7afb, omo desktop, claude-fable-5-1): a native tool search returned `tool_reference` names as `mcp__925c__memory`, `mcp__925c__todo`, ... — a namespace neither senpi nor the request defined — and the block replayed verbatim on the next request, which Anthropic rejected with `Tool reference 'mcp__925c__memory' not found in available tools`. The turn hard-errored and fell back to a weaker model. The repair pass saw the names as dangling but only rewrote `tool_result` content, so native results fell through untouched, and a dangling discovery still exempted a later `tool_use` from demotion. + +### Why an extension could not handle it + +- The reference repair runs after every `before_provider_request` hook, immediately before the SDK call, against the final tools array; an extension cannot see that array or the replayed provider-native blocks the provider itself assembles from history. + +### Expected merge conflict zones + +- MEDIUM: the `demoteUnavailableToolReferences` block and its helpers in `packages/ai/src/api/anthropic-messages.ts` (upstream has no gateway-namespace handling); LOW: the integrity test file (fork-only). + ## 2026-09-08 - Simple stream options carry the requested service tier (code-yeongyu/oh-my-openagent#6795) ### What changed diff --git a/packages/ai/test/anthropic-tool-reference-integrity.test.ts b/packages/ai/test/anthropic-tool-reference-integrity.test.ts index 4d120b6fe..ce4885ff1 100644 --- a/packages/ai/test/anthropic-tool-reference-integrity.test.ts +++ b/packages/ai/test/anthropic-tool-reference-integrity.test.ts @@ -4,7 +4,7 @@ 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 { Context, Tool, ToolResultMessage, UserMessage } from "../src/types.ts"; +import type { AssistantMessage, Context, Tool, ToolResultMessage, UserMessage } from "../src/types.ts"; /** * Anthropic rejects a request whose message history references a tool that is @@ -161,6 +161,53 @@ function toolNamesIn(params: Record): 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 = { @@ -299,4 +346,108 @@ 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 = { + messages: [ + userMessage("remember this"), + fauxAssistantMessage(fauxToolCall("mcp__925c__memory", { input: "note" }, { id: "call_memory" }), { + stopReason: "toolUse", + }), + toolResultMessage("call_memory", "mcp__925c__memory", "stored"), + userMessage("done"), + ], + tools: [makeTool("memory")], + }; + + const params = await captureParams(context, undefined, "claude-sonnet-4-6"); + + const calls = toolUseBlocks(params); + expect(calls).toHaveLength(1); + expect(calls[0]?.name).toBe("memory"); + expect(toolResultBlocks(params).map((block) => block.tool_use_id)).toEqual(["call_memory"]); + expect(textBlocks(params).some((block) => block.text?.includes("no longer available"))).toBe(false); + }); + + it("demotes a history tool call whose only discovery was a stripped tool_reference", async () => { + const context: Context = { + messages: [ + userMessage("find a tool"), + fauxAssistantMessage(fauxToolCall("tool_search", { query: "drag" }, { id: "call_search" }), { + stopReason: "toolUse", + }), + toolResultMessage("call_search", "tool_search", "1 tool(s) activated", ["mcp_computer_use_drag"]), + fauxAssistantMessage(fauxToolCall("mcp_computer_use_drag", { x: 1 }, { id: "call_drag" }), { + stopReason: "toolUse", + }), + toolResultMessage("call_drag", "mcp_computer_use_drag", "dragged"), + userMessage("done"), + ], + tools: [makeTool("tool_search"), makeTool("mcp_computer_use_drag")], + }; + + const params = await captureParams( + context, + (payload) => { + const mutable = payload as { tools?: Array<{ name: string }> }; + mutable.tools = (mutable.tools ?? []).filter((tool) => tool.name !== "mcp_computer_use_drag"); + return payload; + }, + "claude-sonnet-4-6", + ); + + expect(toolNamesIn(params)).not.toContain("mcp_computer_use_drag"); + expect(toolUseBlocks(params).map((block) => block.name)).toEqual(["tool_search"]); + 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); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dde629f8c..997e0b50c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,8 @@ ### Fixed +- 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. + - The GPT-6 Astra prompt preset now does the work itself by default: anything that closes in a handful of calls is the model's own, a follow-up on work it delegated earlier is taken back rather than forwarded to the child, and only a sizeable independent track earns a subagent. The routing line opens a new request instead of every turn, so a steering message gets the work rather than a restatement of what was understood, and a new initiative rule consults stored memory for the user's preferences before asking anything memory may already answer. Observed across the 2026-09-06..08 sessions: Astra spent 15-39% of its tool calls on `task` / `task_send` against 2-4% for the Claude and Kimi presets on the same tools. - GPT-6 Astra variants now show the same high-reasoning warning as GPT-5.6 Sol at `xhigh` and `max` effort.