diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationFunctionCallingTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationFunctionCallingTests.cs index ef711c3586f6..f4008897c790 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationFunctionCallingTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatGenerationFunctionCallingTests.cs @@ -626,6 +626,83 @@ public async Task IfAutoInvokeShouldAllowFilterToModifyFunctionResultAsync() Assert.Contains(ModifiedResult, secondRequestContent); } + [Fact] + public async Task FunctionCallWithThoughtSignatureIsCapturedInToolCallAsync() + { + // Arrange + var responseWithThoughtSignature = File.ReadAllText("./TestData/chat_function_with_thought_signature_response.json") + .Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent(responseWithThoughtSignature); + + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.EnableFunctions([this._timePluginNow]) + }; + + // Act + var messages = await client.GenerateChatMessageAsync(chatHistory, executionSettings: executionSettings, kernel: this._kernelWithFunctions); + + // Assert + Assert.Single(messages); + var geminiMessage = messages[0] as GeminiChatMessageContent; + Assert.NotNull(geminiMessage); + Assert.NotNull(geminiMessage.ToolCalls); + Assert.Single(geminiMessage.ToolCalls); + Assert.Equal("test-thought-signature-abc123", geminiMessage.ToolCalls[0].ThoughtSignature); + } + + [Fact] + public async Task TextResponseWithThoughtSignatureIsCapturedInMetadataAsync() + { + // Arrange + var responseWithThoughtSignature = File.ReadAllText("./TestData/chat_text_with_thought_signature_response.json"); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent(responseWithThoughtSignature); + + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + + // Act + var messages = await client.GenerateChatMessageAsync(chatHistory); + + // Assert + Assert.Single(messages); + var geminiMessage = messages[0] as GeminiChatMessageContent; + Assert.NotNull(geminiMessage); + Assert.NotNull(geminiMessage.Metadata); + var metadata = geminiMessage.Metadata as GeminiMetadata; + Assert.NotNull(metadata); + Assert.Equal("text-response-thought-signature-xyz789", metadata.ThoughtSignature); + } + + [Fact] + public async Task ThoughtSignatureIsIncludedInSubsequentRequestAsync() + { + // Arrange - First response has function call with ThoughtSignature + var responseWithThoughtSignature = File.ReadAllText("./TestData/chat_function_with_thought_signature_response.json") + .Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal); + using var handlerStub = new MultipleHttpMessageHandlerStub(); + handlerStub.AddJsonResponse(responseWithThoughtSignature); + handlerStub.AddJsonResponse(this._responseContent); // Second response is text + + using var httpClient = new HttpClient(handlerStub, false); + var client = this.CreateChatCompletionClient(httpClient: httpClient); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions + }; + + // Act + await client.GenerateChatMessageAsync(chatHistory, executionSettings: executionSettings, kernel: this._kernelWithFunctions); + + // Assert - Check that the second request includes the ThoughtSignature + var secondRequestContent = handlerStub.GetRequestContentAsString(1); + Assert.NotNull(secondRequestContent); + Assert.Contains("test-thought-signature-abc123", secondRequestContent); + } + private static ChatHistory CreateSampleChatHistory() { var chatHistory = new ChatHistory(); diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatStreamingFunctionCallingTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatStreamingFunctionCallingTests.cs index cf7994d861f2..7a6e478cbc4e 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatStreamingFunctionCallingTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatStreamingFunctionCallingTests.cs @@ -417,6 +417,63 @@ await client.StreamGenerateChatMessageAsync(chatHistory, executionSettings: exec c is GeminiChatMessageContent gm && gm.Role == AuthorRole.Tool && gm.CalledToolResult is not null); } + [Fact] + public async Task StreamingTextResponseWithAutoInvokeAndEmptyToolCallsDoesNotEnterToolCallingBranchAsync() + { + // Arrange - This tests the Phase 6 bug fix: empty ToolCalls list should not trigger tool calling + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions + }; + + // Response is text-only (no function calls), so ToolCalls will be empty list + this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContent); + + // Act + var messages = await client.StreamGenerateChatMessageAsync( + chatHistory, + executionSettings: executionSettings, + kernel: this._kernelWithFunctions).ToListAsync(); + + // Assert - Should yield text response without entering tool-calling branch + Assert.NotEmpty(messages); + Assert.All(messages, m => + { + var geminiMessage = m as GeminiStreamingChatMessageContent; + Assert.NotNull(geminiMessage); + // ToolCalls should be null or empty for text responses + Assert.True(geminiMessage.ToolCalls is null || geminiMessage.ToolCalls.Count == 0); + }); + } + + [Fact] + public async Task StreamingTextResponseWithAutoInvokeAndNullToolCallsDoesNotEnterToolCallingBranchAsync() + { + // Arrange - This tests that pattern `is { Count: > 0 }` handles null safely + var client = this.CreateChatCompletionClient(); + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions + }; + + // Response is text-only + this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContent); + + // Act + var messages = await client.StreamGenerateChatMessageAsync( + chatHistory, + executionSettings: executionSettings, + kernel: this._kernelWithFunctions).ToListAsync(); + + // Assert - Should complete without errors + Assert.NotEmpty(messages); + // Verify we got text content + Assert.Contains(messages, m => !string.IsNullOrEmpty(m.Content)); + } + private static ChatHistory CreateSampleChatHistory() { var chatHistory = new ChatHistory(); diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiFunctionToolCallTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiFunctionToolCallTests.cs index ea361f35ca26..0bee8d86add7 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiFunctionToolCallTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiFunctionToolCallTests.cs @@ -68,4 +68,71 @@ public void ToStringReturnsCorrectValue() // Act & Assert Assert.Equal("MyPlugin_MyFunction(location:San Diego, max_price:300)", functionToolCall.ToString()); } + + [Fact] + public void ThoughtSignatureIsNullWhenCreatedFromFunctionCallPart() + { + // Arrange - Using the FunctionCallPart constructor (no ThoughtSignature) + var toolCallPart = new GeminiPart.FunctionCallPart { FunctionName = "MyFunction" }; + var functionToolCall = new GeminiFunctionToolCall(toolCallPart); + + // Act & Assert + Assert.Null(functionToolCall.ThoughtSignature); + } + + [Fact] + public void ThoughtSignatureIsCapturedWhenCreatedFromGeminiPart() + { + // Arrange - Using the GeminiPart constructor (with ThoughtSignature) + var part = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "MyFunction" }, + ThoughtSignature = "test-thought-signature-123" + }; + var functionToolCall = new GeminiFunctionToolCall(part); + + // Act & Assert + Assert.Equal("test-thought-signature-123", functionToolCall.ThoughtSignature); + } + + [Fact] + public void ThoughtSignatureIsNullWhenGeminiPartHasNoSignature() + { + // Arrange + var part = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "MyFunction" }, + ThoughtSignature = null + }; + var functionToolCall = new GeminiFunctionToolCall(part); + + // Act & Assert + Assert.Null(functionToolCall.ThoughtSignature); + } + + [Fact] + public void ArgumentsArePreservedWhenCreatedFromGeminiPart() + { + // Arrange + var part = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart + { + FunctionName = "MyPlugin_MyFunction", + Arguments = new JsonObject + { + { "location", "San Diego" }, + { "max_price", 300 } + } + }, + ThoughtSignature = "signature-abc" + }; + var functionToolCall = new GeminiFunctionToolCall(part); + + // Act & Assert + Assert.NotNull(functionToolCall.Arguments); + Assert.Equal(2, functionToolCall.Arguments.Count); + Assert.Equal("San Diego", functionToolCall.Arguments["location"]!.ToString()); + Assert.Equal("signature-abc", functionToolCall.ThoughtSignature); + } } diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiMetadataTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiMetadataTests.cs new file mode 100644 index 000000000000..a0c97a79ac79 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiMetadataTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Connectors.Google; +using Xunit; + +namespace SemanticKernel.Connectors.Google.UnitTests.Core.Gemini; + +/// +/// Unit tests for class. +/// +public sealed class GeminiMetadataTests +{ + [Fact] + public void ThoughtSignatureCanBeSetAndRetrieved() + { + // Arrange & Act + var metadata = new GeminiMetadata { ThoughtSignature = "test-signature-123" }; + + // Assert + Assert.Equal("test-signature-123", metadata.ThoughtSignature); + } + + [Fact] + public void ThoughtSignatureIsNullByDefault() + { + // Arrange & Act + var metadata = new GeminiMetadata(); + + // Assert + Assert.Null(metadata.ThoughtSignature); + } + + [Fact] + public void ThoughtSignatureIsStoredInDictionary() + { + // Arrange + var metadata = new GeminiMetadata { ThoughtSignature = "dict-signature" }; + + // Act + var hasKey = metadata.TryGetValue("ThoughtSignature", out var value); + + // Assert + Assert.True(hasKey); + Assert.Equal("dict-signature", value); + } + + [Fact] + public void ThoughtSignatureCanBeRetrievedFromDictionary() + { + // Arrange - This simulates deserialized metadata + var metadata = new GeminiMetadata { ThoughtSignature = "from-dict" }; + + // Act + var signature = metadata.ThoughtSignature; + + // Assert + Assert.Equal("from-dict", signature); + } +} diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiPartTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiPartTests.cs index c2414968edfd..f562a3249eb5 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiPartTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiPartTests.cs @@ -137,4 +137,124 @@ public GeminiPartTestData() this.Add(new() { Text = "text", InlineData = new(), FunctionCall = new(), FunctionResponse = new(), FileData = new() }); } } + + [Fact] + public void ThoughtSignatureDoesNotAffectIsValid() + { + // Arrange - ThoughtSignature is metadata, not content, so it shouldn't affect IsValid + var sut = new GeminiPart { ThoughtSignature = "test-signature" }; + + // Act + var result = sut.IsValid(); + + // Assert - Should be invalid because no content type is set + Assert.False(result); + } + + [Fact] + public void ThoughtSignatureWithFunctionCallIsValid() + { + // Arrange + var sut = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "test" }, + ThoughtSignature = "test-signature" + }; + + // Act + var result = sut.IsValid(); + + // Assert + Assert.True(result); + } + + [Fact] + public void ThoughtSignatureWithTextIsValid() + { + // Arrange + var sut = new GeminiPart + { + Text = "Hello", + ThoughtSignature = "test-signature" + }; + + // Act + var result = sut.IsValid(); + + // Assert + Assert.True(result); + } + + [Fact] + public void ThoughtSignatureSerializesToJson() + { + // Arrange + var sut = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "test_function" }, + ThoughtSignature = "abc123-signature" + }; + + // Act + var json = System.Text.Json.JsonSerializer.Serialize(sut); + + // Assert + Assert.Contains("\"thoughtSignature\":\"abc123-signature\"", json); + } + + [Fact] + public void ThoughtSignatureDeserializesFromJson() + { + // Arrange + var json = """ + { + "functionCall": { "name": "test_function" }, + "thoughtSignature": "xyz789-signature" + } + """; + + // Act + var sut = System.Text.Json.JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(sut); + Assert.Equal("xyz789-signature", sut.ThoughtSignature); + } + + [Fact] + public void ThoughtSignatureNullIsNotSerializedToJson() + { + // Arrange + var sut = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "test_function" }, + ThoughtSignature = null + }; + + // Act + var json = System.Text.Json.JsonSerializer.Serialize(sut); + + // Assert + Assert.DoesNotContain("thoughtSignature", json); + } + + [Fact] + public void ThoughtSignatureEmptyStringIsPreserved() + { + // Arrange - Empty string should be preserved (defensive coding) + var sut = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "test_function" }, + ThoughtSignature = "" + }; + + // Act + var json = System.Text.Json.JsonSerializer.Serialize(sut); + var deserialized = System.Text.Json.JsonSerializer.Deserialize(json); + + // Assert + Assert.Contains("\"thoughtSignature\":\"\"", json); + Assert.NotNull(deserialized); + Assert.Equal("", deserialized.ThoughtSignature); + } } diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs index 142f0b20e4c7..c1492b79ed64 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs @@ -781,6 +781,199 @@ public void FromChatHistoryMultiTurnConversationPreservesAllRoles() Assert.Equal("assistant-message-2", request.Contents[3].Parts![0].Text); } + [Fact] + public void FromChatHistoryToolCallsWithThoughtSignatureIncludesSignatureInRequest() + { + // Arrange + ChatHistory chatHistory = []; + var inputPart = new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart + { + FunctionName = "function-name", + Arguments = new JsonObject { ["key"] = "value" } + }, + ThoughtSignature = "thought-signature-abc123" + }; + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Assistant, "tool-message", "model-id", [inputPart])); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var requestParts = request.Contents[0].Parts; + Assert.NotNull(requestParts); + var requestPart = Assert.Single(requestParts); + Assert.NotNull(requestPart.FunctionCall); + Assert.Equal("thought-signature-abc123", requestPart.ThoughtSignature); + } + + [Fact] + public void FromChatHistoryToolCallsWithoutThoughtSignatureDoesNotIncludeSignature() + { + // Arrange + ChatHistory chatHistory = []; + var functionCallPart = new GeminiPart.FunctionCallPart + { + FunctionName = "function-name", + Arguments = new JsonObject { ["key"] = "value" } + }; + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Assistant, "tool-message", "model-id", functionsToolCalls: [functionCallPart])); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var requestParts = request.Contents[0].Parts; + Assert.NotNull(requestParts); + var requestPart = Assert.Single(requestParts); + Assert.Null(requestPart.ThoughtSignature); + } + + [Fact] + public void FromChatHistoryParallelToolCallsOnlyFirstHasThoughtSignature() + { + // Arrange - Parallel function calls: only first has ThoughtSignature per Google docs + ChatHistory chatHistory = []; + var geminiParts = new[] + { + new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "function1" }, + ThoughtSignature = "signature-for-first-only" + }, + new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "function2" }, + ThoughtSignature = null + }, + new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart { FunctionName = "function3" }, + ThoughtSignature = null + } + }; + chatHistory.Add(new GeminiChatMessageContent(AuthorRole.Assistant, null, "model-id", geminiParts)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var parts = request.Contents[0].Parts; + Assert.NotNull(parts); + Assert.Equal(3, parts.Count); + Assert.Equal("signature-for-first-only", parts[0].ThoughtSignature); + Assert.Null(parts[1].ThoughtSignature); + Assert.Null(parts[2].ThoughtSignature); + } + + [Fact] + public void FromChatHistoryTextResponseWithThoughtSignatureIncludesSignatureInRequest() + { + // Arrange - Text response with ThoughtSignature in Metadata + ChatHistory chatHistory = []; + var metadata = new GeminiMetadata { ThoughtSignature = "text-response-signature" }; + chatHistory.Add(new GeminiChatMessageContent( + AuthorRole.Assistant, + "This is a text response", + "model-id", + calledToolResults: null, + metadata)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var parts = request.Contents[0].Parts; + Assert.NotNull(parts); + var part = Assert.Single(parts); + Assert.Equal("This is a text response", part.Text); + Assert.Equal("text-response-signature", part.ThoughtSignature); + } + + [Fact] + public void FromChatHistoryTextResponseWithoutThoughtSignatureDoesNotIncludeSignature() + { + // Arrange - Text response without ThoughtSignature (thinking disabled) + ChatHistory chatHistory = []; + chatHistory.AddAssistantMessage("This is a text response"); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Single(request.Contents); + var parts = request.Contents[0].Parts; + Assert.NotNull(parts); + var part = Assert.Single(parts); + Assert.Null(part.ThoughtSignature); + } + + [Fact] + public void FromChatHistoryMultiTurnWithThoughtSignaturesPreservesAllSignatures() + { + // Arrange - Multi-turn conversation with different ThoughtSignatures + ChatHistory chatHistory = []; + chatHistory.AddUserMessage("Question 1"); + + var metadata1 = new GeminiMetadata { ThoughtSignature = "signature-turn-1" }; + chatHistory.Add(new GeminiChatMessageContent( + AuthorRole.Assistant, + "Answer 1", + "model-id", + calledToolResults: null, + metadata1)); + + chatHistory.AddUserMessage("Question 2"); + + var metadata2 = new GeminiMetadata { ThoughtSignature = "signature-turn-2" }; + chatHistory.Add(new GeminiChatMessageContent( + AuthorRole.Assistant, + "Answer 2", + "model-id", + calledToolResults: null, + metadata2)); + + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert + Assert.Equal(4, request.Contents.Count); + Assert.Null(request.Contents[0].Parts![0].ThoughtSignature); // User message + Assert.Equal("signature-turn-1", request.Contents[1].Parts![0].ThoughtSignature); // Assistant 1 + Assert.Null(request.Contents[2].Parts![0].ThoughtSignature); // User message + Assert.Equal("signature-turn-2", request.Contents[3].Parts![0].ThoughtSignature); // Assistant 2 + } + + [Fact] + public void FromChatHistoryThoughtSignatureFromDictionaryMetadataFallback() + { + // Arrange - Simulate deserialized chat history where Metadata is a dictionary + ChatHistory chatHistory = []; + var metadata = new Dictionary { ["ThoughtSignature"] = "fallback-signature" }; + chatHistory.Add(new ChatMessageContent(AuthorRole.Assistant, "Text response", "model-id", metadata)); + var executionSettings = new GeminiPromptExecutionSettings(); + + // Act + var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chatHistory, executionSettings); + + // Assert - Should NOT include signature because it's not a GeminiChatMessageContent + // The fallback only works for GeminiChatMessageContent with dictionary metadata + Assert.Single(request.Contents); + Assert.Null(request.Contents[0].Parts![0].ThoughtSignature); + } + private sealed class DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) : KernelContent(innerContent, modelId, metadata); diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_function_with_thought_signature_response.json b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_function_with_thought_signature_response.json new file mode 100644 index 000000000000..820e5744b040 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_function_with_thought_signature_response.json @@ -0,0 +1,66 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "TimePlugin%nameSeparator%Now", + "args": { + "param1": "hello" + } + }, + "thoughtSignature": "test-thought-signature-abc123" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + }, + "usageMetadata": { + "promptTokenCount": 9, + "candidatesTokenCount": 27, + "totalTokenCount": 36 + } +} + diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_text_with_thought_signature_response.json b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_text_with_thought_signature_response.json new file mode 100644 index 000000000000..f3e82de0af6d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_text_with_thought_signature_response.json @@ -0,0 +1,61 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "This is a text response with thinking enabled.", + "thoughtSignature": "text-response-thought-signature-xyz789" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + }, + "usageMetadata": { + "promptTokenCount": 9, + "candidatesTokenCount": 27, + "totalTokenCount": 36 + } +} + diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs index 2d093c2207cd..3c3501622b74 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -236,6 +237,9 @@ public async IAsyncEnumerable StreamGenerateChatMes for (state.Iteration = 1; ; state.Iteration++) { + // Reset LastMessage at the start of each iteration to detect if tool calls were found + state.LastMessage = null; + using (var activity = ModelDiagnostics.StartCompletionActivity( this._chatGenerationEndpoint, this._modelId, ModelProvider, chatHistory, state.ExecutionSettings)) { @@ -288,7 +292,8 @@ public async IAsyncEnumerable StreamGenerateChatMes } } - if (!state.AutoInvoke) + // If auto-invoke is disabled or no tool calls were found, we're done + if (!state.AutoInvoke || state.LastMessage is null) { yield break; } @@ -350,39 +355,126 @@ private async IAsyncEnumerable GetStreamingChatMess { var chatResponsesEnumerable = this.ProcessChatResponseStreamAsync(responseStream, ct: ct); IAsyncEnumerator chatResponsesEnumerator = null!; + + // Track content and items from chunks before tool calls (lazy-init, only used when AutoInvoke is enabled) + StringBuilder? preToolCallContent = null; + List? preToolCallItems = null; + try { chatResponsesEnumerator = chatResponsesEnumerable.GetAsyncEnumerator(ct); while (await chatResponsesEnumerator.MoveNextAsync().ConfigureAwait(false)) { var messageContent = chatResponsesEnumerator.Current; - if (state.AutoInvoke && messageContent.ToolCalls is not null) + if (state.AutoInvoke && messageContent.ToolCalls is { Count: > 0 }) { - if (await chatResponsesEnumerator.MoveNextAsync().ConfigureAwait(false)) + // Accumulate all tool calls from streaming chunks (needed for Gemini 3 with thought signatures) + // where multiple chunks may each contain function calls + var allToolCalls = new List(messageContent.ToolCalls); + var combinedContent = new StringBuilder(); + var allItems = new List(); + GeminiMetadata? lastMetadata = messageContent.Metadata as GeminiMetadata; + + // Include any content and items accumulated before we saw tool calls + if (preToolCallContent is { Length: > 0 }) { - // We disable auto-invoke because we have more than one message in the stream. - // This scenario should not happen but I leave it as a precaution - state.AutoInvoke = false; - // We return the first message - yield return this.GetStreamingChatContentFromChatContent(messageContent); - // We return the second message - messageContent = chatResponsesEnumerator.Current; - yield return this.GetStreamingChatContentFromChatContent(messageContent); - continue; + combinedContent.Append(preToolCallContent); + } + if (preToolCallItems is { Count: > 0 }) + { + allItems.AddRange(preToolCallItems); } - // If function call was returned there is no more data in stream - state.LastMessage = messageContent; - - // Yield the message also if it contains text + // Accumulate content and items from first tool-call chunk if (!string.IsNullOrWhiteSpace(messageContent.Content)) { - yield return this.GetStreamingChatContentFromChatContent(messageContent); + combinedContent.Append(messageContent.Content); + } + if (messageContent.Items is { Count: > 0 }) + { + allItems.AddRange(messageContent.Items); } + // Yield the first chunk + yield return this.GetStreamingChatContentFromChatContent(messageContent); + + // Consume the entire stream - accumulate tool calls, content, and items from all chunks + while (await chatResponsesEnumerator.MoveNextAsync().ConfigureAwait(false)) + { + var nextMessage = chatResponsesEnumerator.Current; + + // Always update metadata to capture the final chunk's usage stats and finish reason + if (nextMessage.Metadata is GeminiMetadata metadata) + { + lastMetadata = metadata; + } + + // Accumulate tool calls if present + if (nextMessage.ToolCalls is { Count: > 0 }) + { + allToolCalls.AddRange(nextMessage.ToolCalls); + } + + // Accumulate content if present + if (!string.IsNullOrWhiteSpace(nextMessage.Content)) + { + combinedContent.Append(nextMessage.Content); + } + + // Accumulate items (ReasoningContent) if present + if (nextMessage.Items is { Count: > 0 }) + { + allItems.AddRange(nextMessage.Items); + } + + // Always yield the chunk to the caller for streaming output + yield return this.GetStreamingChatContentFromChatContent(nextMessage); + } + + // Create a combined message with all accumulated tool calls for auto-invoke processing + // Note: We must preserve thought signatures from each tool call + var combinedMessage = new GeminiChatMessageContent( + role: messageContent.Role, + content: combinedContent.Length > 0 ? combinedContent.ToString() : null, + modelId: messageContent.ModelId ?? this._modelId, + partsWithFunctionCalls: allToolCalls.Select(tc => new GeminiPart + { + FunctionCall = new GeminiPart.FunctionCallPart + { + FunctionName = tc.FullyQualifiedName, + Arguments = tc.Arguments != null ? JsonSerializer.SerializeToNode(tc.Arguments) : null + }, + ThoughtSignature = tc.ThoughtSignature + }).ToArray(), + metadata: lastMetadata); + + // Add accumulated items (ReasoningContent) to the combined message + // These are needed for chat history to preserve the model's reasoning trace + foreach (var item in allItems) + { + combinedMessage.Items.Add(item); + } + + state.LastMessage = combinedMessage; yield break; } + // Track content and items before we see tool calls (only if auto-invoke enabled) + // This ensures pre-tool-call content is included in state.LastMessage for chat history + if (state.AutoInvoke) + { + if (!string.IsNullOrWhiteSpace(messageContent.Content)) + { + preToolCallContent ??= new StringBuilder(); + preToolCallContent.Append(messageContent.Content); + } + if (messageContent.Items is { Count: > 0 }) + { + preToolCallItems ??= []; + preToolCallItems.AddRange(messageContent.Items); + } + } + // If we don't want to attempt to invoke any functions, just return the result. yield return this.GetStreamingChatContentFromChatContent(messageContent); } @@ -776,17 +868,23 @@ private GeminiChatMessageContent GetChatMessageContentFromCandidate(GeminiRespon var regularText = string.Concat(regularTextParts); // Gemini sometimes returns function calls with text parts, so collect them - var toolCalls = candidate.Content?.Parts? - .Select(part => part.FunctionCall!) - .Where(toolCall => toolCall is not null).ToArray(); + // Use full GeminiPart[] to preserve ThoughtSignature for function calls with thinking enabled + var partsWithFunctionCalls = candidate.Content?.Parts? + .Where(part => part.FunctionCall is not null).ToArray(); + + // For text responses (no function calls), capture ThoughtSignature from last part for metadata + // Per Google docs: "The final content part returned by the model may contain a thought_signature" + var lastPart = candidate.Content?.Parts?.LastOrDefault(); + var hasFunctionCalls = partsWithFunctionCalls is { Length: > 0 }; + string? textThoughtSignature = (!hasFunctionCalls && lastPart?.FunctionCall is null) ? lastPart?.ThoughtSignature : null; // Pass null if there's no regular (non-thinking) text to avoid creating an empty TextContent item var chatMessage = new GeminiChatMessageContent( role: candidate.Content?.Role ?? AuthorRole.Assistant, content: string.IsNullOrEmpty(regularText) ? null : regularText, modelId: this._modelId, - functionsToolCalls: toolCalls, - metadata: GetResponseMetadata(geminiResponse, candidate)); + partsWithFunctionCalls: partsWithFunctionCalls, + metadata: GetResponseMetadata(geminiResponse, candidate, textThoughtSignature)); // Add items to the message foreach (var item in items) @@ -874,7 +972,8 @@ private static void ValidateAutoInvoke(bool autoInvoke, int resultsPerPrompt) private static GeminiMetadata GetResponseMetadata( GeminiResponse geminiResponse, - GeminiResponseCandidate candidate) => new() + GeminiResponseCandidate candidate, + string? thoughtSignature = null) => new() { FinishReason = candidate.FinishReason, Index = candidate.Index, @@ -887,6 +986,7 @@ private static GeminiMetadata GetResponseMetadata( PromptFeedbackBlockReason = geminiResponse.PromptFeedback?.BlockReason, PromptFeedbackSafetyRatings = geminiResponse.PromptFeedback?.SafetyRatings.ToList(), ResponseSafetyRatings = candidate.SafetyRatings?.ToList(), + ThoughtSignature = thoughtSignature, }; private sealed class ChatCompletionState diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs index 725e6ae54fb3..d49c196da744 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiPart.cs @@ -54,6 +54,19 @@ internal sealed class GeminiPart : IJsonOnDeserialized [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Thought { get; set; } + /// + /// Gets or sets the thought signature for maintaining reasoning context across turns. + /// + /// + /// When thinking is enabled, Gemini returns an opaque signature that must be included + /// in subsequent requests to maintain reasoning context. For function calls, this is + /// mandatory (HTTP 400 without it). For text responses, it is recommended for optimal + /// reasoning quality. + /// + [JsonPropertyName("thoughtSignature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ThoughtSignature { get; set; } + /// /// Checks whether only one property of the GeminiPart instance is not null. /// Returns true if only one property among Text, InlineData, FileData, FunctionCall, and FunctionResponse is not null, diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs index 49a24f05f041..cd15974a886c 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs @@ -211,7 +211,8 @@ private static List CreateGeminiParts(ChatMessageContent content) { FunctionName = toolCall.FullyQualifiedName, Arguments = JsonSerializer.SerializeToNode(toolCall.Arguments), - } + }, + ThoughtSignature = toolCall.ThoughtSignature })); break; default: @@ -224,6 +225,35 @@ private static List CreateGeminiParts(ChatMessageContent content) parts.Add(new GeminiPart { Text = content.Content ?? string.Empty }); } + // Restore ThoughtSignature for text responses (non-ToolCall messages) + // Per Google docs: "The final content part returned by the model may contain a thought_signature" + if (content is GeminiChatMessageContent geminiContent + && (geminiContent.ToolCalls is null || geminiContent.ToolCalls.Count == 0)) + { + string? signature = null; + + // Try typed GeminiMetadata first, then dictionary fallback for deserialized history + if (geminiContent.Metadata is GeminiMetadata meta) + { + signature = meta.ThoughtSignature; + } + else if (content.Metadata?.TryGetValue("ThoughtSignature", out var sigObj) == true + && sigObj is string sigStr) + { + signature = sigStr; + } + + if (signature is not null) + { + // Apply signature to last text part + var lastTextPart = parts.LastOrDefault(p => p.Text is not null); + if (lastTextPart is not null) + { + lastTextPart.ThoughtSignature = signature; + } + } + } + return parts; } diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs index 723d7f99dcaf..558d27548cf4 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs @@ -133,6 +133,38 @@ internal GeminiChatMessageContent( this.ToolCalls = functionsToolCalls?.Select(tool => new GeminiFunctionToolCall(tool)).ToList(); } + /// + /// Initializes a new instance of the class. + /// + /// Role of the author of the message + /// Content of the message + /// The model ID used to generate the content + /// Parts containing function calls (preserves ThoughtSignature) + /// Additional metadata + /// + /// This constructor preserves from the parent + /// , which is required for function calling when thinking is enabled. + /// + internal GeminiChatMessageContent( + AuthorRole role, + string? content, + string modelId, + IEnumerable? partsWithFunctionCalls, + GeminiMetadata? metadata = null) + : base( + role: role, + content: content, + modelId: modelId, + innerContent: content, + encoding: Encoding.UTF8, + metadata: metadata) + { + this.ToolCalls = partsWithFunctionCalls? + .Where(p => p.FunctionCall is not null) + .Select(part => new GeminiFunctionToolCall(part)) + .ToList(); + } + /// /// A list of the tools returned by the model with arguments. /// diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiFunctionToolCall.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiFunctionToolCall.cs index 79fb416eddd6..1c75314ae50e 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiFunctionToolCall.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiFunctionToolCall.cs @@ -9,7 +9,7 @@ namespace Microsoft.SemanticKernel.Connectors.Google; /// -/// Represents an Gemini function tool call with deserialized function name and arguments. +/// Represents a Gemini function tool call with deserialized function name and arguments. /// public sealed class GeminiFunctionToolCall { @@ -41,6 +41,39 @@ internal GeminiFunctionToolCall(GeminiPart.FunctionCallPart functionToolCall) } } + /// Initialize the from a containing a function call. + /// + /// This constructor preserves the from the parent , + /// which is required for function calling when thinking is enabled. + /// + internal GeminiFunctionToolCall(GeminiPart part) + { + Verify.NotNull(part); + Verify.NotNull(part.FunctionCall); + Verify.NotNull(part.FunctionCall.FunctionName); + + string fullyQualifiedFunctionName = part.FunctionCall.FunctionName; + string functionName = fullyQualifiedFunctionName; + string? pluginName = null; + + int separatorPos = fullyQualifiedFunctionName.IndexOf(GeminiFunction.NameSeparator, StringComparison.Ordinal); + if (separatorPos >= 0) + { + pluginName = fullyQualifiedFunctionName.AsSpan(0, separatorPos).Trim().ToString(); + functionName = fullyQualifiedFunctionName.AsSpan(separatorPos + GeminiFunction.NameSeparator.Length).Trim().ToString(); + } + + this._fullyQualifiedFunctionName = fullyQualifiedFunctionName; + this.PluginName = pluginName; + this.FunctionName = functionName; + if (part.FunctionCall.Arguments is not null) + { + this.Arguments = part.FunctionCall.Arguments.Deserialize>(); + } + + this.ThoughtSignature = part.ThoughtSignature; + } + /// Gets the name of the plugin with which this function is associated, if any. public string? PluginName { get; } @@ -50,6 +83,14 @@ internal GeminiFunctionToolCall(GeminiPart.FunctionCallPart functionToolCall) /// Gets a name/value collection of the arguments to the function, if any. public IReadOnlyDictionary? Arguments { get; } + /// Gets the thought signature for this function call, if any. + /// + /// When thinking is enabled, Gemini returns an opaque signature that must be included + /// in subsequent requests when sending function results. This is mandatory for + /// Gemini 2.5/3 Pro with thinking enabled. + /// + public string? ThoughtSignature { get; } + /// Gets the fully-qualified name of the function. /// /// This is the concatenation of the and the , diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiMetadata.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiMetadata.cs index 1765cebb6ce5..0493867a3408 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiMetadata.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiMetadata.cs @@ -115,6 +115,20 @@ public IReadOnlyList? ResponseSafetyRatings internal init => this.SetValueInDictionary(value, nameof(this.ResponseSafetyRatings)); } + /// + /// The thought signature for text responses with thinking enabled. + /// + /// + /// When thinking is enabled, Gemini may return a signature on the last text part that should + /// be included in subsequent requests to maintain optimal reasoning quality. Unlike function + /// call signatures, text response signatures are recommended but not strictly validated. + /// + public string? ThoughtSignature + { + get => this.GetValueFromDictionary(nameof(this.ThoughtSignature)) as string; + internal init => this.SetValueInDictionary(value, nameof(this.ThoughtSignature)); + } + /// /// Converts a dictionary to a object. ///