fix: enforce Mistral tool call ids (#1372) (thanks @zerone0x)

This commit is contained in:
Peter Steinberger
2026-01-22 00:38:48 +00:00
parent d51eca64cc
commit 0704fe7dbb
13 changed files with 193 additions and 51 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).

View File

@@ -119,7 +119,7 @@ describe("sanitizeSessionMessagesImages", () => {
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(b) => b.type === "toolCall",
);
// Strict mode strips all non-alphanumeric characters for Mistral/OpenRouter compatibility
// Strict mode strips all non-alphanumeric characters
expect(toolCall?.id).toBe("call123fc456");
const toolResult = out[1] as unknown as {

View File

@@ -83,7 +83,7 @@ describe("sanitizeSessionMessagesImages", () => {
toolCallIdMode: "strict",
});
// Strict mode strips all non-alphanumeric characters for Mistral/OpenRouter compatibility
// Strict mode strips all non-alphanumeric characters
const assistant = out[0] as { content?: Array<{ id?: string }> };
expect(assistant.content?.[0]?.id).toBe("callabcitem123");
expect(assistant.content?.[1]?.id).toBe("callabcitem456");

View File

@@ -18,7 +18,7 @@ describe("sanitizeToolCallId", () => {
});
});
describe("strict mode (for Mistral/OpenRouter)", () => {
describe("strict mode (alphanumeric only)", () => {
it("strips all non-alphanumeric characters", () => {
expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123");
expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456");
@@ -30,4 +30,14 @@ describe("sanitizeToolCallId", () => {
expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid");
});
});
describe("strict9 mode (Mistral tool call IDs)", () => {
it("returns alphanumeric IDs with length 9", () => {
const out = sanitizeToolCallId("call_abc|item:456", "strict9");
expect(out).toMatch(/^[a-zA-Z0-9]{9}$/);
});
it("returns default for empty IDs", () => {
expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/);
});
});
});

View File

@@ -33,7 +33,12 @@ export async function sanitizeSessionMessagesImages(
label: string,
options?: {
sanitizeToolCallIds?: boolean;
/** Mode for tool call ID sanitization: "standard" (default, preserves _-) or "strict" (alphanumeric only) */
/**
* Mode for tool call ID sanitization:
* - "standard" (default, preserves _-)
* - "strict" (alphanumeric only)
* - "strict9" (alphanumeric only, length 9)
*/
toolCallIdMode?: ToolCallIdMode;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;

View File

@@ -54,6 +54,25 @@ describe("sanitizeSessionHistory", () => {
);
});
it("sanitizes tool call ids with strict9 for Mistral models", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "openai-responses",
provider: "openrouter",
modelId: "mistralai/devstral-2512:free",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeToolCallIds: true, toolCallIdMode: "strict9" }),
);
});
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);

View File

@@ -14,6 +14,8 @@ import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
import { normalizeProviderId } from "../model-selection.js";
import type { ToolCallIdMode } from "../tool-call-id.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -44,12 +46,29 @@ const OPENAI_TOOL_CALL_ID_APIS = new Set([
"openai-responses",
"openai-codex-responses",
]);
const MISTRAL_MODEL_HINTS = [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
];
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
if (!modelApi) return false;
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
const provider = normalizeProviderId(params.provider ?? "");
if (provider === "mistral") return true;
const modelId = (params.modelId ?? "").toLowerCase();
if (!modelId) return false;
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
}
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) {
@@ -191,12 +210,16 @@ export async function sanitizeSessionHistory(params: {
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const provider = (params.provider ?? "").toLowerCase();
const provider = normalizeProviderId(params.provider ?? "");
const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini =
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
const isMistral = isMistralModel({ provider, modelId });
const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : undefined;
const sanitizeToolCallIds = shouldSanitizeToolCallIds(params.modelApi) || isMistral;
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
sanitizeToolCallIds,
toolCallIdMode,
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini

View File

@@ -30,9 +30,7 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} },
],
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
},
{
role: "toolResult",
@@ -141,13 +139,18 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
});
});
describe("strict mode (for Mistral/OpenRouter)", () => {
describe("strict mode (alphanumeric only)", () => {
it("strips underscores and hyphens from tool call IDs", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "whatsapp_login_1768799841527_1", name: "login", arguments: {} },
{
type: "toolCall",
id: "whatsapp_login_1768799841527_1",
name: "login",
arguments: {},
},
],
},
{
@@ -216,4 +219,50 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(r2.toolCallId).toBe(b.id);
});
});
describe("strict9 mode (Mistral tool call IDs)", () => {
it("enforces alphanumeric IDs with length 9", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_abc|item:123", name: "read", arguments: {} },
{ type: "toolCall", id: "call_abc|item:456", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_abc|item:123",
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "call_abc|item:456",
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
expect(a.id?.length).toBe(9);
expect(b.id?.length).toBe(9);
expect(isValidCloudCodeAssistToolId(a.id as string, "strict9")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict9")).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
});
});
});

View File

@@ -2,50 +2,76 @@ import { createHash } from "node:crypto";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
export type ToolCallIdMode = "standard" | "strict";
export type ToolCallIdMode = "standard" | "strict" | "strict9";
const STRICT9_LEN = 9;
/**
* Sanitize a tool call ID to be compatible with various providers.
*
* - "standard" mode: allows [a-zA-Z0-9_-], better readability (default)
* - "strict" mode: only [a-zA-Z0-9], required for Mistral via OpenRouter
* - "strict" mode: only [a-zA-Z0-9]
* - "strict9" mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
*/
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
if (!id || typeof id !== "string") {
if (mode === "strict9") return "defaultid";
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
}
if (mode === "strict") {
// Some providers (e.g. Mistral via OpenRouter) require strictly alphanumeric tool call IDs.
// Some providers require strictly alphanumeric tool call IDs.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
}
if (mode === "strict9") {
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
if (alphanumericOnly.length >= STRICT9_LEN) return alphanumericOnly.slice(0, STRICT9_LEN);
if (alphanumericOnly.length > 0) return shortHash(alphanumericOnly, STRICT9_LEN);
return shortHash("sanitized", STRICT9_LEN);
}
// Standard mode: allow underscores and hyphens for better readability in logs
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
const trimmed = sanitized.replace(/^[^a-zA-Z0-9_-]+/, "");
return trimmed.length > 0 ? trimmed : "sanitized_tool_id";
}
export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode = "standard"): boolean {
export function isValidCloudCodeAssistToolId(
id: string,
mode: ToolCallIdMode = "standard",
): boolean {
if (!id || typeof id !== "string") return false;
if (mode === "strict") {
// Strictly alphanumeric for providers like Mistral via OpenRouter
// Strictly alphanumeric for providers with tighter tool ID constraints
return /^[a-zA-Z0-9]+$/.test(id);
}
if (mode === "strict9") {
return /^[a-zA-Z0-9]{9}$/.test(id);
}
// Standard mode allows underscores and hyphens
return /^[a-zA-Z0-9_-]+$/.test(id);
}
function shortHash(text: string): string {
return createHash("sha1").update(text).digest("hex").slice(0, 8);
function shortHash(text: string, length = 8): string {
return createHash("sha1").update(text).digest("hex").slice(0, length);
}
function makeUniqueToolId(params: {
id: string;
used: Set<string>;
mode: ToolCallIdMode;
}): string {
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
if (params.mode === "strict9") {
const base = sanitizeToolCallId(params.id, params.mode);
const candidate = base.length >= STRICT9_LEN ? base.slice(0, STRICT9_LEN) : "";
if (candidate && !params.used.has(candidate)) return candidate;
for (let i = 0; i < 1000; i += 1) {
const hashed = shortHash(`${params.id}:${i}`, STRICT9_LEN);
if (!params.used.has(hashed)) return hashed;
}
return shortHash(`${params.id}:${Date.now()}`, STRICT9_LEN);
}
const MAX_LEN = 40;
const base = sanitizeToolCallId(params.id, params.mode).slice(0, MAX_LEN);
@@ -128,14 +154,15 @@ function rewriteToolResultIds(params: {
* Sanitize tool call IDs for provider compatibility.
*
* @param messages - The messages to sanitize
* @param mode - "standard" (default, allows _-) or "strict" (alphanumeric only for Mistral/OpenRouter)
* @param mode - "standard" (default, allows _-), "strict" (alphanumeric only), or "strict9" (alphanumeric length 9)
*/
export function sanitizeToolCallIdsForCloudCodeAssist(
messages: AgentMessage[],
mode: ToolCallIdMode = "standard",
): AgentMessage[] {
// Standard mode: allows [a-zA-Z0-9_-] for better readability in session logs
// Strict mode: only [a-zA-Z0-9] for providers like Mistral via OpenRouter
// Strict mode: only [a-zA-Z0-9]
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b` or `ab`).
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
const map = new Map<string, string>();

View File

@@ -60,7 +60,7 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("lists allowlisted models on /model list", async () => {
it("moves /model list to /models", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -90,7 +90,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("falls back to configured models when catalog is unavailable", async () => {
it("shows summary on /model when catalog is unavailable", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
@@ -116,12 +116,13 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("More: /model status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("includes catalog models when no allowlist is set", async () => {
it("moves /model list to /models even when no allowlist is set", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
@@ -156,7 +157,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("merges config allowlist models even when catalog is present", async () => {
it("moves /model list to /models even when catalog is present", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
// Catalog present but missing custom providers: /model should still include
@@ -207,7 +208,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("does not repeat missing auth labels on /model list", async () => {
it("moves /model list to /models without listing auth labels", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -231,6 +232,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved.");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -67,7 +67,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -103,10 +103,11 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Unrecognized model: ki");
expect(text).toContain("Did you mean: moonshot/kimi-k2-0905-preview");
expect(text).toContain("Try: /model moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -65,7 +65,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -94,10 +94,11 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Unrecognized model: kimi");
expect(text).toContain("Did you mean: moonshot/kimi-k2-0905-preview");
expect(text).toContain("Try: /model moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -106,7 +107,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{
Body: "/model kimi-k2-0905-preview",
From: "+1222",
@@ -140,10 +141,11 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Unrecognized model: kimi-k2-0905-preview");
expect(text).toContain("Did you mean: moonshot/kimi-k2-0905-preview");
expect(text).toContain("Try: /model moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -152,7 +154,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
const res = await getReplyFromConfig(
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
@@ -181,10 +183,11 @@ describe("directive behavior", () => {
},
);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Unrecognized model: moonshot/kimi");
expect(text).toContain("Did you mean: moonshot/kimi-k2-0905-preview");
expect(text).toContain("Try: /model moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -187,7 +187,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("lists allowlisted models on /model", async () => {
it("shows summary on /model", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -212,6 +212,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("More: /model status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();