fix(agents): make tool call ID sanitization conditional with standard/strict modes

- Add ToolCallIdMode type ('standard' | 'strict') for provider compatibility
- Standard mode (default): allows [a-zA-Z0-9_-] for readable session logs
- Strict mode: only [a-zA-Z0-9] for Mistral via OpenRouter
- Update sanitizeSessionMessagesImages to accept toolCallIdMode option
- Export ToolCallIdMode from pi-embedded-helpers barrel

Addresses review feedback on PR #1372 about readability.
This commit is contained in:
zerone0x
2026-01-21 21:32:53 +08:00
committed by Peter Steinberger
parent d0f9e22a4b
commit d51eca64cc
7 changed files with 369 additions and 180 deletions

View File

@@ -1,15 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("sanitizeSessionMessagesImages", () => { describe("sanitizeSessionMessagesImages", () => {
it("keeps tool call + tool result IDs unchanged by default", async () => { it("keeps tool call + tool result IDs unchanged by default", async () => {
const input = [ const input = [
@@ -50,7 +42,8 @@ describe("sanitizeSessionMessagesImages", () => {
expect(toolResult.role).toBe("toolResult"); expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe("call_123|fc_456"); expect(toolResult.toolCallId).toBe("call_123|fc_456");
}); });
it("sanitizes tool call + tool result IDs when enabled (alphanumeric only)", async () => {
it("sanitizes tool call + tool result IDs in standard mode (preserves underscores)", async () => {
const input = [ const input = [
{ {
role: "assistant", role: "assistant",
@@ -82,7 +75,51 @@ describe("sanitizeSessionMessagesImages", () => {
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find( const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(b) => b.type === "toolCall", (b) => b.type === "toolCall",
); );
// Sanitization strips all non-alphanumeric characters for Mistral/OpenRouter compatibility // Standard mode preserves underscores for readability, replaces invalid chars
expect(toolCall?.id).toBe("call_123_fc_456");
const toolResult = out[1] as unknown as {
role?: string;
toolCallId?: string;
};
expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe("call_123_fc_456");
});
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123|fc_456",
name: "read",
arguments: { path: "package.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
});
const assistant = out[0] as unknown as { role?: string; content?: unknown };
expect(assistant.role).toBe("assistant");
expect(Array.isArray(assistant.content)).toBe(true);
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
expect(toolCall?.id).toBe("call123fc456"); expect(toolCall?.id).toBe("call123fc456");
const toolResult = out[1] as unknown as { const toolResult = out[1] as unknown as {

View File

@@ -1,15 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js"; import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("sanitizeSessionMessagesImages", () => { describe("sanitizeSessionMessagesImages", () => {
it("removes empty assistant text blocks but preserves tool calls", async () => { it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = [ const input = [
@@ -30,7 +22,8 @@ describe("sanitizeSessionMessagesImages", () => {
expect(content).toHaveLength(1); expect(content).toHaveLength(1);
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall"); expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
}); });
it("sanitizes tool ids for assistant blocks and tool results when enabled (alphanumeric only)", async () => {
it("sanitizes tool ids in standard mode (preserves underscores)", async () => {
const input = [ const input = [
{ {
role: "assistant", role: "assistant",
@@ -55,7 +48,42 @@ describe("sanitizeSessionMessagesImages", () => {
sanitizeToolCallIds: true, sanitizeToolCallIds: true,
}); });
// Sanitization strips all non-alphanumeric characters for Mistral/OpenRouter compatibility // Standard mode preserves underscores for readability
const assistant = out[0] as { content?: Array<{ id?: string }> };
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
const toolResult = out[1] as { toolUseId?: string };
expect(toolResult.toolUseId).toBe("call_abc_item_123");
});
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
{
type: "toolCall",
id: "call_abc|item:456",
name: "exec",
arguments: {},
},
],
},
{
role: "toolResult",
toolUseId: "call_abc|item:123",
content: [{ type: "text", text: "ok" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
});
// Strict mode strips all non-alphanumeric characters for Mistral/OpenRouter compatibility
const assistant = out[0] as { content?: Array<{ id?: string }> }; const assistant = out[0] as { content?: Array<{ id?: string }> };
expect(assistant.content?.[0]?.id).toBe("callabcitem123"); expect(assistant.content?.[0]?.id).toBe("callabcitem123");
expect(assistant.content?.[1]?.id).toBe("callabcitem456"); expect(assistant.content?.[1]?.id).toBe("callabcitem456");

View File

@@ -1,24 +1,33 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeToolCallId } from "./pi-embedded-helpers.js"; import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("sanitizeToolCallId", () => { describe("sanitizeToolCallId", () => {
it("keeps valid alphanumeric tool call IDs", () => { describe("standard mode (default)", () => {
expect(sanitizeToolCallId("callabc123")).toBe("callabc123"); it("keeps valid alphanumeric tool call IDs", () => {
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
});
it("keeps underscores and hyphens for readability", () => {
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
expect(sanitizeToolCallId("call_abc_def")).toBe("call_abc_def");
});
it("replaces invalid characters with underscores", () => {
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
});
it("returns default for empty IDs", () => {
expect(sanitizeToolCallId("")).toBe("default_tool_id");
});
}); });
it("strips non-alphanumeric characters (Mistral/OpenRouter compatibility)", () => {
expect(sanitizeToolCallId("call_abc-123")).toBe("callabc123"); describe("strict mode (for Mistral/OpenRouter)", () => {
expect(sanitizeToolCallId("call_abc|item:456")).toBe("callabcitem456"); it("strips all non-alphanumeric characters", () => {
expect(sanitizeToolCallId("whatsapp_login_1768799841527_1")).toBe("whatsapplogin17687998415271"); expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123");
}); expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456");
it("returns default for empty IDs", () => { expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe(
expect(sanitizeToolCallId("")).toBe("defaulttoolid"); "whatsapplogin17687998415271",
);
});
it("returns default for empty IDs", () => {
expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid");
});
}); });
}); });

View File

@@ -50,4 +50,5 @@ export {
} from "./pi-embedded-helpers/turns.js"; } from "./pi-embedded-helpers/turns.js";
export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js"; export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js";
export type { ToolCallIdMode } from "./tool-call-id.js";
export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js"; export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js";

View File

@@ -1,5 +1,6 @@
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ToolCallIdMode } from "../tool-call-id.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
import { sanitizeContentBlocksImages } from "../tool-images.js"; import { sanitizeContentBlocksImages } from "../tool-images.js";
import { stripThoughtSignatures } from "./bootstrap.js"; import { stripThoughtSignatures } from "./bootstrap.js";
@@ -32,6 +33,8 @@ export async function sanitizeSessionMessagesImages(
label: string, label: string,
options?: { options?: {
sanitizeToolCallIds?: boolean; sanitizeToolCallIds?: boolean;
/** Mode for tool call ID sanitization: "standard" (default, preserves _-) or "strict" (alphanumeric only) */
toolCallIdMode?: ToolCallIdMode;
enforceToolCallLast?: boolean; enforceToolCallLast?: boolean;
preserveSignatures?: boolean; preserveSignatures?: boolean;
sanitizeThoughtSignatures?: { sanitizeThoughtSignatures?: {
@@ -43,7 +46,7 @@ export async function sanitizeSessionMessagesImages(
// We sanitize historical session messages because Anthropic can reject a request // We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX). // if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
const sanitizedIds = options?.sanitizeToolCallIds const sanitizedIds = options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages) ? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages; : messages;
const out: AgentMessage[] = []; const out: AgentMessage[] = [];
for (const msg of sanitizedIds) { for (const msg of sanitizedIds) {

View File

@@ -7,135 +7,213 @@ import {
} from "./tool-call-id.js"; } from "./tool-call-id.js";
describe("sanitizeToolCallIdsForCloudCodeAssist", () => { describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
it("is a no-op for already-valid non-colliding alphanumeric IDs", () => { describe("standard mode (default)", () => {
const input = [ it("is a no-op for already-valid non-colliding IDs", () => {
{ const input = [
role: "assistant", {
content: [{ type: "toolCall", id: "call1", name: "read", arguments: {} }], role: "assistant",
}, content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
{ },
role: "toolResult", {
toolCallId: "call1", role: "toolResult",
toolName: "read", toolCallId: "call_1",
content: [{ type: "text", text: "ok" }], toolName: "read",
}, content: [{ type: "text", text: "ok" }],
] satisfies AgentMessage[]; },
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input); const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).toBe(input); expect(out).toBe(input);
});
it("replaces invalid characters with underscores (preserves readability)", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call|item:123",
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCall = assistant.content?.[0] as { id?: string };
// Standard mode preserves underscores for readability
expect(toolCall.id).toBe("call_item_123");
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true);
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
expect(result.toolCallId).toBe(toolCall.id);
});
it("avoids collisions when sanitization would produce duplicate IDs", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_a|b",
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "call_a:b",
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
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(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).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);
});
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: longA, name: "read", arguments: {} },
{ type: "toolCall", id: longB, name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: longA,
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: longB,
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(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).toBeLessThanOrEqual(40);
expect(b.id?.length).toBeLessThanOrEqual(40);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).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);
});
}); });
it("strips underscores from tool call IDs (Mistral/OpenRouter compatibility)", () => { describe("strict mode (for Mistral/OpenRouter)", () => {
const input = [ it("strips underscores and hyphens from tool call IDs", () => {
{ const input = [
role: "assistant", {
content: [ role: "assistant",
{ type: "toolCall", id: "whatsapp_login_1768799841527_1", name: "login", arguments: {} }, content: [
], { type: "toolCall", id: "whatsapp_login_1768799841527_1", name: "login", arguments: {} },
}, ],
{ },
role: "toolResult", {
toolCallId: "whatsapp_login_1768799841527_1", role: "toolResult",
toolName: "login", toolCallId: "whatsapp_login_1768799841527_1",
content: [{ type: "text", text: "ok" }], toolName: "login",
}, content: [{ type: "text", text: "ok" }],
] satisfies AgentMessage[]; },
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input); const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input); expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>; const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCall = assistant.content?.[0] as { id?: string }; const toolCall = assistant.content?.[0] as { id?: string };
// ID should be alphanumeric only, no underscores // Strict mode strips all non-alphanumeric characters
expect(toolCall.id).toBe("whatsapplogin17687998415271"); expect(toolCall.id).toBe("whatsapplogin17687998415271");
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true); expect(isValidCloudCodeAssistToolId(toolCall.id as string, "strict")).toBe(true);
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>; const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
expect(result.toolCallId).toBe(toolCall.id); expect(result.toolCallId).toBe(toolCall.id);
}); });
it("avoids collisions when sanitization would produce duplicate IDs", () => { it("avoids collisions with alphanumeric-only suffixes", () => {
const input = [ const input = [
{ {
role: "assistant", role: "assistant",
content: [ content: [
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} }, { type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} }, { type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
], ],
}, },
{ {
role: "toolResult", role: "toolResult",
toolCallId: "call_a|b", toolCallId: "call_a|b",
toolName: "read", toolName: "read",
content: [{ type: "text", text: "one" }], content: [{ type: "text", text: "one" }],
}, },
{ {
role: "toolResult", role: "toolResult",
toolCallId: "call_a:b", toolCallId: "call_a:b",
toolName: "read", toolName: "read",
content: [{ type: "text", text: "two" }], content: [{ type: "text", text: "two" }],
}, },
] satisfies AgentMessage[]; ] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input); const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input); expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>; const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string }; const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string }; const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string"); expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string"); expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id); expect(a.id).not.toBe(b.id);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true); // Both should be strictly alphanumeric
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true); expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
// Should not contain underscores or hyphens
expect(a.id).not.toMatch(/[_-]/);
expect(b.id).not.toMatch(/[_-]/);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>; const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>; const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id); expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id); expect(r2.toolCallId).toBe(b.id);
}); });
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: longA, name: "read", arguments: {} },
{ type: "toolCall", id: longB, name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: longA,
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: longB,
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(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).toBeLessThanOrEqual(40);
expect(b.id?.length).toBeLessThanOrEqual(40);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).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,46 +2,70 @@ import { createHash } from "node:crypto";
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
export function sanitizeToolCallId(id: string): string { export type ToolCallIdMode = "standard" | "strict";
if (!id || typeof id !== "string") return "defaulttoolid";
// Some providers (e.g. Mistral via OpenRouter) require strictly alphanumeric tool call IDs. /**
// Strip all non-alphanumeric characters to ensure maximum compatibility. * Sanitize a tool call ID to be compatible with various providers.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, ""); *
* - "standard" mode: allows [a-zA-Z0-9_-], better readability (default)
* - "strict" mode: only [a-zA-Z0-9], required for Mistral via OpenRouter
*/
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
if (!id || typeof id !== "string") {
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
}
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid"; if (mode === "strict") {
// Some providers (e.g. Mistral via OpenRouter) require strictly alphanumeric tool call IDs.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
}
// 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): boolean { export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode = "standard"): boolean {
if (!id || typeof id !== "string") return false; if (!id || typeof id !== "string") return false;
// Strictly alphanumeric for maximum provider compatibility (e.g. Mistral via OpenRouter). if (mode === "strict") {
return /^[a-zA-Z0-9]+$/.test(id); // Strictly alphanumeric for providers like Mistral via OpenRouter
return /^[a-zA-Z0-9]+$/.test(id);
}
// Standard mode allows underscores and hyphens
return /^[a-zA-Z0-9_-]+$/.test(id);
} }
function shortHash(text: string): string { function shortHash(text: string): string {
return createHash("sha1").update(text).digest("hex").slice(0, 8); return createHash("sha1").update(text).digest("hex").slice(0, 8);
} }
function makeUniqueToolId(params: { id: string; used: Set<string> }): string { function makeUniqueToolId(params: {
id: string;
used: Set<string>;
mode: ToolCallIdMode;
}): string {
const MAX_LEN = 40; const MAX_LEN = 40;
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN); const base = sanitizeToolCallId(params.id, params.mode).slice(0, MAX_LEN);
if (!params.used.has(base)) return base; if (!params.used.has(base)) return base;
// Use alphanumeric-only suffixes to maintain strict compatibility.
const hash = shortHash(params.id); const hash = shortHash(params.id);
const maxBaseLen = MAX_LEN - hash.length; // Use separator based on mode: underscore for standard (readable), none for strict
const separator = params.mode === "strict" ? "" : "_";
const maxBaseLen = MAX_LEN - separator.length - hash.length;
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base; const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
const candidate = `${clippedBase}${hash}`; const candidate = `${clippedBase}${separator}${hash}`;
if (!params.used.has(candidate)) return candidate; if (!params.used.has(candidate)) return candidate;
for (let i = 2; i < 1000; i += 1) { for (let i = 2; i < 1000; i += 1) {
const suffix = `x${i}`; const suffix = params.mode === "strict" ? `x${i}` : `_${i}`;
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`; const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
if (!params.used.has(next)) return next; if (!params.used.has(next)) return next;
} }
const ts = `t${Date.now()}`; const ts = params.mode === "strict" ? `t${Date.now()}` : `_${Date.now()}`;
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`; return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
} }
@@ -100,18 +124,27 @@ function rewriteToolResultIds(params: {
} as Extract<AgentMessage, { role: "toolResult" }>; } as Extract<AgentMessage, { role: "toolResult" }>;
} }
export function sanitizeToolCallIdsForCloudCodeAssist(messages: AgentMessage[]): AgentMessage[] { /**
// Some providers (e.g. Mistral via OpenRouter) require strictly alphanumeric tool IDs. * Sanitize tool call IDs for provider compatibility.
// Use ^[a-zA-Z0-9]+$ pattern for maximum compatibility across all providers. *
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `ab`). * @param messages - The messages to sanitize
// Fix by applying a stable, transcript-wide mapping and de-duping via hash suffix. * @param mode - "standard" (default, allows _-) or "strict" (alphanumeric only for Mistral/OpenRouter)
*/
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
// 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>(); const map = new Map<string, string>();
const used = new Set<string>(); const used = new Set<string>();
const resolve = (id: string) => { const resolve = (id: string) => {
const existing = map.get(id); const existing = map.get(id);
if (existing) return existing; if (existing) return existing;
const next = makeUniqueToolId({ id, used }); const next = makeUniqueToolId({ id, used, mode });
map.set(id, next); map.set(id, next);
used.add(next); used.add(next);
return next; return next;