Skip to content

Commit c5fe109

Browse files
committed
fix(agents): guard Anthropic tool schema conversion
1 parent 2e8b444 commit c5fe109

2 files changed

Lines changed: 104 additions & 9 deletions

File tree

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,6 +1258,69 @@ describe("anthropic transport stream", () => {
12581258
});
12591259
});
12601260

1261+
it("skips tools with unreadable schemas when building Anthropic payloads", async () => {
1262+
const unreadableTool = {
1263+
name: "bad_plugin_tool",
1264+
description: "unreadable schema",
1265+
parameters: {
1266+
type: "object",
1267+
properties: {},
1268+
},
1269+
execute: async () => ({ content: [{ type: "text", text: "bad" }] }),
1270+
};
1271+
Object.defineProperty(unreadableTool, "parameters", {
1272+
enumerable: true,
1273+
get() {
1274+
throw new Error("fuzzplugin parameters getter exploded");
1275+
},
1276+
});
1277+
const unreadablePropertiesTool = {
1278+
name: "bad_nested_plugin_tool",
1279+
description: "unreadable nested schema",
1280+
parameters: {
1281+
type: "object",
1282+
properties: {},
1283+
},
1284+
execute: async () => ({ content: [{ type: "text", text: "bad" }] }),
1285+
};
1286+
Object.defineProperty(unreadablePropertiesTool.parameters, "properties", {
1287+
enumerable: true,
1288+
get() {
1289+
throw new Error("fuzzplugin properties getter exploded");
1290+
},
1291+
});
1292+
1293+
await runTransportStream(
1294+
makeAnthropicTransportModel(),
1295+
{
1296+
messages: [{ role: "user", content: "hello" }],
1297+
tools: [
1298+
unreadableTool,
1299+
unreadablePropertiesTool,
1300+
{
1301+
name: "good_plugin_tool",
1302+
description: "valid schema",
1303+
parameters: {
1304+
type: "object",
1305+
properties: {
1306+
query: { type: "string" },
1307+
},
1308+
required: ["query"],
1309+
},
1310+
},
1311+
],
1312+
} as unknown as AnthropicStreamContext,
1313+
{
1314+
apiKey: "sk-ant-api",
1315+
} as AnthropicStreamOptions,
1316+
);
1317+
1318+
const tools = requireArray(latestAnthropicRequest().payload.tools, "tools");
1319+
expect(tools).toHaveLength(1);
1320+
const tool = requireRecord(tools[0], "tool");
1321+
expect(tool.name).toBe("good_plugin_tool");
1322+
});
1323+
12611324
it("coerces replayed malformed tool-call args to an object for Anthropic payloads", async () => {
12621325
const model = makeAnthropicTransportModel({
12631326
requestTransport: {

src/agents/anthropic-transport-stream.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -490,26 +490,58 @@ function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
490490
for (const tool of tools) {
491491
// Main quarantine happens when plugin tools materialize; this keeps Anthropic
492492
// 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) {
493+
const snapshot = readAnthropicToolSnapshot(tool);
494+
if (!snapshot) {
498495
continue;
499496
}
500497
converted.push({
501-
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
502-
description: tool.description,
498+
name: isOAuthToken ? toClaudeCodeName(snapshot.name) : snapshot.name,
499+
...(snapshot.description !== undefined ? { description: snapshot.description } : {}),
503500
input_schema: {
504501
type: "object",
505-
properties: parameters.properties || {},
506-
required: parameters.required || [],
502+
properties: snapshot.properties,
503+
required: snapshot.required,
507504
},
508505
});
509506
}
510507
return converted;
511508
}
512509

510+
type AnthropicToolSnapshot = {
511+
name: string;
512+
description?: string;
513+
properties: unknown;
514+
required: unknown;
515+
};
516+
517+
function readAnthropicToolSnapshot(
518+
tool: NonNullable<Context["tools"]>[number],
519+
): AnthropicToolSnapshot | undefined {
520+
try {
521+
const name = tool.name;
522+
const parameters = tool.parameters;
523+
if (
524+
typeof name !== "string" ||
525+
!name.trim() ||
526+
!parameters ||
527+
typeof parameters !== "object" ||
528+
Array.isArray(parameters)
529+
) {
530+
return undefined;
531+
}
532+
const description = typeof tool.description === "string" ? tool.description : undefined;
533+
const record = parameters as Record<string, unknown>;
534+
return {
535+
name,
536+
...(description !== undefined ? { description } : {}),
537+
properties: record.properties || {},
538+
required: record.required || [],
539+
};
540+
} catch {
541+
return undefined;
542+
}
543+
}
544+
513545
function parseAnthropicToolCallArguments(inputJson: string): unknown {
514546
return parseJsonObjectPreservingUnsafeIntegers(inputJson) ?? parseStreamingJson(inputJson);
515547
}

0 commit comments

Comments
 (0)