Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 126 additions & 49 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__<id>__<tool>`) 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>): 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<string, unknown> & {
type: "tool_search_tool_result";
tool_use_id: string;
content: Record<string, unknown> & { 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;
Expand All @@ -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<string>();
collectToolReferenceNames(messages, discoveredNames);
const resolve = (name: string): string | undefined => resolveAvailableToolName(name, definedNames);

const demotedCallNames = new Map<string, string>();
const renamedCallNames = new Map<string, string>();
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<string>();
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<string>();
Expand All @@ -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<string>();
const droppedSearchNames = new Map<string, string[]>();
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<string, unknown> => 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") {
Expand All @@ -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);
Expand All @@ -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;
}
Expand All @@ -989,14 +1043,37 @@ function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming):
return { ...params, messages: rewrittenMessages };
}

function collectToolReferenceNames(value: unknown, names: Set<string>): 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 {
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -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__<id>__<tool>`) 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
Expand Down
Loading