openclaw/src/agents/tool-call-id.ts
zerone0x d0f9e22a4b fix(agents): use alphanumeric-only tool call IDs for OpenRouter compatibility
Some providers like Mistral via OpenRouter require strictly alphanumeric
tool call IDs. The error message indicates: "Tool call id was
whatsapp_login_1768799841527_1 but must be a-z, A-Z, 0-9, with a length
of 9."

Changes:
- Update sanitizeToolCallId to strip all non-alphanumeric characters
  (previously allowed underscores and hyphens)
- Update makeUniqueToolId to use alphanumeric suffixes (x2, x3, etc.)
  instead of underscores
- Update isValidCloudCodeAssistToolId to validate alphanumeric-only IDs
- Update tests to reflect stricter sanitization

Fixes #1359

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 00:41:22 +00:00

145 lines
5.0 KiB
TypeScript

import { createHash } from "node:crypto";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
export function sanitizeToolCallId(id: string): string {
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.
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
}
export function isValidCloudCodeAssistToolId(id: string): boolean {
if (!id || typeof id !== "string") return false;
// Strictly alphanumeric for maximum provider compatibility (e.g. Mistral via OpenRouter).
return /^[a-zA-Z0-9]+$/.test(id);
}
function shortHash(text: string): string {
return createHash("sha1").update(text).digest("hex").slice(0, 8);
}
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
const MAX_LEN = 40;
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
if (!params.used.has(base)) return base;
// Use alphanumeric-only suffixes to maintain strict compatibility.
const hash = shortHash(params.id);
const maxBaseLen = MAX_LEN - hash.length;
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
const candidate = `${clippedBase}${hash}`;
if (!params.used.has(candidate)) return candidate;
for (let i = 2; i < 1000; i += 1) {
const suffix = `x${i}`;
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
if (!params.used.has(next)) return next;
}
const ts = `t${Date.now()}`;
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
}
function rewriteAssistantToolCallIds(params: {
message: Extract<AgentMessage, { role: "assistant" }>;
resolve: (id: string) => string;
}): Extract<AgentMessage, { role: "assistant" }> {
const content = params.message.content;
if (!Array.isArray(content)) return params.message;
let changed = false;
const next = content.map((block) => {
if (!block || typeof block !== "object") return block;
const rec = block as { type?: unknown; id?: unknown };
const type = rec.type;
const id = rec.id;
if (
(type !== "functionCall" && type !== "toolUse" && type !== "toolCall") ||
typeof id !== "string" ||
!id
) {
return block;
}
const nextId = params.resolve(id);
if (nextId === id) return block;
changed = true;
return { ...(block as unknown as Record<string, unknown>), id: nextId };
});
if (!changed) return params.message;
return { ...params.message, content: next as typeof params.message.content };
}
function rewriteToolResultIds(params: {
message: Extract<AgentMessage, { role: "toolResult" }>;
resolve: (id: string) => string;
}): Extract<AgentMessage, { role: "toolResult" }> {
const toolCallId =
typeof params.message.toolCallId === "string" && params.message.toolCallId
? params.message.toolCallId
: undefined;
const toolUseId = (params.message as { toolUseId?: unknown }).toolUseId;
const toolUseIdStr = typeof toolUseId === "string" && toolUseId ? toolUseId : undefined;
const nextToolCallId = toolCallId ? params.resolve(toolCallId) : undefined;
const nextToolUseId = toolUseIdStr ? params.resolve(toolUseIdStr) : undefined;
if (nextToolCallId === toolCallId && nextToolUseId === toolUseIdStr) {
return params.message;
}
return {
...params.message,
...(nextToolCallId && { toolCallId: nextToolCallId }),
...(nextToolUseId && { toolUseId: nextToolUseId }),
} as Extract<AgentMessage, { role: "toolResult" }>;
}
export function sanitizeToolCallIdsForCloudCodeAssist(messages: AgentMessage[]): AgentMessage[] {
// Some providers (e.g. Mistral via OpenRouter) require strictly alphanumeric tool IDs.
// Use ^[a-zA-Z0-9]+$ pattern for maximum compatibility across all providers.
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `ab`).
// Fix by applying a stable, transcript-wide mapping and de-duping via hash suffix.
const map = new Map<string, string>();
const used = new Set<string>();
const resolve = (id: string) => {
const existing = map.get(id);
if (existing) return existing;
const next = makeUniqueToolId({ id, used });
map.set(id, next);
used.add(next);
return next;
};
let changed = false;
const out = messages.map((msg) => {
if (!msg || typeof msg !== "object") return msg;
const role = (msg as { role?: unknown }).role;
if (role === "assistant") {
const next = rewriteAssistantToolCallIds({
message: msg as Extract<AgentMessage, { role: "assistant" }>,
resolve,
});
if (next !== msg) changed = true;
return next;
}
if (role === "toolResult") {
const next = rewriteToolResultIds({
message: msg as Extract<AgentMessage, { role: "toolResult" }>,
resolve,
});
if (next !== msg) changed = true;
return next;
}
return msg;
});
return changed ? out : messages;
}