fix: downgrade unsigned gemini thinking
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
- 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.
|
- 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: 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.
|
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export {
|
|||||||
} from "./pi-embedded-helpers/errors.js";
|
} from "./pi-embedded-helpers/errors.js";
|
||||||
export {
|
export {
|
||||||
downgradeGeminiHistory,
|
downgradeGeminiHistory,
|
||||||
|
downgradeGeminiThinkingBlocks,
|
||||||
isGoogleModelApi,
|
isGoogleModelApi,
|
||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
} from "./pi-embedded-helpers/google.js";
|
} from "./pi-embedded-helpers/google.js";
|
||||||
|
|||||||
@@ -26,6 +26,58 @@ type GeminiToolCallBlock = {
|
|||||||
input?: unknown;
|
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[] {
|
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
|
||||||
const downgradedIds = new Set<string>();
|
const downgradedIds = new Set<string>();
|
||||||
const out: AgentMessage[] = [];
|
const out: AgentMessage[] = [];
|
||||||
|
|||||||
175
src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts
Normal file
175
src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
|||||||
|
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
import {
|
import {
|
||||||
|
downgradeGeminiThinkingBlocks,
|
||||||
downgradeGeminiHistory,
|
downgradeGeminiHistory,
|
||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isGoogleModelApi,
|
isGoogleModelApi,
|
||||||
@@ -148,10 +149,13 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
});
|
});
|
||||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||||
|
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||||
const downgraded = isGoogleModelApi(params.modelApi)
|
const downgradedThinking = isGoogleModelApi(params.modelApi)
|
||||||
? downgradeGeminiHistory(repairedTools)
|
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||||
: repairedTools;
|
: repairedTools;
|
||||||
|
const downgraded = isGoogleModelApi(params.modelApi)
|
||||||
|
? downgradeGeminiHistory(downgradedThinking)
|
||||||
|
: downgradedThinking;
|
||||||
|
|
||||||
return applyGoogleTurnOrderingFix({
|
return applyGoogleTurnOrderingFix({
|
||||||
messages: downgraded,
|
messages: downgraded,
|
||||||
|
|||||||
Reference in New Issue
Block a user