Skip to content

Commit 9a29bd9

Browse files
committed
fix(agents): skip unreadable Anthropic tool schemas
1 parent e38b8f6 commit 9a29bd9

2 files changed

Lines changed: 117 additions & 14 deletions

File tree

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

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,16 @@ describe("anthropic transport stream", () => {
649649
},
650650
},
651651
});
652+
const unreadableNameTool = {
653+
description: "Broken name",
654+
parameters: { type: "object", properties: {} },
655+
};
656+
Object.defineProperty(unreadableNameTool, "name", {
657+
enumerable: true,
658+
get() {
659+
throw new Error("anthropic tool name exploded");
660+
},
661+
});
652662
const streamFn = createAnthropicMessagesTransportStreamFn();
653663
const stream = await Promise.resolve(
654664
streamFn(
@@ -657,6 +667,7 @@ describe("anthropic transport stream", () => {
657667
systemPrompt: "Follow policy.",
658668
messages: [{ role: "user", content: "Read the file" }],
659669
tools: [
670+
unreadableNameTool,
660671
{
661672
name: "read",
662673
description: "Read a file",
@@ -1221,6 +1232,25 @@ describe("anthropic transport stream", () => {
12211232
});
12221233

12231234
it("skips malformed tools when building Anthropic payloads", async () => {
1235+
const unreadableParametersTool = {
1236+
name: "bad_parameters_tool",
1237+
description: "unreadable schema",
1238+
execute: async () => ({ content: [{ type: "text", text: "bad" }] }),
1239+
};
1240+
Object.defineProperty(unreadableParametersTool, "parameters", {
1241+
enumerable: true,
1242+
get() {
1243+
throw new Error("anthropic tool parameters exploded");
1244+
},
1245+
});
1246+
const unreadablePropertiesSchema: Record<string, unknown> = { type: "object" };
1247+
Object.defineProperty(unreadablePropertiesSchema, "properties", {
1248+
enumerable: true,
1249+
get() {
1250+
throw new Error("anthropic tool properties exploded");
1251+
},
1252+
});
1253+
12241254
await runTransportStream(
12251255
makeAnthropicTransportModel(),
12261256
{
@@ -1231,6 +1261,21 @@ describe("anthropic transport stream", () => {
12311261
description: "missing schema",
12321262
execute: async () => ({ content: [{ type: "text", text: "bad" }] }),
12331263
},
1264+
unreadableParametersTool,
1265+
{
1266+
name: "bad_properties_tool",
1267+
description: "unreadable properties",
1268+
parameters: unreadablePropertiesSchema,
1269+
},
1270+
{
1271+
name: "null_schema_fields_tool",
1272+
description: "null schema fields",
1273+
parameters: {
1274+
type: "object",
1275+
properties: null,
1276+
required: null,
1277+
},
1278+
},
12341279
{
12351280
name: "good_plugin_tool",
12361281
description: "valid schema",
@@ -1250,8 +1295,13 @@ describe("anthropic transport stream", () => {
12501295
);
12511296

12521297
const tools = requireArray(latestAnthropicRequest().payload.tools, "tools");
1253-
expect(tools).toHaveLength(1);
1254-
const tool = requireRecord(tools[0], "tool");
1298+
expect(tools).toHaveLength(2);
1299+
const nullFieldTool = findRecord(tools, (record) => record.name === "null_schema_fields_tool");
1300+
expect(requireRecord(nullFieldTool.input_schema, "input schema")).toMatchObject({
1301+
properties: {},
1302+
required: [],
1303+
});
1304+
const tool = findRecord(tools, (record) => record.name === "good_plugin_tool");
12551305
expect(tool.name).toBe("good_plugin_tool");
12561306
expect(requireRecord(tool.input_schema, "input schema").properties).toEqual({
12571307
query: { type: "string" },

src/agents/anthropic-transport-stream.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,16 @@ function toClaudeCodeName(name: string): string {
243243
function fromClaudeCodeName(name: string, tools: Context["tools"] | undefined): string {
244244
if (tools && tools.length > 0) {
245245
const lowerName = normalizeLowercaseStringOrEmpty(name);
246-
const matchedTool = tools.find(
247-
(tool) => normalizeLowercaseStringOrEmpty(tool.name) === lowerName,
248-
);
249-
if (matchedTool) {
250-
return matchedTool.name;
246+
for (const tool of tools) {
247+
let toolName: unknown;
248+
try {
249+
toolName = tool.name;
250+
} catch {
251+
continue;
252+
}
253+
if (typeof toolName === "string" && normalizeLowercaseStringOrEmpty(toolName) === lowerName) {
254+
return toolName;
255+
}
251256
}
252257
}
253258
return name;
@@ -474,6 +479,31 @@ function ensureNonEmptyAnthropicMessages(messages: Array<Record<string, unknown>
474479
: [{ role: "user", content: EMPTY_ANTHROPIC_MESSAGES_FALLBACK_TEXT }];
475480
}
476481

482+
type AnthropicToolCandidate = NonNullable<Context["tools"]>[number];
483+
type AnthropicToolFieldRead = { readable: true; value: unknown } | { readable: false };
484+
485+
function readAnthropicToolField(
486+
tool: AnthropicToolCandidate,
487+
field: "name" | "description" | "parameters",
488+
): AnthropicToolFieldRead {
489+
try {
490+
return { readable: true, value: (tool as unknown as Record<string, unknown>)[field] };
491+
} catch {
492+
return { readable: false };
493+
}
494+
}
495+
496+
function readAnthropicSchemaField(
497+
schema: Record<string, unknown>,
498+
field: "properties" | "required",
499+
): AnthropicToolFieldRead {
500+
try {
501+
return { readable: true, value: schema[field] };
502+
} catch {
503+
return { readable: false };
504+
}
505+
}
506+
477507
function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
478508
if (!tools) {
479509
return [];
@@ -490,20 +520,43 @@ function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
490520
for (const tool of tools) {
491521
// Main quarantine happens when plugin tools materialize; this keeps Anthropic
492522
// safe for direct/custom tool arrays that bypass the plugin registry.
523+
const nameRead = readAnthropicToolField(tool, "name");
524+
const parametersRead = readAnthropicToolField(tool, "parameters");
525+
if (!nameRead.readable || !parametersRead.readable) {
526+
continue;
527+
}
528+
const rawParameters = parametersRead.value;
493529
const parameters =
494-
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
495-
? (tool.parameters as Record<string, unknown>)
530+
rawParameters && typeof rawParameters === "object" && !Array.isArray(rawParameters)
531+
? (rawParameters as Record<string, unknown>)
496532
: undefined;
497-
if (!parameters) {
533+
if (typeof nameRead.value !== "string" || !parameters) {
534+
continue;
535+
}
536+
const propertiesRead = readAnthropicSchemaField(parameters, "properties");
537+
const requiredRead = readAnthropicSchemaField(parameters, "required");
538+
const descriptionRead = readAnthropicToolField(tool, "description");
539+
if (!propertiesRead.readable || !requiredRead.readable) {
540+
continue;
541+
}
542+
const properties = propertiesRead.value;
543+
const required = requiredRead.value;
544+
if (
545+
(properties !== undefined && properties !== null && typeof properties !== "object") ||
546+
(required !== undefined && required !== null && !Array.isArray(required))
547+
) {
498548
continue;
499549
}
500550
converted.push({
501-
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
502-
description: tool.description,
551+
name: isOAuthToken ? toClaudeCodeName(nameRead.value) : nameRead.value,
552+
description:
553+
descriptionRead.readable && typeof descriptionRead.value === "string"
554+
? descriptionRead.value
555+
: undefined,
503556
input_schema: {
504557
type: "object",
505-
properties: parameters.properties || {},
506-
required: parameters.required || [],
558+
properties: properties ?? {},
559+
required: required ?? [],
507560
},
508561
});
509562
}

0 commit comments

Comments
 (0)