mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: restore Gemini thought signature round-tripping after AI SDK migration (#11237)
PR #11180 migrated Gemini/Vertex providers to the AI SDK and deleted gemini-format.ts which contained the working thought signature round-trip logic (originally added in PR #10590). This broke all Gemini 3 tool use with a 400 error: 'Function call is missing a thought_signature'. Changes: - Gemini/Vertex handlers: capture thoughtSignature from providerMetadata on tool-call stream events, expose via getThoughtSignature() - convertToAiSdkMessages(): extract thoughtSignature content blocks from history, attach as providerOptions on first tool-call part (per Gemini 3 parallel call rules) - Add 3 tests verifying thought signature round-trip behavior
This commit is contained in:
parent
a266834ee2
commit
6a32b2e6fe
4 changed files with 175 additions and 3 deletions
|
|
@ -31,6 +31,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
protected options: ApiHandlerOptions
|
||||
protected provider: GoogleGenerativeAIProvider
|
||||
private readonly providerName = "Gemini"
|
||||
private lastThoughtSignature: string | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
|
@ -124,11 +125,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
|
||||
try {
|
||||
// Reset thought signature for this request
|
||||
this.lastThoughtSignature = undefined
|
||||
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
// Process the full stream to get all events including reasoning
|
||||
for await (const part of result.fullStream) {
|
||||
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
|
||||
// The AI SDK's tool-call event includes providerMetadata with the signature
|
||||
if (part.type === "tool-call") {
|
||||
const googleMeta = (part as any).providerMetadata?.google
|
||||
if (googleMeta?.thoughtSignature) {
|
||||
this.lastThoughtSignature = googleMeta.thoughtSignature
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
|
|
@ -401,4 +414,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the thought signature captured from the last Gemini response.
|
||||
* Gemini 3 models return thoughtSignature on function call parts,
|
||||
* which must be round-tripped back for tool use continuations.
|
||||
*/
|
||||
getThoughtSignature(): string | undefined {
|
||||
return this.lastThoughtSignature
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
|
|||
protected options: ApiHandlerOptions
|
||||
protected provider: GoogleVertexProvider
|
||||
private readonly providerName = "Vertex"
|
||||
private lastThoughtSignature: string | undefined
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super()
|
||||
|
|
@ -138,11 +139,26 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
|
||||
try {
|
||||
// Reset thought signature for this request
|
||||
this.lastThoughtSignature = undefined
|
||||
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
// Process the full stream to get all events including reasoning
|
||||
for await (const part of result.fullStream) {
|
||||
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
|
||||
// The AI SDK's tool-call event includes providerMetadata with the signature
|
||||
// Vertex AI stores it under the "vertex" key in providerMetadata
|
||||
if (part.type === "tool-call") {
|
||||
const vertexMeta = (part as any).providerMetadata?.vertex
|
||||
const googleMeta = (part as any).providerMetadata?.google
|
||||
const sig = vertexMeta?.thoughtSignature ?? googleMeta?.thoughtSignature
|
||||
if (sig) {
|
||||
this.lastThoughtSignature = sig
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
|
|
@ -406,4 +422,13 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
|
|||
override isAiSdkProvider(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the thought signature captured from the last Vertex AI response.
|
||||
* Gemini 3 models return thoughtSignature on function call parts,
|
||||
* which must be round-tripped back for tool use continuations.
|
||||
*/
|
||||
getThoughtSignature(): string | undefined {
|
||||
return this.lastThoughtSignature
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -399,6 +399,101 @@ describe("AI SDK conversion utilities", () => {
|
|||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me check that." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
{ type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const assistantMsg = result[0]
|
||||
expect(assistantMsg.role).toBe("assistant")
|
||||
|
||||
const content = assistantMsg.content as any[]
|
||||
expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through)
|
||||
|
||||
const toolCallPart = content.find((p: any) => p.type === "tool-call")
|
||||
expect(toolCallPart).toBeDefined()
|
||||
expect(toolCallPart.providerOptions).toEqual({
|
||||
google: { thoughtSignature: "encrypted-sig-abc" },
|
||||
vertex: { thoughtSignature: "encrypted-sig-abc" },
|
||||
})
|
||||
})
|
||||
|
||||
it("attaches thoughtSignature only to the first tool-call in parallel calls", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "get_weather",
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-2",
|
||||
name: "get_weather",
|
||||
input: { city: "London" },
|
||||
},
|
||||
{ type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
const content = (result[0] as any).content as any[]
|
||||
|
||||
const toolCalls = content.filter((p: any) => p.type === "tool-call")
|
||||
expect(toolCalls).toHaveLength(2)
|
||||
|
||||
// Only the first tool call should have the signature
|
||||
expect(toolCalls[0].providerOptions).toEqual({
|
||||
google: { thoughtSignature: "sig-parallel" },
|
||||
vertex: { thoughtSignature: "sig-parallel" },
|
||||
})
|
||||
// Second tool call should NOT have the signature
|
||||
expect(toolCalls[1].providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not attach providerOptions when no thoughtSignature block is present", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Using tool" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
const content = (result[0] as any).content as any[]
|
||||
const toolCallPart = content.find((p: any) => p.type === "tool-call")
|
||||
|
||||
expect(toolCallPart).toBeDefined()
|
||||
expect(toolCallPart.providerOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToolsForAiSdk", () => {
|
||||
|
|
|
|||
|
|
@ -136,8 +136,19 @@ export function convertToAiSdkMessages(
|
|||
toolCallId: string
|
||||
toolName: string
|
||||
input: unknown
|
||||
providerOptions?: Record<string, Record<string, unknown>>
|
||||
}> = []
|
||||
|
||||
// Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping).
|
||||
// Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks.
|
||||
let thoughtSignature: string | undefined
|
||||
for (const part of message.content) {
|
||||
const partAny = part as unknown as { type?: string; thoughtSignature?: string }
|
||||
if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
|
||||
thoughtSignature = partAny.thoughtSignature
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
textParts.push(part.text)
|
||||
|
|
@ -145,12 +156,25 @@ export function convertToAiSdkMessages(
|
|||
}
|
||||
|
||||
if (part.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
const toolCall: (typeof toolCalls)[number] = {
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.input,
|
||||
})
|
||||
}
|
||||
|
||||
// Attach thoughtSignature as providerOptions on tool-call parts.
|
||||
// The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature
|
||||
// and attaches it to the Gemini functionCall part.
|
||||
// Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature.
|
||||
if (thoughtSignature && toolCalls.length === 0) {
|
||||
toolCall.providerOptions = {
|
||||
google: { thoughtSignature },
|
||||
vertex: { thoughtSignature },
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls.push(toolCall)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +207,13 @@ export function convertToAiSdkMessages(
|
|||
const content: Array<
|
||||
| { type: "reasoning"; text: string }
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
|
||||
| {
|
||||
type: "tool-call"
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: unknown
|
||||
providerOptions?: Record<string, Record<string, unknown>>
|
||||
}
|
||||
> = []
|
||||
|
||||
if (reasoningContent) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue