Skip to content

Commit 991471b

Browse files
authored
fix(mistral): skip unreadable tool schemas (#90242)
Skip unreadable Mistral tool schemas while preserving valid sibling tools. Omit empty tool payloads, reject forced choices for removed tools, and snapshot pinned tool names before validation and emission. Live Mistral E2E: run_9b34d30e9f5b CI: https://github.com/openclaw/openclaw/actions/runs/27459679633 Co-authored-by: Vincent Koc <[email protected]>
1 parent 7190fc4 commit 991471b

2 files changed

Lines changed: 200 additions & 13 deletions

File tree

src/llm/providers/mistral.test.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock("@mistralai/mistralai", () => ({
1717
},
1818
}));
1919

20-
import { streamSimpleMistral } from "./mistral.js";
20+
import { streamMistral, streamSimpleMistral } from "./mistral.js";
2121

2222
function makeMistralModel(): Model<"mistral-conversations"> {
2323
return {
@@ -38,6 +38,24 @@ const context = {
3838
messages: [{ role: "user", content: "hello", timestamp: 0 }],
3939
} satisfies Context;
4040

41+
function makeUnreadableParameterTool() {
42+
const tool = {
43+
name: "broken_tool",
44+
description: "broken tool",
45+
parameters: { type: "object", properties: {} },
46+
async execute() {
47+
return { content: [{ type: "text", text: "broken" }] };
48+
},
49+
};
50+
Object.defineProperty(tool, "parameters", {
51+
enumerable: true,
52+
get() {
53+
throw new Error("fuzzplugin parameters getter exploded");
54+
},
55+
});
56+
return tool;
57+
}
58+
4159
describe("Mistral provider", () => {
4260
beforeEach(() => {
4361
mistralMockState.payloads = [];
@@ -54,4 +72,146 @@ describe("Mistral provider", () => {
5472
expect(result.stopReason).toBe("error");
5573
expect((mistralMockState.payloads[0] as { stop?: unknown }).stop).toEqual(["STOP"]);
5674
});
75+
76+
it("skips unreadable tool schemas while preserving healthy Mistral tools", async () => {
77+
const stream = streamMistral(
78+
makeMistralModel(),
79+
{
80+
...context,
81+
tools: [
82+
makeUnreadableParameterTool(),
83+
{
84+
name: "healthy_tool",
85+
description: "healthy tool",
86+
parameters: {
87+
type: "object",
88+
properties: {
89+
query: { type: "string" },
90+
},
91+
},
92+
async execute() {
93+
return { content: [{ type: "text", text: "ok" }] };
94+
},
95+
},
96+
] as never,
97+
},
98+
{
99+
apiKey: "sk-mistral-provider",
100+
},
101+
);
102+
103+
const result = await stream.result();
104+
105+
expect(result.stopReason).toBe("error");
106+
expect((mistralMockState.payloads[0] as { tools?: unknown[] }).tools).toEqual([
107+
{
108+
type: "function",
109+
function: {
110+
name: "healthy_tool",
111+
description: "healthy tool",
112+
parameters: {
113+
type: "object",
114+
properties: {
115+
query: { type: "string" },
116+
},
117+
},
118+
strict: false,
119+
},
120+
},
121+
]);
122+
});
123+
124+
it("omits tools and automatic tool choice when every schema is unreadable", async () => {
125+
const stream = streamMistral(
126+
makeMistralModel(),
127+
{
128+
...context,
129+
tools: [makeUnreadableParameterTool()] as never,
130+
},
131+
{
132+
apiKey: "sk-mistral-provider",
133+
toolChoice: "auto",
134+
},
135+
);
136+
137+
const result = await stream.result();
138+
const payload = mistralMockState.payloads[0] as Record<string, unknown>;
139+
140+
expect(result.stopReason).toBe("error");
141+
expect(payload).not.toHaveProperty("tools");
142+
expect(payload).not.toHaveProperty("toolChoice");
143+
});
144+
145+
it("fails locally when a pinned Mistral tool choice is skipped", async () => {
146+
const stream = streamMistral(
147+
makeMistralModel(),
148+
{
149+
...context,
150+
tools: [
151+
makeUnreadableParameterTool(),
152+
{
153+
name: "healthy_tool",
154+
description: "healthy tool",
155+
parameters: { type: "object", properties: {} },
156+
async execute() {
157+
return { content: [{ type: "text", text: "ok" }] };
158+
},
159+
},
160+
] as never,
161+
},
162+
{
163+
apiKey: "sk-mistral-provider",
164+
toolChoice: { type: "function", function: { name: "broken_tool" } },
165+
},
166+
);
167+
168+
const result = await stream.result();
169+
170+
expect(result.stopReason).toBe("error");
171+
expect(result.errorMessage).toContain(
172+
'Mistral tool_choice requested unavailable tool "broken_tool"',
173+
);
174+
expect(mistralMockState.payloads).toHaveLength(0);
175+
});
176+
177+
it("validates and emits one snapshot of a pinned Mistral tool name", async () => {
178+
let nameReads = 0;
179+
const stream = streamMistral(
180+
makeMistralModel(),
181+
{
182+
...context,
183+
tools: [
184+
{
185+
name: "healthy_tool",
186+
description: "healthy tool",
187+
parameters: { type: "object", properties: {} },
188+
async execute() {
189+
return { content: [{ type: "text", text: "ok" }] };
190+
},
191+
},
192+
] as never,
193+
},
194+
{
195+
apiKey: "sk-mistral-provider",
196+
toolChoice: {
197+
type: "function",
198+
function: {
199+
get name() {
200+
nameReads += 1;
201+
return nameReads === 1 ? "healthy_tool" : "broken_tool";
202+
},
203+
},
204+
},
205+
},
206+
);
207+
208+
const result = await stream.result();
209+
210+
expect(result.stopReason).toBe("error");
211+
expect(nameReads).toBe(1);
212+
expect((mistralMockState.payloads[0] as { toolChoice?: unknown }).toolChoice).toEqual({
213+
type: "function",
214+
function: { name: "healthy_tool" },
215+
});
216+
});
57217
});

src/llm/providers/mistral.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,14 @@ function buildChatPayload(
275275
stream: true,
276276
messages: toChatMessages(messages, model.input.includes("image")),
277277
};
278+
let convertedToolNames: Set<string> | undefined;
278279

279280
if (context.tools?.length) {
280-
payload.tools = toFunctionTools(context.tools);
281+
const tools = toFunctionTools(context.tools);
282+
convertedToolNames = new Set(tools.map((tool) => tool.function.name));
283+
if (tools.length > 0) {
284+
payload.tools = tools;
285+
}
281286
}
282287
if (options?.temperature !== undefined) {
283288
payload.temperature = options.temperature;
@@ -289,7 +294,10 @@ function buildChatPayload(
289294
payload.stop = options.stop;
290295
}
291296
if (options?.toolChoice) {
292-
payload.toolChoice = mapToolChoice(options.toolChoice);
297+
const toolChoice = mapToolChoice(options.toolChoice, convertedToolNames);
298+
if (toolChoice) {
299+
payload.toolChoice = toolChoice;
300+
}
293301
}
294302
if (options?.promptMode) {
295303
payload.promptMode = options.promptMode;
@@ -507,15 +515,21 @@ async function consumeChatStream(
507515
}
508516

509517
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
510-
return tools.map((tool) => ({
511-
type: "function",
512-
function: {
513-
name: tool.name,
514-
description: tool.description,
515-
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
516-
strict: false,
517-
},
518-
}));
518+
return tools.flatMap((tool) => {
519+
try {
520+
return {
521+
type: "function",
522+
function: {
523+
name: tool.name,
524+
description: tool.description,
525+
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
526+
strict: false,
527+
},
528+
};
529+
} catch {
530+
return [];
531+
}
532+
});
519533
}
520534

521535
function stripSymbolKeys(value: unknown): unknown {
@@ -688,6 +702,7 @@ function mapReasoningEffort(
688702

689703
function mapToolChoice(
690704
choice: MistralOptions["toolChoice"],
705+
convertedToolNames?: ReadonlySet<string>,
691706
):
692707
| "auto"
693708
| "none"
@@ -698,12 +713,24 @@ function mapToolChoice(
698713
if (!choice) {
699714
return undefined;
700715
}
716+
if (convertedToolNames && convertedToolNames.size === 0) {
717+
if (choice === "none" || choice === "auto") {
718+
return choice === "none" ? "none" : undefined;
719+
}
720+
throw new Error("Mistral tool_choice requires a tool, but no tools survived schema conversion");
721+
}
701722
if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") {
702723
return choice;
703724
}
725+
const toolName = choice.function.name;
726+
if (convertedToolNames && !convertedToolNames.has(toolName)) {
727+
throw new Error(
728+
`Mistral tool_choice requested unavailable tool "${toolName}" after schema conversion`,
729+
);
730+
}
704731
return {
705732
type: "function",
706-
function: { name: choice.function.name },
733+
function: { name: toolName },
707734
};
708735
}
709736

0 commit comments

Comments
 (0)