mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
fix(ai): use skip_thought_signature_validator for unsigned Gemini 3 tool calls
Replace text fallback with the official sentinel value so unsigned function calls retain structured context in multi-turn conversations. closes #1829
This commit is contained in:
parent
cfbb15876a
commit
a0d839ce84
4 changed files with 148 additions and 50 deletions
|
|
@ -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)).
|
- 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 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
|
## [0.56.1] - 2026-03-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s
|
||||||
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
|
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
|
||||||
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
|
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 {
|
function isValidThoughtSignature(signature: string | undefined): boolean {
|
||||||
if (!signature) return false;
|
if (!signature) return false;
|
||||||
if (signature.length % 4 !== 0) return false;
|
if (signature.length % 4 !== 0) return false;
|
||||||
|
|
@ -138,28 +143,19 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||||
} else if (block.type === "toolCall") {
|
} else if (block.type === "toolCall") {
|
||||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
||||||
// Gemini 3 requires thoughtSignature on all function calls when thinking mode is enabled.
|
// 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),
|
// Use the skip_thought_signature_validator sentinel for unsigned function calls
|
||||||
// convert unsigned function calls to text to avoid API validation errors.
|
// (e.g. replayed from providers without thought signatures like Claude via Antigravity).
|
||||||
// We include a note telling the model this is historical context to prevent mimicry.
|
|
||||||
const isGemini3 = model.id.toLowerCase().includes("gemini-3");
|
const isGemini3 = model.id.toLowerCase().includes("gemini-3");
|
||||||
if (isGemini3 && !thoughtSignature) {
|
const effectiveSignature = thoughtSignature || (isGemini3 ? SKIP_THOUGHT_SIGNATURE : undefined);
|
||||||
const argsStr = JSON.stringify(block.arguments ?? {}, null, 2);
|
const part: Part = {
|
||||||
parts.push({
|
functionCall: {
|
||||||
text: `[Historical context: a different model called tool "${block.name}" with arguments: ${argsStr}. Do not mimic this format - use proper function calling.]`,
|
name: block.name,
|
||||||
});
|
args: block.arguments ?? {},
|
||||||
} else {
|
...(requiresToolCallId(model.id) ? { id: block.id } : {}),
|
||||||
const part: Part = {
|
},
|
||||||
functionCall: {
|
...(effectiveSignature && { thoughtSignature: effectiveSignature }),
|
||||||
name: block.name,
|
};
|
||||||
args: block.arguments ?? {},
|
parts.push(part);
|
||||||
...(requiresToolCallId(model.id) ? { id: block.id } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (thoughtSignature) {
|
|
||||||
part.thoughtSignature = thoughtSignature;
|
|
||||||
}
|
|
||||||
parts.push(part);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,26 @@ import { describe, expect, it } from "vitest";
|
||||||
import { convertMessages } from "../src/providers/google-shared.js";
|
import { convertMessages } from "../src/providers/google-shared.js";
|
||||||
import type { Context, Model } from "../src/types.js";
|
import type { Context, Model } from "../src/types.js";
|
||||||
|
|
||||||
describe("google-shared convertMessages", () => {
|
const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
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 now = Date.now();
|
||||||
const context: Context = {
|
const context: Context = {
|
||||||
messages: [
|
messages: [
|
||||||
|
|
@ -51,22 +56,117 @@ describe("google-shared convertMessages", () => {
|
||||||
|
|
||||||
const contents = convertMessages(model, context);
|
const contents = convertMessages(model, context);
|
||||||
|
|
||||||
let toolTurn: (typeof contents)[number] | undefined;
|
const modelTurn = contents.find((c) => c.role === "model");
|
||||||
for (let i = contents.length - 1; i >= 0; i -= 1) {
|
expect(modelTurn).toBeTruthy();
|
||||||
if (contents[i]?.role === "model") {
|
|
||||||
toolTurn = contents[i];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toolTurn).toBeTruthy();
|
// Should be a structured functionCall, NOT text fallback
|
||||||
expect(toolTurn?.parts?.some((p) => p.functionCall !== undefined)).toBe(false);
|
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");
|
// No text fallback should exist
|
||||||
// Should contain historical context note to prevent mimicry
|
const textParts = modelTurn?.parts?.filter((p) => p.text !== undefined) ?? [];
|
||||||
expect(text).toContain("Historical context");
|
const historicalText = textParts.filter((p) => p.text?.includes("Historical context"));
|
||||||
expect(text).toContain("bash");
|
expect(historicalText).toHaveLength(0);
|
||||||
expect(text).toContain("ls -la");
|
});
|
||||||
expect(text).toContain("Do not mimic this format");
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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)).
|
- 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)).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue