mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
fix(ai): correct Google thinking detection and remove unsupported id fields
- isThinkingPart now only checks thought === true, not thoughtSignature - thoughtSignature is for context replay and can appear on any part type - Store thoughtSignature on text blocks as textSignature for proper replay - Remove id from functionCall/functionResponse (unsupported by Vertex/Cloud Code Assist) Refs: https://ai.google.dev/gemini-api/docs/thought-signatures Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
parent
f5e97427ce
commit
4f757fbe23
5 changed files with 33 additions and 22 deletions
|
|
@ -511,6 +511,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||
});
|
||||
} else {
|
||||
currentBlock.text += part.text;
|
||||
if (part.thoughtSignature) {
|
||||
currentBlock.textSignature = retainThoughtSignature(
|
||||
currentBlock.textSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
}
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
|
|
@ -13,18 +13,19 @@ type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vert
|
|||
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
|
||||
*
|
||||
* Protocol note (Gemini / Vertex AI thought signatures):
|
||||
* - `thoughtSignature` may appear without `thought: true` (including in empty-text parts at the end of streaming).
|
||||
* - `thought: true` is the definitive marker for thinking content (thought summaries).
|
||||
* - `thoughtSignature` is an encrypted representation of the model's internal thought process
|
||||
* used to preserve reasoning context across multi-turn interactions.
|
||||
* - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT
|
||||
* indicate the part itself is thinking content.
|
||||
* - For non-functionCall responses, the signature appears on the last part for context replay.
|
||||
* - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is;
|
||||
* do not merge/move signatures across parts.
|
||||
* - Our streaming representation uses content blocks, so we classify any non-empty `thoughtSignature`
|
||||
* as thinking to avoid leaking thought content into normal assistant text.
|
||||
*
|
||||
* Some Google backends send thought content with `thoughtSignature` but omit `thought: true`
|
||||
* on subsequent deltas. We treat any non-empty `thoughtSignature` as thinking to avoid
|
||||
* leaking thought text into the normal assistant text stream.
|
||||
* See: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
*/
|
||||
export function isThinkingPart(part: Pick<Part, "thought" | "thoughtSignature">): boolean {
|
||||
return part.thought === true || (typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0);
|
||||
return part.thought === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -104,14 +105,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|||
} else if (block.type === "toolCall") {
|
||||
const part: Part = {
|
||||
functionCall: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
args: block.arguments,
|
||||
},
|
||||
};
|
||||
if (model.provider === "google-vertex" && part?.functionCall?.id) {
|
||||
delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall
|
||||
}
|
||||
if (block.thoughtSignature) {
|
||||
part.thoughtSignature = block.thoughtSignature;
|
||||
}
|
||||
|
|
@ -152,7 +149,6 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|||
|
||||
const functionResponsePart: Part = {
|
||||
functionResponse: {
|
||||
id: msg.toolCallId,
|
||||
name: msg.toolName,
|
||||
response: msg.isError ? { error: responseValue } : { output: responseValue },
|
||||
// Nest images inside functionResponse.parts for Gemini 3
|
||||
|
|
@ -160,10 +156,6 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|||
},
|
||||
};
|
||||
|
||||
if (model.provider === "google-vertex" && functionResponsePart.functionResponse?.id) {
|
||||
delete functionResponsePart.functionResponse.id; // Vertex AI does not support 'id' in functionResponse
|
||||
}
|
||||
|
||||
// Cloud Code Assist API requires all function responses to be in a single user turn.
|
||||
// Check if the last content is already a user turn with function responses and merge.
|
||||
const lastContent = contents[contents.length - 1];
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|||
});
|
||||
} else {
|
||||
currentBlock.text += part.text;
|
||||
if (part.thoughtSignature) {
|
||||
currentBlock.textSignature = retainThoughtSignature(
|
||||
currentBlock.textSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
}
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
|||
});
|
||||
} else {
|
||||
currentBlock.text += part.text;
|
||||
if (part.thoughtSignature) {
|
||||
currentBlock.textSignature = retainThoughtSignature(
|
||||
currentBlock.textSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
}
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-
|
|||
describe("Google thinking detection (thoughtSignature)", () => {
|
||||
it("treats part.thought === true as thinking", () => {
|
||||
expect(isThinkingPart({ thought: true, thoughtSignature: undefined })).toBe(true);
|
||||
expect(isThinkingPart({ thought: true, thoughtSignature: "opaque-signature" })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a non-empty thoughtSignature as thinking even if thought is missing", () => {
|
||||
// This is the bug: some backends omit `thought: true` but still include `thoughtSignature`
|
||||
expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(true);
|
||||
expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(true);
|
||||
it("does not treat thoughtSignature alone as thinking", () => {
|
||||
// Per Google docs, thoughtSignature is for context replay and can appear on any part type.
|
||||
// Only thought === true indicates thinking content.
|
||||
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(false);
|
||||
expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat empty/missing signatures as thinking if thought is not set", () => {
|
||||
|
|
@ -32,6 +35,4 @@ describe("Google thinking detection (thoughtSignature)", () => {
|
|||
const updated = retainThoughtSignature("sig-1", "sig-2");
|
||||
expect(updated).toBe("sig-2");
|
||||
});
|
||||
|
||||
// Note: signature-only parts (empty text + thoughtSignature) are handled by isThinkingPart via thoughtSignature presence.
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue