Skip to content

Commit 7e23447

Browse files
committed
fix(anthropic): quarantine invalid projected tools
1 parent 2d61521 commit 7e23447

5 files changed

Lines changed: 421 additions & 41 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { RuntimeToolSchemaDiagnostic } from "./tool-schema-projection.js";
2+
import { projectRuntimeToolInputSchema } from "./tool-schema-projection.js";
3+
4+
type ToolProjectionField = "name" | "description" | "parameters";
5+
type AnthropicToolChoiceMode = "auto" | "any" | "none";
6+
type AnthropicToolChoiceWithDisable = {
7+
readonly disable_parallel_tool_use?: boolean;
8+
};
9+
export type AnthropicResolvedToolChoice =
10+
| AnthropicToolChoiceMode
11+
| ({ readonly type: "auto" } & AnthropicToolChoiceWithDisable)
12+
| ({ readonly type: "any" } & AnthropicToolChoiceWithDisable)
13+
| { readonly type: "none" }
14+
| ({ readonly type: "tool"; readonly name: string } & AnthropicToolChoiceWithDisable);
15+
16+
type AnthropicToolProjectionDescriptor = {
17+
readonly [key in ToolProjectionField]?: unknown;
18+
};
19+
20+
export type AnthropicProjectedTool = {
21+
readonly originalIndex: number;
22+
readonly name: string;
23+
readonly description?: string;
24+
readonly parameters: Record<string, unknown> & { required?: string[] };
25+
};
26+
27+
export type AnthropicToolProjectionSnapshot = {
28+
readonly tools: readonly AnthropicProjectedTool[];
29+
readonly diagnostics: readonly RuntimeToolSchemaDiagnostic[];
30+
};
31+
32+
function readObjectStringField(value: unknown, field: string): string | undefined {
33+
return isRecord(value) && typeof value[field] === "string" ? value[field] : undefined;
34+
}
35+
36+
function readDisableParallelToolUse(value: unknown): AnthropicToolChoiceWithDisable {
37+
return isRecord(value) && typeof value.disable_parallel_tool_use === "boolean"
38+
? { disable_parallel_tool_use: value.disable_parallel_tool_use }
39+
: {};
40+
}
41+
42+
function isAnthropicToolChoiceMode(value: string): value is AnthropicToolChoiceMode {
43+
return value === "auto" || value === "any" || value === "none";
44+
}
45+
46+
function readToolField(
47+
tool: object,
48+
field: ToolProjectionField,
49+
): { ok: true; value: unknown } | { ok: false } {
50+
try {
51+
return { ok: true, value: Reflect.get(tool, field) };
52+
} catch {
53+
return { ok: false };
54+
}
55+
}
56+
57+
function readToolEntry(
58+
tools: readonly AnthropicToolProjectionDescriptor[],
59+
toolIndex: number,
60+
): { ok: true; tool: unknown } | { ok: false } {
61+
try {
62+
return { ok: true, tool: Reflect.get(tools, String(toolIndex)) };
63+
} catch {
64+
return { ok: false };
65+
}
66+
}
67+
68+
function isRecord(value: unknown): value is Record<string, unknown> {
69+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
70+
}
71+
72+
function unreadableDiagnostic(toolIndex: number): RuntimeToolSchemaDiagnostic {
73+
return {
74+
toolName: `tool[${toolIndex}]`,
75+
toolIndex,
76+
violations: [`tool[${toolIndex}] is unreadable`],
77+
};
78+
}
79+
80+
function normalizeRequired(value: unknown): string[] | undefined {
81+
return Array.isArray(value)
82+
? value.filter((entry): entry is string => typeof entry === "string")
83+
: undefined;
84+
}
85+
86+
export function snapshotAnthropicToolProjectionInputs(
87+
tools: readonly AnthropicToolProjectionDescriptor[] | undefined,
88+
): AnthropicToolProjectionSnapshot {
89+
if (!tools) {
90+
return { tools: [], diagnostics: [] };
91+
}
92+
93+
let length: number;
94+
try {
95+
length = tools.length;
96+
} catch {
97+
return { tools: [], diagnostics: [unreadableDiagnostic(0)] };
98+
}
99+
100+
const projectedTools: AnthropicProjectedTool[] = [];
101+
const diagnostics: RuntimeToolSchemaDiagnostic[] = [];
102+
for (let toolIndex = 0; toolIndex < length; toolIndex += 1) {
103+
const entry = readToolEntry(tools, toolIndex);
104+
if (!entry.ok || !isRecord(entry.tool)) {
105+
diagnostics.push(unreadableDiagnostic(toolIndex));
106+
continue;
107+
}
108+
109+
const name = readToolField(entry.tool, "name");
110+
const toolName =
111+
name.ok && typeof name.value === "string" && name.value ? name.value : `tool[${toolIndex}]`;
112+
const descriptorViolations = name.ok ? [] : [`${toolName}.name is unreadable`];
113+
if (!name.ok || typeof name.value !== "string" || !name.value) {
114+
diagnostics.push({
115+
toolName,
116+
toolIndex,
117+
violations:
118+
descriptorViolations.length > 0
119+
? descriptorViolations
120+
: [`${toolName}.name must be a non-empty string`],
121+
});
122+
continue;
123+
}
124+
125+
const parameters = readToolField(entry.tool, "parameters");
126+
if (!parameters.ok) {
127+
diagnostics.push({
128+
toolName,
129+
toolIndex,
130+
violations: [`${toolName}.parameters is unreadable`],
131+
});
132+
continue;
133+
}
134+
135+
const schemaProjection = projectRuntimeToolInputSchema(
136+
parameters.value,
137+
`${toolName}.parameters`,
138+
);
139+
if (schemaProjection.violations.length > 0 || !isRecord(schemaProjection.schema)) {
140+
diagnostics.push({
141+
toolName,
142+
toolIndex,
143+
violations: schemaProjection.violations,
144+
});
145+
continue;
146+
}
147+
148+
const description = readToolField(entry.tool, "description");
149+
const required = normalizeRequired(schemaProjection.schema.required);
150+
projectedTools.push({
151+
originalIndex: toolIndex,
152+
name: name.value,
153+
...(description.ok && typeof description.value === "string"
154+
? { description: description.value }
155+
: {}),
156+
parameters: {
157+
...schemaProjection.schema,
158+
...(required ? { required } : {}),
159+
},
160+
});
161+
}
162+
163+
return { tools: projectedTools, diagnostics };
164+
}
165+
166+
export function resolveAnthropicToolChoiceForProjectedTools(
167+
toolChoice: unknown,
168+
toolNames: readonly string[],
169+
): AnthropicResolvedToolChoice | undefined {
170+
if (typeof toolChoice === "string") {
171+
return isAnthropicToolChoiceMode(toolChoice) && (toolChoice === "none" || toolNames.length > 0)
172+
? toolChoice
173+
: undefined;
174+
}
175+
if (!isRecord(toolChoice)) {
176+
return undefined;
177+
}
178+
const type = readObjectStringField(toolChoice, "type");
179+
if (type === "none") {
180+
return { type };
181+
}
182+
if ((type === "auto" || type === "any") && toolNames.length > 0) {
183+
return { type, ...readDisableParallelToolUse(toolChoice) };
184+
}
185+
if (type === "tool") {
186+
const name = readObjectStringField(toolChoice, "name");
187+
return name && toolNames.includes(name)
188+
? { type, name, ...readDisableParallelToolUse(toolChoice) }
189+
: { type: "none" };
190+
}
191+
return undefined;
192+
}

src/agents/anthropic-transport-stream.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,7 @@ describe("anthropic transport stream", () => {
672672
} as unknown as Parameters<typeof streamFn>[1],
673673
{
674674
apiKey: "sk-ant-oat-example",
675+
toolChoice: { type: "tool", name: "Read" },
675676
} as Parameters<typeof streamFn>[2],
676677
),
677678
);
@@ -700,6 +701,7 @@ describe("anthropic transport stream", () => {
700701
(item) => requireRecord(item, "tool").name === "Read",
701702
),
702703
).toBe(true);
704+
expect(firstCallParams.tool_choice).toEqual({ type: "tool", name: "Read" });
703705
expect(result.stopReason).toBe("toolUse");
704706
expect(result.content.some((item) => item.type === "toolCall" && item.name === "read")).toBe(
705707
true,
@@ -1221,11 +1223,20 @@ describe("anthropic transport stream", () => {
12211223
});
12221224

12231225
it("skips malformed tools when building Anthropic payloads", async () => {
1226+
const unreadableTool = {
1227+
name: "unreadable_plugin_tool",
1228+
description: "unreadable schema",
1229+
get parameters(): unknown {
1230+
throw new Error("fuzz parameters getter exploded");
1231+
},
1232+
};
1233+
12241234
await runTransportStream(
12251235
makeAnthropicTransportModel(),
12261236
{
12271237
messages: [{ role: "user", content: "hello" }],
12281238
tools: [
1239+
unreadableTool,
12291240
{
12301241
name: "bad_plugin_tool",
12311242
description: "missing schema",
@@ -1258,6 +1269,70 @@ describe("anthropic transport stream", () => {
12581269
});
12591270
});
12601271

1272+
it("omits Anthropic tools and forced tool_choice when all tools are quarantined", async () => {
1273+
await runTransportStream(
1274+
makeAnthropicTransportModel(),
1275+
{
1276+
messages: [{ role: "user", content: "hello" }],
1277+
tools: [
1278+
{
1279+
name: "bad_plugin_tool",
1280+
description: "array schema",
1281+
parameters: { type: "array", items: { type: "number" } },
1282+
},
1283+
],
1284+
} as unknown as AnthropicStreamContext,
1285+
{
1286+
apiKey: "sk-ant-api",
1287+
toolChoice: "any",
1288+
} as AnthropicStreamOptions,
1289+
);
1290+
1291+
const payload = latestAnthropicRequest().payload;
1292+
expect(payload).not.toHaveProperty("tools");
1293+
expect(payload).not.toHaveProperty("tool_choice");
1294+
});
1295+
1296+
it("disables pinned Anthropic tool_choice when the pinned tool is quarantined", async () => {
1297+
await runTransportStream(
1298+
makeAnthropicTransportModel(),
1299+
{
1300+
messages: [{ role: "user", content: "hello" }],
1301+
tools: [
1302+
{
1303+
name: "bad_plugin_tool",
1304+
description: "dynamic schema",
1305+
parameters: {
1306+
type: "object",
1307+
properties: {
1308+
target: { $dynamicRef: "#target" },
1309+
},
1310+
},
1311+
},
1312+
{
1313+
name: "good_plugin_tool",
1314+
description: "valid schema",
1315+
parameters: {
1316+
type: "object",
1317+
properties: {
1318+
query: { type: "string" },
1319+
},
1320+
},
1321+
},
1322+
],
1323+
} as unknown as AnthropicStreamContext,
1324+
{
1325+
apiKey: "sk-ant-api",
1326+
toolChoice: { type: "tool", name: "bad_plugin_tool" },
1327+
} as AnthropicStreamOptions,
1328+
);
1329+
1330+
const payload = latestAnthropicRequest().payload;
1331+
const tools = requireArray(payload.tools, "tools");
1332+
expect(tools.map((tool) => requireRecord(tool, "tool").name)).toEqual(["good_plugin_tool"]);
1333+
expect(payload.tool_choice).toEqual({ type: "none" });
1334+
});
1335+
12611336
it("coerces replayed malformed tool-call args to an object for Anthropic payloads", async () => {
12621337
const model = makeAnthropicTransportModel({
12631338
requestTransport: {

src/agents/anthropic-transport-stream.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
applyAnthropicPayloadPolicyToParams,
1010
resolveAnthropicPayloadPolicy,
1111
} from "./anthropic-payload-policy.js";
12+
import {
13+
resolveAnthropicToolChoiceForProjectedTools,
14+
snapshotAnthropicToolProjectionInputs,
15+
} from "./anthropic-tool-schema.js";
1216
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
1317
import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js";
1418
import { resolveProviderEndpoint } from "./provider-attribution.js";
@@ -475,9 +479,7 @@ function ensureNonEmptyAnthropicMessages(messages: Array<Record<string, unknown>
475479
}
476480

477481
function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
478-
if (!tools) {
479-
return [];
480-
}
482+
const projection = snapshotAnthropicToolProjectionInputs(tools);
481483
const converted: Array<{
482484
name: string;
483485
description?: string;
@@ -487,27 +489,18 @@ function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
487489
required: unknown;
488490
};
489491
}> = [];
490-
for (const tool of tools) {
491-
// Main quarantine happens when plugin tools materialize; this keeps Anthropic
492-
// safe for direct/custom tool arrays that bypass the plugin registry.
493-
const parameters =
494-
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
495-
? (tool.parameters as Record<string, unknown>)
496-
: undefined;
497-
if (!parameters) {
498-
continue;
499-
}
492+
for (const tool of projection.tools) {
500493
converted.push({
501494
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
502495
description: tool.description,
503496
input_schema: {
504497
type: "object",
505-
properties: parameters.properties || {},
506-
required: parameters.required || [],
498+
properties: tool.parameters.properties || {},
499+
required: tool.parameters.required || [],
507500
},
508501
});
509502
}
510-
return converted;
503+
return { tools: converted, toolNames: converted.map((tool) => tool.name) };
511504
}
512505

513506
function parseAnthropicToolCallArguments(inputJson: string): unknown {
@@ -860,8 +853,13 @@ function buildAnthropicParams(
860853
if (options?.stop !== undefined && options.stop.length > 0) {
861854
params.stop_sequences = options.stop;
862855
}
856+
let projectedToolNames: string[] = [];
863857
if (context.tools) {
864-
params.tools = convertAnthropicTools(context.tools, isOAuthToken);
858+
const convertedTools = convertAnthropicTools(context.tools, isOAuthToken);
859+
projectedToolNames = convertedTools.toolNames;
860+
if (convertedTools.tools.length > 0) {
861+
params.tools = convertedTools.tools;
862+
}
865863
}
866864
if (model.reasoning) {
867865
if (options?.thinkingEnabled) {
@@ -883,9 +881,12 @@ function buildAnthropicParams(
883881
if (options?.metadata && typeof options.metadata.user_id === "string") {
884882
params.metadata = { user_id: options.metadata.user_id };
885883
}
886-
if (options?.toolChoice) {
884+
const projectedToolChoice =
885+
options?.toolChoice &&
886+
resolveAnthropicToolChoiceForProjectedTools(options.toolChoice, projectedToolNames);
887+
if (projectedToolChoice) {
887888
params.tool_choice =
888-
typeof options.toolChoice === "string" ? { type: options.toolChoice } : options.toolChoice;
889+
typeof projectedToolChoice === "string" ? { type: projectedToolChoice } : projectedToolChoice;
889890
}
890891
applyAnthropicPayloadPolicyToParams(params, payloadPolicy);
891892
return params;

0 commit comments

Comments
 (0)