From 3dfee78d72b788b4ffef18464eb8d1bd9a8c99ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Mon, 23 Feb 2026 02:37:12 +0800 Subject: [PATCH] fix: sanitize tool call IDs in agent loop for Mistral strict9 format (#23595) (#23698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sanitize tool call IDs in agent loop for Mistral strict9 format (#23595) Mistral requires tool call IDs to be exactly 9 alphanumeric characters ([a-zA-Z0-9]{9}). The existing sanitizeToolCallIdsForCloudCodeAssist mechanism only ran on historical messages at attempt start via sanitizeSessionHistory, but the pi-agent-core agent loop's internal tool call → tool result cycles bypassed that path entirely. Changes: - Wrap streamFn (like dropThinkingBlocks) so every outbound request sees sanitized tool call IDs when the transcript policy requires it - Replace call_${Date.now()} in pendingToolCalls with a 9-char hex ID generated from crypto.randomBytes - Add Mistral tool call ID error pattern to ERROR_PATTERNS.format so the error is correctly classified for retry/rotation * Changelog: document Mistral strict9 tool-call ID fix --------- Co-authored-by: echoVic Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers/errors.ts | 1 + src/agents/pi-embedded-runner/run.ts | 3 ++- src/agents/pi-embedded-runner/run/attempt.ts | 27 ++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c23e73007..dd7d15e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,7 @@ Docs: https://docs.openclaw.ai - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. - Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. +- Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 `HTTP 400` failures on tool continuations. (#23698) Thanks @echoVic. - Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue. - Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 9e0ceb050..68ee31f3f 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -614,6 +614,7 @@ const ERROR_PATTERNS = { "tool_use_id", "messages.1.content.1.tool_use.id", "invalid request format", + /tool call id was.*must be/i, ], } as const; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0a810a38a..1347354e3 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { generateSecureToken } from "../../infra/secure-random.js"; @@ -1122,7 +1123,7 @@ export async function runEmbeddedPiAgent( pendingToolCalls: attempt.clientToolCall ? [ { - id: `call_${Date.now()}`, + id: randomBytes(5).toString("hex").slice(0, 9), name: attempt.clientToolCall.name, arguments: JSON.stringify(attempt.clientToolCall.params), }, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d8ba48e15..d0892148b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -77,6 +77,7 @@ import { } from "../../skills.js"; import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; +import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; @@ -771,6 +772,32 @@ export async function runEmbeddedAttempt( }; } + // Mistral (and other strict providers) reject tool call IDs that don't match their + // format requirements (e.g. [a-zA-Z0-9]{9}). sanitizeSessionHistory only processes + // historical messages at attempt start, but the agent loop's internal tool call → + // tool result cycles bypass that path. Wrap streamFn so every outbound request + // sees sanitized tool call IDs. + if (transcriptPolicy.sanitizeToolCallIds && transcriptPolicy.toolCallIdMode) { + const inner = activeSession.agent.streamFn; + const mode = transcriptPolicy.toolCallIdMode; + activeSession.agent.streamFn = (model, context, options) => { + const ctx = context as unknown as { messages?: unknown }; + const messages = ctx?.messages; + if (!Array.isArray(messages)) { + return inner(model, context, options); + } + const sanitized = sanitizeToolCallIdsForCloudCodeAssist(messages as AgentMessage[], mode); + if (sanitized === messages) { + return inner(model, context, options); + } + const nextContext = { + ...(context as unknown as Record), + messages: sanitized, + } as unknown; + return inner(model, nextContext as typeof context, options); + }; + } + if (anthropicPayloadLogger) { activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn( activeSession.agent.streamFn,