diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs new file mode 100644 index 000000000000..49b96537bdf3 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.Google.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Google.UnitTests.Core.Gemini.Clients; + +/// +/// Unit tests for IChatClient-based function calling with Gemini using FunctionChoiceBehavior. +/// +public sealed class GeminiChatClientFunctionCallingTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly string _responseContent; + private readonly string _responseContentWithFunction; + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly GeminiFunction _timePluginDate, _timePluginNow; + private readonly Kernel _kernelWithFunctions; + private const string ChatTestDataFilePath = "./TestData/chat_one_response.json"; + private const string ChatTestDataWithFunctionFilePath = "./TestData/chat_one_function_response.json"; + + public GeminiChatClientFunctionCallingTests() + { + this._responseContent = File.ReadAllText(ChatTestDataFilePath); + this._responseContentWithFunction = File.ReadAllText(ChatTestDataWithFunctionFilePath) + .Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal); + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent( + this._responseContent); + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + + var kernelPlugin = KernelPluginFactory.CreateFromFunctions("TimePlugin", new[] + { + KernelFunctionFactory.CreateFromMethod((string? format = null) + => DateTime.Now.Date.ToString(format, CultureInfo.InvariantCulture), "Date", "TimePlugin.Date"), + KernelFunctionFactory.CreateFromMethod(() + => DateTime.Now.ToString("", CultureInfo.InvariantCulture), "Now", "TimePlugin.Now", + parameters: [new KernelParameterMetadata("param1") { ParameterType = typeof(string), Description = "desc", IsRequired = false }]), + }); + IList functions = kernelPlugin.GetFunctionsMetadata(); + + this._timePluginDate = functions[0].ToGeminiFunction(); + this._timePluginNow = functions[1].ToGeminiFunction(); + + this._kernelWithFunctions = new Kernel(); + this._kernelWithFunctions.Plugins.Add(kernelPlugin); + } + + [Fact] + public async Task ChatClientShouldConvertToIChatClientSuccessfullyAsync() + { + // Arrange + var chatCompletionService = this.CreateChatCompletionService(); + + // Act + var chatClient = chatCompletionService.AsChatClient(); + + // Assert - Verify conversion works + Assert.NotNull(chatClient); + Assert.IsAssignableFrom(chatClient); + + // Verify we can make a basic call through IChatClient + var messages = new List + { + new(ChatRole.User, "What time is it?") + }; + + var response = await chatClient.GetResponseAsync(messages); + + Assert.NotNull(response); + Assert.NotEmpty(response.Messages); + } + + [Fact] + public async Task ChatClientShouldReceiveFunctionCallsInResponseAsync() + { + // Arrange + this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContentWithFunction); + var chatCompletionService = this.CreateChatCompletionService(); + var chatClient = chatCompletionService.AsChatClient(); + + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false) + }; + var chatOptions = settings.ToChatOptions(this._kernelWithFunctions); + + var messages = new List + { + new(ChatRole.User, "What time is it?") + }; + + // Act + var response = await chatClient.GetResponseAsync(messages, chatOptions); + + // Assert - Verify that FunctionCallContent is returned in the response + Assert.NotNull(response); + var functionCalls = response.Messages + .SelectMany(m => m.Contents) + .OfType() + .ToList(); + + Assert.NotEmpty(functionCalls); + var functionCall = functionCalls.First(); + Assert.Contains(this._timePluginNow.FunctionName, functionCall.Name, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ChatClientShouldStreamResponsesAsync() + { + // Arrange + var chatCompletionService = this.CreateChatCompletionService(); + var chatClient = chatCompletionService.AsChatClient(); + + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var chatOptions = settings.ToChatOptions(this._kernelWithFunctions); + + var messages = new List + { + new(ChatRole.User, "What time is it?") + }; + + // Act + var updates = new List(); + await foreach (var update in chatClient.GetStreamingResponseAsync(messages, chatOptions)) + { + updates.Add(update); + } + + // Assert - Verify that streaming works and returns updates + Assert.NotEmpty(updates); + } + + [Fact] + public async Task AsChatClientConvertsServiceToIChatClientAsync() + { + // Arrange + var chatCompletionService = this.CreateChatCompletionService(); + + // Act + var chatClient = chatCompletionService.AsChatClient(); + + // Assert + Assert.NotNull(chatClient); + Assert.IsAssignableFrom(chatClient); + } + + private GoogleAIGeminiChatCompletionService CreateChatCompletionService(HttpClient? httpClient = null) + { + return new GoogleAIGeminiChatCompletionService( + modelId: "fake-model", + apiKey: "fake-key", + apiVersion: GoogleAIVersion.V1, + httpClient: httpClient ?? this._httpClient); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index 958f2ad27082..4833fac1a4cd 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -194,6 +194,111 @@ public void KernelFunctionsCloneReturnsCorrectClone() Assert.Equivalent(toolcallbehavior, clone, strict: true); } + [Fact] + public void FunctionChoiceBehaviorAutoConvertsToAutoInvokeKernelFunctions() + { + // Arrange + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); + } + + [Fact] + public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnableKernelFunctions() + { + // Arrange + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false) + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void FunctionChoiceBehaviorRequiredConvertsToAutoInvokeKernelFunctions() + { + // Arrange + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Required() + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); + } + + [Fact] + public void FunctionChoiceBehaviorNoneConvertsToEnableKernelFunctions() + { + // Arrange + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.None() + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + // None behavior doesn't auto-invoke + Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void GeminiPromptExecutionSettingsWithNoFunctionChoiceBehaviorDoesNotSetToolCallBehavior() + { + // Arrange + var settings = new GeminiPromptExecutionSettings(); + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert + Assert.Null(converted.ToolCallBehavior); + } + + [Fact] + public void GeminiPromptExecutionSettingsPreservesExistingToolCallBehavior() + { + // Arrange + var settings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.EnableKernelFunctions, + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + + // Assert - ToolCallBehavior should be preserved when already set + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); + } + private static KernelPlugin GetTestPlugin() { var function = KernelFunctionFactory.CreateFromMethod( diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs index 5f8bc0874cc2..a9729f518899 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs @@ -188,6 +188,9 @@ public IDictionary? Labels /// the function, and sending back the result. The intermediate messages will be retained in the /// if an instance was provided. /// + /// + /// This property is deprecated. Use instead. + /// public GeminiToolCallBehavior? ToolCallBehavior { get => this._toolCallBehavior; @@ -357,11 +360,71 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio { case null: return new GeminiPromptExecutionSettings(); - case GeminiPromptExecutionSettings settings: - return settings; + case GeminiPromptExecutionSettings geminiSettings: + // If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it + if (geminiSettings.FunctionChoiceBehavior is not null && geminiSettings.ToolCallBehavior is null) + { + geminiSettings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(geminiSettings.FunctionChoiceBehavior); + } + return geminiSettings; } var json = JsonSerializer.Serialize(executionSettings); - return JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive)!; + var settings = JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive)!; + + // If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it + if (executionSettings.FunctionChoiceBehavior is not null && settings.ToolCallBehavior is null) + { + settings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(executionSettings.FunctionChoiceBehavior); + } + + return settings; + } + + /// + /// Shared empty kernel instance used for FunctionChoiceBehavior conversion. + /// + private static readonly Kernel s_emptyKernel = new(); + + /// + /// Converts a to a . + /// + /// The to convert. + /// The converted . + internal static GeminiToolCallBehavior? ConvertFunctionChoiceBehaviorToToolCallBehavior(FunctionChoiceBehavior? functionChoiceBehavior) + { + if (functionChoiceBehavior is null) + { + return null; + } + + // Check the type and determine auto-invoke by reflection or known behavior types + // All FunctionChoiceBehavior types (Auto, Required, None) support auto-invoke + // We use a simple approach: get the configuration with minimal context to check AutoInvoke + try + { + var context = new FunctionChoiceBehaviorConfigurationContext(new ChatHistory()) + { + Kernel = s_emptyKernel, // Provide an empty kernel for the configuration + RequestSequenceIndex = 0 + }; + var config = functionChoiceBehavior.GetConfiguration(context); + + // Return appropriate GeminiToolCallBehavior based on AutoInvoke setting + if (config.AutoInvoke) + { + return GeminiToolCallBehavior.AutoInvokeKernelFunctions; + } + + return GeminiToolCallBehavior.EnableKernelFunctions; + } +#pragma warning disable CA1031 // Do not catch general exception types + catch +#pragma warning restore CA1031 + { + // If we can't get configuration (e.g., due to missing dependencies or unexpected state), + // default to EnableKernelFunctions as the safer option that doesn't auto-invoke + return GeminiToolCallBehavior.EnableKernelFunctions; + } } } diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs index ac5f6ced3e8b..6154decf12d8 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -38,6 +39,16 @@ public GeminiChatMessageContent(GeminiFunctionToolResult calledToolResult) Verify.NotNull(calledToolResult); this.CalledToolResult = calledToolResult; + + // Parse plugin and function names from FullyQualifiedName + var (pluginName, functionName) = ParseFullyQualifiedName(calledToolResult.FullyQualifiedName); + + // Also populate Items collection with FunctionResultContent for compatibility with FunctionChoiceBehavior + this.Items.Add(new FunctionResultContent( + functionName: functionName, + pluginName: pluginName, + callId: null, // Gemini doesn't provide call IDs + result: calledToolResult.FunctionResult)); } /// @@ -63,6 +74,35 @@ internal GeminiChatMessageContent( metadata: metadata) { this.CalledToolResult = calledToolResult; + + // Also populate Items collection with FunctionResultContent for compatibility with FunctionChoiceBehavior + if (calledToolResult is not null) + { + // Parse plugin and function names from FullyQualifiedName + var (pluginName, functionName) = ParseFullyQualifiedName(calledToolResult.FullyQualifiedName); + + this.Items.Add(new FunctionResultContent( + functionName: functionName, + pluginName: pluginName, + callId: null, // Gemini doesn't provide call IDs + result: calledToolResult.FunctionResult)); + } + } + + /// + /// Parses a fully qualified function name into plugin name and function name. + /// + private static (string? PluginName, string FunctionName) ParseFullyQualifiedName(string fullyQualifiedName) + { + int separatorPos = fullyQualifiedName.IndexOf(GeminiFunction.NameSeparator, StringComparison.Ordinal); + if (separatorPos >= 0) + { + string pluginName = fullyQualifiedName.Substring(0, separatorPos).Trim(); + string functionName = fullyQualifiedName.Substring(separatorPos + GeminiFunction.NameSeparator.Length).Trim(); + return (pluginName, functionName); + } + + return (null, fullyQualifiedName); } /// @@ -88,6 +128,29 @@ internal GeminiChatMessageContent( metadata: metadata) { this.ToolCalls = functionsToolCalls?.Select(tool => new GeminiFunctionToolCall(tool)).ToList(); + + // Also populate Items collection with FunctionCallContent for compatibility with FunctionChoiceBehavior + if (this.ToolCalls is not null) + { + foreach (var toolCall in this.ToolCalls) + { + KernelArguments? arguments = null; + if (toolCall.Arguments is not null) + { + arguments = new KernelArguments(); + foreach (var arg in toolCall.Arguments) + { + arguments[arg.Key] = arg.Value; + } + } + + this.Items.Add(new FunctionCallContent( + functionName: toolCall.FunctionName, + pluginName: toolCall.PluginName, + id: null, // Gemini doesn't provide call IDs + arguments: arguments)); + } + } } ///