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
231 changes: 1 addition & 230 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -861,242 +861,13 @@ 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 '<name>' 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__<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)
);
}

/** Numeric HTTP status carried by an SDK error (Anthropic APIError.status), if any. */
function httpStatusOfError(error: unknown): number | undefined {
if (!isRecord(error)) return undefined;
const status = error.status;
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<string>();
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<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") 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<string>();
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<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") {
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,
Expand Down
Loading