Skip to content

Commit a028131

Browse files
steipetevincentkoc
andauthored
fix(anthropic): quarantine invalid direct tool schemas (#92896)
Quarantine unreadable and structurally invalid direct/custom Anthropic tool schemas in both canonical request builders while preserving healthy siblings, forced-choice semantics, OAuth name mapping, and official OpenAI behavior. Supersedes #89418, #89221, #90228, #89622, #89229, and #90278. Co-authored-by: Vincent Koc <[email protected]>
1 parent 462c076 commit a028131

8 files changed

Lines changed: 760 additions & 214 deletions
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { projectRuntimeToolInputSchema } from "./tool-schema-json-projection.js";
2+
3+
type AnthropicToolDescriptor = {
4+
readonly name: string;
5+
readonly description: string;
6+
readonly parameters: unknown;
7+
};
8+
9+
export type AnthropicProjectedTool = {
10+
readonly originalName: string;
11+
readonly wireName: string;
12+
readonly description?: string;
13+
readonly inputSchema: {
14+
readonly type: "object";
15+
readonly properties: Record<string, unknown>;
16+
readonly required: string[];
17+
};
18+
};
19+
20+
export type AnthropicToolProjection = {
21+
readonly inputToolCount: number;
22+
readonly unavailableOriginalNames: ReadonlySet<string>;
23+
readonly tools: readonly AnthropicProjectedTool[];
24+
};
25+
26+
type AnthropicParallelToolChoice = {
27+
readonly disable_parallel_tool_use?: boolean;
28+
};
29+
30+
export type AnthropicProjectedToolChoice =
31+
| ({ readonly type: "auto" } & AnthropicParallelToolChoice)
32+
| ({ readonly type: "any" } & AnthropicParallelToolChoice)
33+
| { readonly type: "none" }
34+
| ({ readonly type: "tool"; readonly name: string } & AnthropicParallelToolChoice);
35+
36+
function isRecord(value: unknown): value is Record<string, unknown> {
37+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
38+
}
39+
40+
function isProviderSupportedViolation(violation: string): boolean {
41+
return violation.endsWith(".$dynamicRef") || violation.endsWith(".$dynamicAnchor");
42+
}
43+
44+
/** Snapshots direct/custom tool descriptors before Anthropic payload construction. */
45+
export function projectAnthropicTools(
46+
tools: readonly AnthropicToolDescriptor[],
47+
toWireName: (name: string) => string,
48+
): AnthropicToolProjection {
49+
const projectedTools: AnthropicProjectedTool[] = [];
50+
const unavailableOriginalNames = new Set<string>();
51+
for (const tool of tools) {
52+
let projectedTool: AnthropicProjectedTool;
53+
let originalName: string | undefined;
54+
try {
55+
const name = tool.name;
56+
originalName = name;
57+
if (!name) {
58+
continue;
59+
}
60+
const schemaProjection = projectRuntimeToolInputSchema(tool.parameters, `${name}.parameters`);
61+
if (
62+
!isRecord(schemaProjection.schema) ||
63+
schemaProjection.violations.some((violation) => !isProviderSupportedViolation(violation))
64+
) {
65+
unavailableOriginalNames.add(name);
66+
continue;
67+
}
68+
const properties = schemaProjection.schema.properties;
69+
const required = schemaProjection.schema.required;
70+
if (
71+
(properties !== undefined && properties !== null && !isRecord(properties)) ||
72+
(required !== undefined &&
73+
required !== null &&
74+
(!Array.isArray(required) || required.some((entry) => typeof entry !== "string")))
75+
) {
76+
unavailableOriginalNames.add(name);
77+
continue;
78+
}
79+
let description: string | undefined;
80+
try {
81+
description = typeof tool.description === "string" ? tool.description : undefined;
82+
} catch {
83+
// Description is optional; keep the usable tool schema.
84+
}
85+
const wireName = toWireName(name);
86+
projectedTool = {
87+
originalName: name,
88+
wireName,
89+
...(description ? { description } : {}),
90+
inputSchema: {
91+
type: "object",
92+
properties: (properties ?? {}) as Record<string, unknown>,
93+
required: (required ?? []) as string[],
94+
},
95+
};
96+
} catch {
97+
// Direct/custom tool arrays can bypass the runtime quarantine.
98+
if (originalName) {
99+
unavailableOriginalNames.add(originalName);
100+
}
101+
continue;
102+
}
103+
const conflictingTool = projectedTools.find(
104+
(entry) => entry.wireName === projectedTool.wireName,
105+
);
106+
if (conflictingTool && conflictingTool.originalName !== projectedTool.originalName) {
107+
throw new Error(
108+
`Anthropic tool names "${conflictingTool.originalName}" and "${projectedTool.originalName}" both map to "${projectedTool.wireName}"`,
109+
);
110+
}
111+
projectedTools.push(projectedTool);
112+
}
113+
return {
114+
inputToolCount: tools.length,
115+
unavailableOriginalNames,
116+
tools: projectedTools,
117+
};
118+
}
119+
120+
/** Keeps forced Anthropic tool choices aligned with the projected wire names. */
121+
export function reconcileAnthropicToolChoice(
122+
choice: AnthropicProjectedToolChoice,
123+
projection: AnthropicToolProjection,
124+
): AnthropicProjectedToolChoice | undefined {
125+
if (projection.inputToolCount === 0) {
126+
return choice;
127+
}
128+
if (choice.type === "tool") {
129+
const requestedName = choice.name;
130+
const originalMatch = projection.tools.find((tool) => tool.originalName === requestedName);
131+
if (originalMatch) {
132+
return { ...choice, name: originalMatch.wireName };
133+
}
134+
if (projection.unavailableOriginalNames.has(requestedName)) {
135+
throw new Error(
136+
`Anthropic tool_choice requested unavailable tool "${requestedName}" after schema conversion`,
137+
);
138+
}
139+
const matchedTool = projection.tools.find((tool) => tool.wireName === requestedName);
140+
if (!matchedTool) {
141+
throw new Error(
142+
`Anthropic tool_choice requested unavailable tool "${requestedName}" after schema conversion`,
143+
);
144+
}
145+
return { ...choice, name: matchedTool.wireName };
146+
}
147+
if (projection.tools.length === 0) {
148+
if (choice.type === "auto") {
149+
return undefined;
150+
}
151+
if (choice.type === "any") {
152+
throw new Error(
153+
"Anthropic tool_choice requires a tool, but no tools survived schema conversion",
154+
);
155+
}
156+
}
157+
return choice;
158+
}
159+
160+
/** Maps Claude Code wire names without trusting every direct/custom descriptor. */
161+
export function resolveOriginalAnthropicToolName(
162+
name: string,
163+
projection: AnthropicToolProjection | undefined,
164+
): string {
165+
return projection?.tools.find((tool) => tool.wireName === name)?.originalName ?? name;
166+
}

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
import http from "node:http";
77
import type { Model } from "openclaw/plugin-sdk/llm";
88
import { describe, expect, it } from "vitest";
9+
import { streamAnthropic } from "../llm/providers/anthropic.js";
910
import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js";
1011
import { isLiveTestEnabled } from "./live-test-helpers.js";
1112

1213
const LIVE = isLiveTestEnabled(["ANTHROPIC_TRANSPORT_LIVE_TEST"]);
1314
const describeLive = LIVE ? describe : describe.skip;
15+
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY ?? "";
16+
const PROVIDER_LIVE = isLiveTestEnabled(["ANTHROPIC_LIVE_TEST"]) && Boolean(ANTHROPIC_KEY);
17+
const describeProviderLive = PROVIDER_LIVE ? describe : describe.skip;
1418

1519
type AnthropicMessagesModel = Model<"anthropic-messages">;
1620
type AnthropicStreamFn = ReturnType<typeof createAnthropicMessagesTransportStreamFn>;
@@ -144,3 +148,124 @@ describeLive("anthropic transport stream live", () => {
144148
}
145149
}, 10_000);
146150
});
151+
152+
describeProviderLive("anthropic transport stream provider live", () => {
153+
it("keeps a healthy forced tool when a sibling descriptor is unreadable", async () => {
154+
const modelId = process.env.OPENCLAW_LIVE_ANTHROPIC_TOOL_MODEL || "claude-haiku-4-5-20251001";
155+
const model: AnthropicMessagesModel = {
156+
id: modelId,
157+
name: modelId,
158+
api: "anthropic-messages",
159+
provider: "anthropic",
160+
baseUrl: "https://api.anthropic.com",
161+
reasoning: false,
162+
input: ["text"],
163+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
164+
contextWindow: 200_000,
165+
maxTokens: 512,
166+
};
167+
const streamFn = createAnthropicMessagesTransportStreamFn();
168+
const stream = await Promise.resolve(
169+
streamFn(
170+
model,
171+
{
172+
messages: [{ role: "user", content: "Call healthy_probe with value LIVE_OK." }],
173+
tools: [
174+
{
175+
name: "unreadable_probe",
176+
description: "Unreadable probe",
177+
get parameters() {
178+
throw new Error("live unreadable parameters getter");
179+
},
180+
},
181+
{
182+
name: "healthy_probe",
183+
description: "Return the requested probe value.",
184+
parameters: {
185+
type: "object",
186+
properties: { value: { type: "string" } },
187+
required: ["value"],
188+
},
189+
},
190+
],
191+
} as unknown as AnthropicStreamContext,
192+
{
193+
apiKey: ANTHROPIC_KEY,
194+
maxTokens: 128,
195+
toolChoice: { type: "tool", name: "healthy_probe" },
196+
} as AnthropicStreamOptions,
197+
),
198+
);
199+
200+
const result = await stream.result();
201+
const toolCall = result.content.find(
202+
(block) => block.type === "toolCall" && block.name === "healthy_probe",
203+
);
204+
205+
expect(result.stopReason).toBe("toolUse");
206+
expect(toolCall).toMatchObject({
207+
type: "toolCall",
208+
name: "healthy_probe",
209+
arguments: { value: "LIVE_OK" },
210+
});
211+
}, 45_000);
212+
213+
it("keeps a healthy forced tool through the Anthropic SDK provider", async () => {
214+
const modelId = process.env.OPENCLAW_LIVE_ANTHROPIC_TOOL_MODEL || "claude-haiku-4-5-20251001";
215+
const model: AnthropicMessagesModel = {
216+
id: modelId,
217+
name: modelId,
218+
api: "anthropic-messages",
219+
provider: "anthropic",
220+
baseUrl: "https://api.anthropic.com",
221+
reasoning: false,
222+
input: ["text"],
223+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
224+
contextWindow: 200_000,
225+
maxTokens: 512,
226+
};
227+
const stream = streamAnthropic(
228+
model,
229+
{
230+
messages: [
231+
{ role: "user", content: "Call healthy_probe with value SDK_OK.", timestamp: Date.now() },
232+
],
233+
tools: [
234+
{
235+
name: "unreadable_probe",
236+
description: "Unreadable probe",
237+
get parameters() {
238+
throw new Error("live unreadable parameters getter");
239+
},
240+
},
241+
{
242+
name: "healthy_probe",
243+
description: "Return the requested probe value.",
244+
parameters: {
245+
type: "object",
246+
properties: { value: { type: "string" } },
247+
required: ["value"],
248+
},
249+
},
250+
],
251+
} as unknown as Parameters<typeof streamAnthropic>[1],
252+
{
253+
apiKey: ANTHROPIC_KEY,
254+
maxTokens: 128,
255+
toolChoice: { type: "tool", name: "healthy_probe" },
256+
},
257+
);
258+
259+
const result = await stream.result();
260+
const toolCall = result.content.find(
261+
(block) => block.type === "toolCall" && block.name === "healthy_probe",
262+
);
263+
264+
expect(result.stopReason).toBe("toolUse");
265+
expect(toolCall).toMatchObject({
266+
type: "toolCall",
267+
name: "healthy_probe",
268+
arguments: { value: "SDK_OK" },
269+
});
270+
}, 45_000);
271+
});

0 commit comments

Comments
 (0)