diff --git a/components/model/gemini/gemini.go b/components/model/gemini/gemini.go index a1c87c05b..0abddc2cb 100644 --- a/components/model/gemini/gemini.go +++ b/components/model/gemini/gemini.go @@ -1179,6 +1179,7 @@ func convCandidate(candidate *genai.Candidate) (*schema.Message, error) { outParts []schema.MessageOutputPart contentBuilder strings.Builder ) + // Process parts and extract thought signatures per Gemini docs: // https://cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures // @@ -1186,9 +1187,21 @@ func convCandidate(candidate *genai.Candidate) (*schema.Message, error) { // - functionCall parts: signature stored on ToolCall.Extra (required for Gemini 3 Pro) // - output parts (text/inlineData/...): signature stored on MessageOutputPart.Extra // - message.Extra is only used when output parts are absent + + // There are rare cases that the response contains no thoughtSignature in the first part of parallel functionCall + firstFunctionCallIdx := -1 + fallbackFunctionCallSig := []byte(nil) for _, part := range candidate.Content.Parts { + if part.FunctionCall != nil && firstFunctionCallIdx < 0 { + firstFunctionCallIdx = len(result.ToolCalls) + } + // Store thought signature at message level for non-functionCall parts if len(part.ThoughtSignature) > 0 && part.FunctionCall == nil { + // means that we have functionCall but without thoughtSignature + if firstFunctionCallIdx >= 0 && len(fallbackFunctionCallSig) == 0 { + fallbackFunctionCallSig = part.ThoughtSignature + } setMessageThoughtSignature(result, part.ThoughtSignature) } @@ -1248,6 +1261,15 @@ func convCandidate(candidate *genai.Candidate) (*schema.Message, error) { outParts = append(outParts, outPart) } } + + // Gemini 3 streaming may place the step thought signature on a trailing empty + // non-function part instead of the first functionCall part. + if firstFunctionCallIdx >= 0 && len(fallbackFunctionCallSig) > 0 { + firstCall := &result.ToolCalls[firstFunctionCallIdx] + if len(getToolCallThoughtSignature(firstCall)) == 0 { + setToolCallThoughtSignature(firstCall, fallbackFunctionCallSig) + } + } result.Content = contentBuilder.String() if len(texts) > 1 { for _, text := range texts { diff --git a/components/model/gemini/gemini_test.go b/components/model/gemini/gemini_test.go index 741b7dba4..46eb6fcae 100644 --- a/components/model/gemini/gemini_test.go +++ b/components/model/gemini/gemini_test.go @@ -927,6 +927,49 @@ func TestThoughtSignatureRoundTrip(t *testing.T) { assert.NoError(t, err) assert.Equal(t, sigB, content2.Parts[0].ThoughtSignature) }) + + t.Run("convCandidate moves trailing step signature onto first function call", func(t *testing.T) { + signature := []byte("trailing_function_step_signature") + + candidate := &genai.Candidate{ + Content: &genai.Content{ + Role: roleModel, + Parts: []*genai.Part{ + { + Text: "thinking before tool", + Thought: true, + }, + { + FunctionCall: &genai.FunctionCall{ + Name: "fc", + Args: map[string]any{}, + }, + }, + { + Text: "", + ThoughtSignature: signature, + }, + }, + }, + } + + message, err := convCandidate(candidate) + assert.NoError(t, err) + assert.NotNil(t, message) + if assert.Len(t, message.ToolCalls, 1) { + sig, ok := GetThoughtSignatureFromExtra(message.ToolCalls[0].Extra) + assert.True(t, ok) + assert.Equal(t, signature, sig) + } + + content, err := convSchemaMessage(message) + assert.NoError(t, err) + if assert.Len(t, content.Parts, 2) { + assert.True(t, content.Parts[0].Thought) + assert.NotNil(t, content.Parts[1].FunctionCall) + assert.Equal(t, signature, content.Parts[1].ThoughtSignature) + } + }) } func TestCreatePrefixCache(t *testing.T) {