From 0edbdb19487e83c87e5634596269af25438f6c14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 01:03:10 +0000 Subject: [PATCH] fix: downgrade Gemini tool history --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers.ts | 145 ++++++++++++++++++++++++++++++ src/agents/pi-embedded-runner.ts | 9 +- 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cf1e687..935cedfe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs. - Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus) - Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc) +- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc) ## 2026.1.12-3 diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index ab548718e..8a9d01284 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -704,3 +704,148 @@ export function isMessagingToolDuplicate( sentTexts.map(normalizeTextForComparison), ); } + +/** + * Downgrades tool calls that are missing `thought_signature` (required by Gemini) + * into text representations, to prevent 400 INVALID_ARGUMENT errors. + * Also converts corresponding tool results into user messages. + */ +type GeminiToolCallBlock = { + type?: unknown; + thought_signature?: unknown; + id?: unknown; + toolCallId?: unknown; + name?: unknown; + toolName?: unknown; + arguments?: unknown; + input?: unknown; +}; + +export function downgradeGeminiHistory( + messages: AgentMessage[], +): AgentMessage[] { + const downgradedIds = new Set(); + const out: AgentMessage[] = []; + + const resolveToolResultId = ( + msg: Extract, + ): string | undefined => { + const toolCallId = (msg as { toolCallId?: unknown }).toolCallId; + if (typeof toolCallId === "string" && toolCallId) return toolCallId; + const toolUseId = (msg as { toolUseId?: unknown }).toolUseId; + if (typeof toolUseId === "string" && toolUseId) return toolUseId; + return undefined; + }; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") { + out.push(msg); + continue; + } + + const role = (msg as { role?: unknown }).role; + + if (role === "assistant") { + const assistantMsg = msg as Extract; + if (!Array.isArray(assistantMsg.content)) { + out.push(msg); + continue; + } + + let hasDowngraded = false; + const newContent = assistantMsg.content.map((block) => { + if (!block || typeof block !== "object") return block; + const blockRecord = block as GeminiToolCallBlock; + const type = blockRecord.type; + + // Check for tool calls / function calls + if ( + type === "toolCall" || + type === "functionCall" || + type === "toolUse" + ) { + // Check if thought_signature is missing + // Note: TypeScript doesn't know about thought_signature on standard types + const hasSignature = Boolean(blockRecord.thought_signature); + + if (!hasSignature) { + const id = + typeof blockRecord.id === "string" + ? blockRecord.id + : typeof blockRecord.toolCallId === "string" + ? blockRecord.toolCallId + : undefined; + const name = + typeof blockRecord.name === "string" + ? blockRecord.name + : typeof blockRecord.toolName === "string" + ? blockRecord.toolName + : undefined; + const args = + blockRecord.arguments !== undefined + ? blockRecord.arguments + : blockRecord.input; + + if (id) downgradedIds.add(id); + hasDowngraded = true; + + const argsText = + typeof args === "string" ? args : JSON.stringify(args, null, 2); + + return { + type: "text", + text: `[Tool Call: ${name ?? "unknown"}${ + id ? ` (ID: ${id})` : "" + }]\nArguments: ${argsText}`, + }; + } + } + return block; + }); + + if (hasDowngraded) { + out.push({ ...assistantMsg, content: newContent } as AgentMessage); + } else { + out.push(msg); + } + continue; + } + + if (role === "toolResult") { + const toolMsg = msg as Extract; + const toolResultId = resolveToolResultId(toolMsg); + if (toolResultId && downgradedIds.has(toolResultId)) { + // Convert to User message + let textContent = ""; + if (Array.isArray(toolMsg.content)) { + textContent = toolMsg.content + .map((entry) => { + if (entry && typeof entry === "object") { + const text = (entry as { text?: unknown }).text; + if (typeof text === "string") return text; + } + return JSON.stringify(entry); + }) + .join("\n"); + } else { + textContent = JSON.stringify(toolMsg.content); + } + + out.push({ + role: "user", + content: [ + { + type: "text", + text: `[Tool Result for ID ${toolResultId}]\n${textContent}`, + }, + ], + } as AgentMessage); + + continue; + } + } + + out.push(msg); + } + return out; +} diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 591b9e5b6..51f711e74 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -85,6 +85,7 @@ export type { MessagingToolSend } from "./pi-embedded-messaging.js"; import { buildBootstrapContextFiles, classifyFailoverReason, + downgradeGeminiHistory, type EmbeddedContextFile, ensureSessionHeader, formatAssistantErrorText, @@ -493,8 +494,14 @@ async function sanitizeSessionHistory(params: { }, ); const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); + + // Downgrade tool calls missing thought_signature if using Gemini + const downgraded = isGoogleModelApi(params.modelApi) + ? downgradeGeminiHistory(repairedTools) + : repairedTools; + return applyGoogleTurnOrderingFix({ - messages: repairedTools, + messages: downgraded, modelApi: params.modelApi, sessionManager: params.sessionManager, sessionId: params.sessionId,