Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
? (this.options.modelMaxTokens ?? maxTokens ?? undefined)
: (maxTokens ?? undefined)

// Only forward encrypted reasoning continuations (thoughtSignature) when we are
// using reasoning (thinkingConfig is present). Both effort-based (thinkingLevel)
// and budget-based (thinkingBudget) models require this for active loops.
const includeThoughtSignatures = Boolean(thinkingConfig)
// Gemini 3 validates thought signatures for tool/function calling steps.
// We must round-trip the signature when tools are in use, even if the user chose
// a minimal thinking level (or thinkingConfig is otherwise absent).
const usingNativeTools = Boolean(metadata?.tools && metadata.tools.length > 0)
const includeThoughtSignatures = Boolean(thinkingConfig) || usingNativeTools

// The message list can include provider-specific meta entries such as
// `{ type: "reasoning", ... }` that are intended only for providers like
Expand Down Expand Up @@ -236,9 +237,10 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
}>) {
// Capture thought signatures so they can be persisted into API history.
const thoughtSignature = part.thoughtSignature
// Persist encrypted reasoning when using reasoning. Both effort-based
// and budget-based models require this for active loops.
if (thinkingConfig && thoughtSignature) {
// Persist thought signatures so they can be round-tripped in the next step.
// Gemini 3 requires this during tool calling; other Gemini thinking models
// benefit from it for continuity.
if (includeThoughtSignatures && thoughtSignature) {
this.lastThoughtSignature = thoughtSignature
}

Expand Down Expand Up @@ -455,10 +457,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
}

public getThoughtSignature(): string | undefined {
// Disabled to prevent "Corrupted thought signature" errors on task resumption.
// Gemini thought signatures are session-specific and cannot be reliably reused
// across API calls or after task resumption from history.
return undefined
return this.lastThoughtSignature
}

public getResponseId(): string | undefined {
Expand Down
21 changes: 21 additions & 0 deletions src/api/transform/__tests__/gemini-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,27 @@ describe("convertAnthropicMessageToGemini", () => {
])
})

it("should only attach thoughtSignature to the first functionCall in the message", () => {
const anthropicMessage: Anthropic.Messages.MessageParam = {
role: "assistant",
content: [
{ type: "thoughtSignature", thoughtSignature: "sig-123" } as any,
{ type: "tool_use", id: "call-1", name: "toolA", input: { a: 1 } },
{ type: "tool_use", id: "call-2", name: "toolB", input: { b: 2 } },
],
}

const result = convertAnthropicMessageToGemini(anthropicMessage)
expect(result).toHaveLength(1)

const parts = result[0]!.parts as any[]
const functionCallParts = parts.filter((p) => p.functionCall)
expect(functionCallParts).toHaveLength(2)

expect(functionCallParts[0].thoughtSignature).toBe("sig-123")
expect(functionCallParts[1].thoughtSignature).toBeUndefined()
})

it("should convert a message with tool result as string", () => {
const toolIdToName = new Map<string, string>()
toolIdToName.set("calculator-123", "calculator")
Expand Down
28 changes: 26 additions & 2 deletions src/api/transform/gemini-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,19 @@ export function convertAnthropicContentToGemini(

return { inlineData: { data: block.source.data, mimeType: block.source.media_type } }
case "tool_use":
// Gemini 3 validation rules:
// - In a parallel function calling response, only the FIRST functionCall part has a signature.
// - In sequential steps, each step's first functionCall must include its signature.
// When converting from our history, we don't always have enough information to perfectly
// recreate the original per-part distribution, but we can and should avoid attaching the
// signature to every parallel call in a single assistant message.
return {
functionCall: {
name: block.name,
args: block.input as Record<string, unknown>,
},
// Inject the thoughtSignature into the functionCall part if required.
// This is necessary for Gemini 2.5/3+ thinking models to validate the tool call.
// This is necessary for Gemini 3+ thinking models to validate the tool call.
...(functionCallSignature ? { thoughtSignature: functionCallSignature } : {}),
} as Part
case "tool_result": {
Expand Down Expand Up @@ -136,7 +142,10 @@ export function convertAnthropicContentToGemini(
}
})

// Post-processing: Ensure thought signature is attached if required
// Post-processing:
// 1) Ensure thought signature is attached if required
// 2) For multiple function calls in a single message, keep the signature only on the first
// functionCall part to match Gemini 3 parallel-calling behavior.
if (includeThoughtSignatures && activeThoughtSignature) {
const hasSignature = parts.some((p) => "thoughtSignature" in p)

Expand All @@ -153,6 +162,21 @@ export function convertAnthropicContentToGemini(
}
}

if (includeThoughtSignatures) {
let seenFirstFunctionCall = false
for (const part of parts) {
if (part && typeof part === "object" && "functionCall" in part && (part as any).functionCall) {
const partWithSig = part as PartWithThoughtSignature
if (!seenFirstFunctionCall) {
seenFirstFunctionCall = true
} else {
// Remove signature from subsequent function calls in this message.
delete partWithSig.thoughtSignature
}
}
}
}

return parts
}

Expand Down
17 changes: 12 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const reasoningSummary = handler.getSummary?.()
const reasoningDetails = handler.getReasoningDetails?.()

// Only Anthropic's API expects/validates the special `thinking` content block signature.
// Other providers (notably Gemini 3) use different signature semantics (e.g. `thoughtSignature`)
// and require round-tripping the signature in their own format.
const modelId = getModelId(this.apiConfiguration)
const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId)
const isAnthropicProtocol = apiProtocol === "anthropic"

// Start from the original assistant message
const messageWithTs: any = {
...message,
Expand All @@ -969,7 +976,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native)
// Skip if reasoning_details already contains the reasoning (to avoid duplication)
if (reasoning && thoughtSignature && !reasoningDetails) {
if (isAnthropicProtocol && reasoning && thoughtSignature && !reasoningDetails) {
// Anthropic provider with extended thinking: Store as proper `thinking` block
// This format passes through anthropic-filter.ts and is properly round-tripped
// for interleaved thinking with tool use (required by Anthropic API)
Expand Down Expand Up @@ -1028,10 +1035,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

// If we have a thought signature WITHOUT reasoning text (edge case),
// append it as a dedicated content block for non-Anthropic providers (e.g., Gemini).
// Note: For Anthropic, the signature is already included in the thinking block above.
if (thoughtSignature && !reasoning) {
// For non-Anthropic providers (e.g., Gemini 3), persist the thought signature as its own
// content block so converters can attach it back to the correct provider-specific fields.
// Note: For Anthropic extended thinking, the signature is already included in the thinking block above.
if (thoughtSignature && !isAnthropicProtocol) {
const thoughtSignatureBlock = {
type: "thoughtSignature",
thoughtSignature,
Expand Down
Loading