diff --git a/CHANGELOG.md b/CHANGELOG.md index 23aeb2c2e..ef5ef6417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixes - Browser: add tests for snapshot labels/efficient query params and labeled image responses. +- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 88fe701a9..0f38772f4 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -26,6 +26,7 @@ export { } from "./pi-embedded-helpers/errors.js"; export { downgradeGeminiHistory, + downgradeGeminiThinkingBlocks, isGoogleModelApi, sanitizeGoogleTurnOrdering, } from "./pi-embedded-helpers/google.js"; diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/pi-embedded-helpers/google.ts index 6f9846d80..8e72215a4 100644 --- a/src/agents/pi-embedded-helpers/google.ts +++ b/src/agents/pi-embedded-helpers/google.ts @@ -26,6 +26,58 @@ type GeminiToolCallBlock = { input?: unknown; }; +type GeminiThinkingBlock = { + type?: unknown; + thinking?: unknown; + thinkingSignature?: unknown; +}; + +export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMessage[] { + const out: AgentMessage[] = []; + for (const msg of messages) { + if (!msg || typeof msg !== "object") { + out.push(msg); + continue; + } + const role = (msg as { role?: unknown }).role; + if (role !== "assistant") { + out.push(msg); + continue; + } + const assistantMsg = msg as Extract; + if (!Array.isArray(assistantMsg.content)) { + out.push(msg); + continue; + } + + // Gemini rejects thinking blocks that lack a signature; downgrade to text for safety. + let hasDowngraded = false; + const nextContent = assistantMsg.content.flatMap((block) => { + if (!block || typeof block !== "object") return [block]; + const record = block as GeminiThinkingBlock; + if (record.type !== "thinking") return [block]; + const signature = + typeof record.thinkingSignature === "string" ? record.thinkingSignature.trim() : ""; + if (signature.length > 0) return [block]; + const thinking = typeof record.thinking === "string" ? record.thinking : ""; + const trimmed = thinking.trim(); + hasDowngraded = true; + if (!trimmed) return []; + return [{ type: "text", text: thinking }]; + }); + + if (!hasDowngraded) { + out.push(msg); + continue; + } + if (nextContent.length === 0) { + continue; + } + out.push({ ...assistantMsg, content: nextContent } as AgentMessage); + } + return out; +} + export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] { const downgradedIds = new Set(); const out: AgentMessage[] = []; diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts new file mode 100644 index 000000000..ebc16c62e --- /dev/null +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts @@ -0,0 +1,175 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js"; + +describe("sanitizeSessionHistory (google thinking)", () => { + it("downgrades thinking blocks without signatures for Google models", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [{ type: "thinking", thinking: "reasoning" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ) as { content?: Array<{ type?: string; text?: string }> }; + expect(assistant.content?.map((block) => block.type)).toEqual(["text"]); + expect(assistant.content?.[0]?.text).toBe("reasoning"); + }); + + it("keeps thinking blocks with signatures for Google models", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ) as { content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }> }; + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); + expect(assistant.content?.[0]?.thinking).toBe("reasoning"); + expect(assistant.content?.[0]?.thinkingSignature).toBe("sig"); + }); + + it("preserves order when downgrading mixed assistant content", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "internal note" }, + { type: "text", text: "world" }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google-mixed", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ) as { content?: Array<{ type?: string; text?: string }> }; + expect(assistant.content?.map((block) => block.type)).toEqual(["text", "text", "text"]); + expect(assistant.content?.[1]?.text).toBe("internal note"); + }); + + it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, + { type: "thinking", thinking: "unsigned" }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google-mixed-signatures", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ) as { content?: Array<{ type?: string; thinking?: string; text?: string }> }; + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "text"]); + expect(assistant.content?.[0]?.thinking).toBe("signed"); + expect(assistant.content?.[1]?.text).toBe("unsigned"); + }); + + it("drops empty unsigned thinking blocks for Google models", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [{ type: "thinking", thinking: " " }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "google-antigravity", + sessionManager, + sessionId: "session:google-empty", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ); + expect(assistant).toBeUndefined(); + }); + + it("keeps thinking blocks for non-Google models", async () => { + const sessionManager = SessionManager.inMemory(); + const input = [ + { + role: "user", + content: "hi", + }, + { + role: "assistant", + content: [{ type: "thinking", thinking: "reasoning" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "openai", + sessionManager, + sessionId: "session:openai", + }); + + const assistant = out.find( + (msg) => (msg as { role?: string }).role === "assistant", + ) as { content?: Array<{ type?: string }> }; + expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); + }); +}); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 0405f5e27..978edc78b 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -3,6 +3,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js"; import { + downgradeGeminiThinkingBlocks, downgradeGeminiHistory, isCompactionFailureError, isGoogleModelApi, @@ -148,10 +149,13 @@ export async function sanitizeSessionHistory(params: { enforceToolCallLast: params.modelApi === "anthropic-messages", }); const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); - - const downgraded = isGoogleModelApi(params.modelApi) - ? downgradeGeminiHistory(repairedTools) + // Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT. + const downgradedThinking = isGoogleModelApi(params.modelApi) + ? downgradeGeminiThinkingBlocks(repairedTools) : repairedTools; + const downgraded = isGoogleModelApi(params.modelApi) + ? downgradeGeminiHistory(downgradedThinking) + : downgradedThinking; return applyGoogleTurnOrderingFix({ messages: downgraded,