fix: downgrade unsigned gemini thinking

This commit is contained in:
Peter Steinberger
2026-01-15 04:42:01 +00:00
parent fa4670c5fe
commit 5fdaef3646
5 changed files with 236 additions and 3 deletions

View File

@@ -26,6 +26,7 @@ export {
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,
downgradeGeminiThinkingBlocks,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
} from "./pi-embedded-helpers/google.js";

View File

@@ -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<AgentMessage, { role: "assistant" }>;
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<string>();
const out: AgentMessage[] = [];

View File

@@ -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"]);
});
});

View File

@@ -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,