diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index dc9036f2..2e507531 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,7 @@ - Preserved OpenAI Responses assistant `phase` metadata (`commentary`, `final_answer`) across turns by encoding `id` and `phase` in `textSignature` for session persistence and replay, with backward compatibility for legacy plain signatures ([#1819](https://github.com/badlogic/pi-mono/issues/1819)). - Fixed Antigravity endpoint fallback: 403/404 responses now cascade to the next endpoint instead of throwing immediately, added `autopush-cloudcode-pa.sandbox` endpoint to the fallback list, and removed extra fingerprint headers (`X-Goog-Api-Client`, `Client-Metadata`) from Antigravity requests ([#1830](https://github.com/badlogic/pi-mono/issues/1830)). +- Fixed Gemini 3 unsigned tool call replay: use `skip_thought_signature_validator` sentinel instead of converting function calls to text, preserving structured tool call context across multi-turn conversations ([#1829](https://github.com/badlogic/pi-mono/issues/1829)). ## [0.56.1] - 2026-03-05 diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 4605513d..e942314f 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -45,6 +45,11 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s // Thought signatures must be base64 for Google APIs (TYPE_BYTES). const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; +// Sentinel value that tells the Gemini API to skip thought signature validation. +// Used for unsigned function call parts (e.g. replayed from providers without thought signatures). +// See: https://ai.google.dev/gemini-api/docs/thought-signatures +const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; + function isValidThoughtSignature(signature: string | undefined): boolean { if (!signature) return false; if (signature.length % 4 !== 0) return false; @@ -138,28 +143,19 @@ export function convertMessages(model: Model, contex } else if (block.type === "toolCall") { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); // Gemini 3 requires thoughtSignature on all function calls when thinking mode is enabled. - // When replaying history from providers without thought signatures (e.g. Claude via Antigravity), - // convert unsigned function calls to text to avoid API validation errors. - // We include a note telling the model this is historical context to prevent mimicry. + // Use the skip_thought_signature_validator sentinel for unsigned function calls + // (e.g. replayed from providers without thought signatures like Claude via Antigravity). const isGemini3 = model.id.toLowerCase().includes("gemini-3"); - if (isGemini3 && !thoughtSignature) { - const argsStr = JSON.stringify(block.arguments ?? {}, null, 2); - parts.push({ - text: `[Historical context: a different model called tool "${block.name}" with arguments: ${argsStr}. Do not mimic this format - use proper function calling.]`, - }); - } else { - const part: Part = { - functionCall: { - name: block.name, - args: block.arguments ?? {}, - ...(requiresToolCallId(model.id) ? { id: block.id } : {}), - }, - }; - if (thoughtSignature) { - part.thoughtSignature = thoughtSignature; - } - parts.push(part); - } + const effectiveSignature = thoughtSignature || (isGemini3 ? SKIP_THOUGHT_SIGNATURE : undefined); + const part: Part = { + functionCall: { + name: block.name, + args: block.arguments ?? {}, + ...(requiresToolCallId(model.id) ? { id: block.id } : {}), + }, + ...(effectiveSignature && { thoughtSignature: effectiveSignature }), + }; + parts.push(part); } } diff --git a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts index e773a6a6..179fe75d 100644 --- a/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts +++ b/packages/ai/test/google-shared-gemini3-unsigned-tool-call.test.ts @@ -2,21 +2,26 @@ import { describe, expect, it } from "vitest"; import { convertMessages } from "../src/providers/google-shared.js"; import type { Context, Model } from "../src/types.js"; -describe("google-shared convertMessages", () => { - it("converts unsigned tool calls to text for Gemini 3", () => { - const model: Model<"google-generative-ai"> = { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }; +const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; +function makeGemini3Model(id = "gemini-3-pro-preview"): Model<"google-generative-ai"> { + return { + id, + name: "Gemini 3 Pro Preview", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; +} + +describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () => { + it("uses skip_thought_signature_validator for unsigned tool calls on Gemini 3", () => { + const model = makeGemini3Model(); const now = Date.now(); const context: Context = { messages: [ @@ -51,22 +56,117 @@ describe("google-shared convertMessages", () => { const contents = convertMessages(model, context); - let toolTurn: (typeof contents)[number] | undefined; - for (let i = contents.length - 1; i >= 0; i -= 1) { - if (contents[i]?.role === "model") { - toolTurn = contents[i]; - break; - } - } + const modelTurn = contents.find((c) => c.role === "model"); + expect(modelTurn).toBeTruthy(); - expect(toolTurn).toBeTruthy(); - expect(toolTurn?.parts?.some((p) => p.functionCall !== undefined)).toBe(false); + // Should be a structured functionCall, NOT text fallback + const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); + expect(fcPart).toBeTruthy(); + expect(fcPart?.functionCall?.name).toBe("bash"); + expect(fcPart?.functionCall?.args).toEqual({ command: "ls -la" }); + expect(fcPart?.thoughtSignature).toBe(SKIP_THOUGHT_SIGNATURE); - const text = toolTurn?.parts?.map((p) => p.text ?? "").join("\n"); - // Should contain historical context note to prevent mimicry - expect(text).toContain("Historical context"); - expect(text).toContain("bash"); - expect(text).toContain("ls -la"); - expect(text).toContain("Do not mimic this format"); + // No text fallback should exist + const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? []; + const historicalText = textParts.filter((p) => p.text?.includes("Historical context")); + expect(historicalText).toHaveLength(0); + }); + + it("preserves valid thoughtSignature when present (same provider/model)", () => { + const model = makeGemini3Model(); + const now = Date.now(); + // Valid base64 signature (16 bytes = 24 chars base64) + const validSig = "AAAAAAAAAAAAAAAAAAAAAA=="; + const context: Context = { + messages: [ + { role: "user", content: "Hi", timestamp: now }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "bash", + arguments: { command: "echo hi" }, + thoughtSignature: validSig, + }, + ], + api: "google-generative-ai", + provider: "google", + model: "gemini-3-pro-preview", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: now, + }, + ], + }; + + const contents = convertMessages(model, context); + const modelTurn = contents.find((c) => c.role === "model"); + const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); + + expect(fcPart).toBeTruthy(); + expect(fcPart?.thoughtSignature).toBe(validSig); + }); + + it("does not add sentinel for non-Gemini-3 models", () => { + const model: Model<"google-generative-ai"> = { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; + const now = Date.now(); + const context: Context = { + messages: [ + { role: "user", content: "Hi", timestamp: now }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "bash", + arguments: { command: "ls" }, + // No thoughtSignature + }, + ], + api: "google-gemini-cli", + provider: "google-antigravity", + model: "claude-sonnet-4-20250514", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: now, + }, + ], + }; + + const contents = convertMessages(model, context); + const modelTurn = contents.find((c) => c.role === "model"); + const fcPart = modelTurn?.parts?.find((p) => p.functionCall !== undefined); + + expect(fcPart).toBeTruthy(); + // No sentinel, no thoughtSignature at all + expect(fcPart?.thoughtSignature).toBeUndefined(); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d9372a99..9db5dac1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed GPT-5.3 Codex follow-up turns dropping OpenAI Responses assistant `phase` metadata by preserving replayable signatures in session history and forwarding `phase` back to the Responses API ([#1819](https://github.com/badlogic/pi-mono/issues/1819)). - Fixed Antigravity reliability: endpoint cascade on 403/404, added autopush sandbox fallback, removed extra fingerprint headers ([#1830](https://github.com/badlogic/pi-mono/issues/1830)). +- Fixed Gemini 3 multi-turn tool use losing structured context by using `skip_thought_signature_validator` sentinel for unsigned function calls instead of text fallback ([#1829](https://github.com/badlogic/pi-mono/issues/1829)). - Fixed editor/footer visibility drift during terminal resize by forcing full redraws when terminal width or height changes ([#1844](https://github.com/badlogic/pi-mono/pull/1844) by [@ghoulr](https://github.com/ghoulr)). - Fixed footer width truncation for wide Unicode text (session name, model, provider) to prevent TUI crashes from rendered lines exceeding terminal width ([#1833](https://github.com/badlogic/pi-mono/issues/1833)).