From 689d8f3dfd6abf12258ac411aee8ac315fedeb79 Mon Sep 17 00:00:00 2001 From: James Abbott Date: Fri, 17 Oct 2025 15:44:52 +0100 Subject: [PATCH 1/5] fix: Mulitple gemini tool responses need to be added as a single chat item with multiple parts. --- .../Core/Gemini/GeminiRequestTests.cs | 2 +- .../Clients/GeminiChatCompletionClient.cs | 110 +++++++++++++++++- .../Core/Gemini/Models/GeminiRequest.cs | 18 +-- .../Models/Gemini/GeminiChatMessageContent.cs | 57 ++++++++- 4 files changed, 172 insertions(+), 15 deletions(-) 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 877b80debf67..31ce6e9946fd 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs @@ -491,7 +491,7 @@ public void AddChatMessageToRequest() // Arrange ChatHistory chat = []; var request = GeminiRequest.FromChatHistoryAndExecutionSettings(chat, new GeminiPromptExecutionSettings()); - var message = new GeminiChatMessageContent(AuthorRole.User, "user-message", "model-id"); + var message = new GeminiChatMessageContent(AuthorRole.User, "user-message", "model-id", calledToolResults: null); // Act request.AddChatMessage(message); 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 3d52a92f8825..9bd1aeaeb60d 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -395,11 +395,18 @@ private async Task ProcessFunctionsAsync(ChatCompletionState state, Cancellation // We must send back a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. + // Collect all tool responses before adding to chat history + var toolResponses = new List(); + foreach (var toolCall in state.LastMessage!.ToolCalls!) { - await this.ProcessSingleToolCallAsync(state, toolCall, cancellationToken).ConfigureAwait(false); + var toolResponse = await this.ProcessSingleToolCallAndReturnResponseAsync(state, toolCall, cancellationToken).ConfigureAwait(false); + toolResponses.Add(toolResponse); } + // Add all tool responses as a single batched message + this.AddBatchedToolResponseMessage(state.ChatHistory, state.GeminiRequest, toolResponses); + // Clear the tools. If we end up wanting to use tools, we'll reset it to the desired value. state.GeminiRequest.Tools = null; @@ -431,6 +438,46 @@ private async Task ProcessFunctionsAsync(ChatCompletionState state, Cancellation } } + private void AddBatchedToolResponseMessage( + ChatHistory chat, + GeminiRequest request, + List toolResponses) + { + if (toolResponses.Count == 0) + { + return; + } + + // Extract all tool results and combine content + var allToolResults = toolResponses + .Where(tr => tr.CalledToolResults != null) + .SelectMany(tr => tr.CalledToolResults!) + .ToList(); + + // Combine tool response content as a JSON array for better structure + var combinedContentList = toolResponses + .Select(tr => tr.Content) + .Where(c => !string.IsNullOrEmpty(c)) + .ToList(); + + var combinedContent = combinedContentList.Count switch + { + 0 => string.Empty, + 1 => combinedContentList[0], + _ => JsonSerializer.Serialize(combinedContentList) + }; + + // Create a single message with all function response parts using the new constructor + var batchedMessage = new GeminiChatMessageContent( + AuthorRole.Tool, + combinedContent, + this._modelId, + calledToolResults: allToolResults); + + chat.Add(batchedMessage); + request.AddChatMessage(batchedMessage); + } + private async Task ProcessSingleToolCallAsync(ChatCompletionState state, GeminiFunctionToolCall toolCall, CancellationToken cancellationToken) { // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, @@ -480,6 +527,65 @@ private async Task ProcessSingleToolCallAsync(ChatCompletionState state, GeminiF functionResponse: functionResult, errorMessage: null); } + private async Task ProcessSingleToolCallAndReturnResponseAsync(ChatCompletionState state, GeminiFunctionToolCall toolCall, CancellationToken cancellationToken) + { + // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, + // then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able + // to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check. + if (state.ExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && + !IsRequestableTool(state.GeminiRequest.Tools![0].Functions, toolCall)) + { + return CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Function call request for a function that wasn't defined."); + } + + // Ensure the provided function exists for calling + if (!state.Kernel!.Plugins.TryGetFunctionAndArguments(toolCall, out KernelFunction? function, out KernelArguments? functionArgs)) + { + return CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Requested function could not be found."); + } + + // Now, invoke the function, and create the resulting tool call message. + s_inflightAutoInvokes.Value++; + FunctionResult? functionResult; + try + { + // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any + // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, + // as the called function could in turn telling the model about itself as a possible candidate for invocation. + functionResult = await function.InvokeAsync(state.Kernel, functionArgs, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception e) +#pragma warning restore CA1031 + { + return CreateToolResponseMessage(toolCall, functionResponse: null, $"Error: Exception while invoking function. {e.Message}"); + } + finally + { + s_inflightAutoInvokes.Value--; + } + + return CreateToolResponseMessage(toolCall, functionResponse: functionResult, errorMessage: null); + } + + private GeminiChatMessageContent CreateToolResponseMessage( + GeminiFunctionToolCall tool, + FunctionResult? functionResponse, + string? errorMessage) + { + if (errorMessage is not null && this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Failed to handle tool request ({ToolName}). {Error}", tool.FullyQualifiedName, errorMessage); + } + + return new GeminiChatMessageContent(AuthorRole.Tool, + content: errorMessage ?? string.Empty, + modelId: this._modelId, + calledToolResult: functionResponse is not null ? new GeminiFunctionToolResult(tool, functionResponse) : null, + metadata: null); + } + private async Task SendRequestAndReturnValidGeminiResponseAsync( Uri endpoint, GeminiRequest geminiRequest, @@ -604,7 +710,7 @@ private void LogUsage(List chatMessageContents) private List GetChatMessageContentsFromResponse(GeminiResponse geminiResponse) => geminiResponse.Candidates == null ? - [new GeminiChatMessageContent(role: AuthorRole.Assistant, content: string.Empty, modelId: this._modelId)] + [new GeminiChatMessageContent(role: AuthorRole.Assistant, content: string.Empty, modelId: this._modelId, functionsToolCalls: null)] : geminiResponse.Candidates.Select(candidate => this.GetChatMessageContentFromCandidate(geminiResponse, candidate)).ToList(); private GeminiChatMessageContent GetChatMessageContentFromCandidate(GeminiResponse geminiResponse, GeminiResponseCandidate candidate) 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 5d4b917ee1e7..b70ad9a0fbab 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs @@ -183,15 +183,17 @@ private static List CreateGeminiParts(ChatMessageContent content) List parts = []; switch (content) { - case GeminiChatMessageContent { CalledToolResult: not null } contentWithCalledTool: - parts.Add(new GeminiPart - { - FunctionResponse = new GeminiPart.FunctionResponsePart + case GeminiChatMessageContent { CalledToolResults: not null } contentWithCalledTools: + // Add all function responses as separate parts in a single message + parts.AddRange(contentWithCalledTools.CalledToolResults.Select(toolResult => + new GeminiPart { - FunctionName = contentWithCalledTool.CalledToolResult.FullyQualifiedName, - Response = new(contentWithCalledTool.CalledToolResult.FunctionResult.GetValue()) - } - }); + FunctionResponse = new GeminiPart.FunctionResponsePart + { + FunctionName = toolResult.FullyQualifiedName, + Response = new(toolResult.FunctionResult.GetValue()) + } + })); break; case GeminiChatMessageContent { ToolCalls: not null } contentWithToolCalls: parts.AddRange(contentWithToolCalls.ToolCalls.Select(toolCall => diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs index ac5f6ced3e8b..ad1b2a41123e 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs @@ -37,7 +37,25 @@ public GeminiChatMessageContent(GeminiFunctionToolResult calledToolResult) { Verify.NotNull(calledToolResult); - this.CalledToolResult = calledToolResult; + this.CalledToolResults = [calledToolResult]; + } + + /// + /// Initializes a new instance of the class with multiple tool results. + /// + /// The results of tools called by the kernel. + public GeminiChatMessageContent(IEnumerable calledToolResults) + : base( + role: AuthorRole.Tool, + content: null, + modelId: null, + innerContent: null, + encoding: Encoding.UTF8, + metadata: null) + { + Verify.NotNull(calledToolResults); + + this.CalledToolResults = calledToolResults.ToList().AsReadOnly(); } /// @@ -62,7 +80,32 @@ internal GeminiChatMessageContent( encoding: Encoding.UTF8, metadata: metadata) { - this.CalledToolResult = calledToolResult; + this.CalledToolResults = calledToolResult != null ? [calledToolResult] : null; + } + + /// + /// Initializes a new instance of the class with multiple tool results. + /// + /// Role of the author of the message + /// Content of the message + /// The model ID used to generate the content + /// The results of tools called by the kernel. + /// Additional metadata + internal GeminiChatMessageContent( + AuthorRole role, + string? content, + string modelId, + IEnumerable? calledToolResults = null, + GeminiMetadata? metadata = null) + : base( + role: role, + content: content, + modelId: modelId, + innerContent: content, + encoding: Encoding.UTF8, + metadata: metadata) + { + this.CalledToolResults = calledToolResults?.ToList().AsReadOnly(); } /// @@ -96,9 +139,15 @@ internal GeminiChatMessageContent( public IReadOnlyList? ToolCalls { get; } /// - /// The result of tool called by the kernel. + /// The results of tools called by the kernel. + /// + public IReadOnlyList? CalledToolResults { get; } + + /// + /// The result of tool called by the kernel (for backward compatibility). + /// Returns the first tool result if multiple exist, or null if none. /// - public GeminiFunctionToolResult? CalledToolResult { get; } + public GeminiFunctionToolResult? CalledToolResult => CalledToolResults?.FirstOrDefault(); /// /// The metadata associated with the content. From cd0fb3d628444d2cae96095ac7b0203268920d81 Mon Sep 17 00:00:00 2001 From: James Abbott Date: Fri, 17 Oct 2025 15:54:43 +0100 Subject: [PATCH 2/5] fix: Resolve build warnings --- .../Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs index ad1b2a41123e..723d7f99dcaf 100644 --- a/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs +++ b/dotnet/src/Connectors/Connectors.Google/Models/Gemini/GeminiChatMessageContent.cs @@ -147,7 +147,7 @@ internal GeminiChatMessageContent( /// The result of tool called by the kernel (for backward compatibility). /// Returns the first tool result if multiple exist, or null if none. /// - public GeminiFunctionToolResult? CalledToolResult => CalledToolResults?.FirstOrDefault(); + public GeminiFunctionToolResult? CalledToolResult => this.CalledToolResults?.Count > 0 ? this.CalledToolResults[0] : null; /// /// The metadata associated with the content. From db5f137c68fd835a037bbcf0d5271210b22a6582 Mon Sep 17 00:00:00 2001 From: James Abbott Date: Fri, 17 Oct 2025 16:06:48 +0100 Subject: [PATCH 3/5] test: Add unit test for batching multiple Gemini tool responses into a single message --- ...eminiChatGenerationFunctionCallingTests.cs | 64 ++++++++++++++++ ...chat_multiple_function_calls_response.json | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_multiple_function_calls_response.json 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 fdf70b8182bf..d1a8904109de 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 @@ -376,6 +376,70 @@ public async Task IfAutoInvokeMaximumAutoInvokeAttemptsReachedShouldStopInvoking c is GeminiChatMessageContent gm && gm.Role == AuthorRole.Tool && gm.CalledToolResult is not null); } + [Fact] + public async Task ShouldBatchMultipleToolResponsesIntoSingleMessageAsync() + { + // Arrange + var responseContentWithMultipleFunctions = File.ReadAllText("./TestData/chat_multiple_function_calls_response.json") + .Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal); + + using var handlerStub = new MultipleHttpMessageHandlerStub(); + handlerStub.AddJsonResponse(responseContentWithMultipleFunctions); + handlerStub.AddJsonResponse(this._responseContent); // Final response after tool execution + +#pragma warning disable CA2000 + var client = this.CreateChatCompletionClient(httpClient: handlerStub.CreateHttpClient()); +#pragma warning restore CA2000 + var chatHistory = CreateSampleChatHistory(); + var executionSettings = new GeminiPromptExecutionSettings + { + ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions + }; + + // Act + await client.GenerateChatMessageAsync(chatHistory, executionSettings: executionSettings, kernel: this._kernelWithFunctions); + + // Assert + // Find the tool response message that should be batched + var toolResponseMessage = chatHistory.OfType() + .FirstOrDefault(m => m.Role == AuthorRole.Tool && m.CalledToolResults != null); + + Assert.NotNull(toolResponseMessage); + Assert.NotNull(toolResponseMessage.CalledToolResults); + + // Verify that multiple tool results are batched into a single message + Assert.Equal(2, toolResponseMessage.CalledToolResults.Count); + + // Verify the specific tool calls that were batched + var toolNames = toolResponseMessage.CalledToolResults.Select(tr => tr.FullyQualifiedName).ToArray(); + Assert.Contains(this._timePluginNow.FullyQualifiedName, toolNames); + Assert.Contains(this._timePluginDate.FullyQualifiedName, toolNames); + + // Verify backward compatibility - CalledToolResult property should return the first result + Assert.NotNull(toolResponseMessage.CalledToolResult); + Assert.Equal(toolResponseMessage.CalledToolResults[0], toolResponseMessage.CalledToolResult); + + // Verify the request that would be sent to Gemini contains the correct structure + var requestJson = handlerStub.GetRequestContentAsString(1); // Get the second request (after tool execution) + Assert.NotNull(requestJson); + var request = JsonSerializer.Deserialize(requestJson); + Assert.NotNull(request); + + // Find the content that represents the batched tool responses + var toolResponseContent = request.Contents.FirstOrDefault(c => c.Role == AuthorRole.Tool); + Assert.NotNull(toolResponseContent); + Assert.NotNull(toolResponseContent.Parts); + + // Verify that all function responses are included as separate parts in the single message + var functionResponseParts = toolResponseContent.Parts.Where(p => p.FunctionResponse != null).ToArray(); + Assert.Equal(2, functionResponseParts.Length); + + // Verify each function response part corresponds to the tool calls + var functionNames = functionResponseParts.Select(p => p.FunctionResponse!.FunctionName).ToArray(); + Assert.Contains(this._timePluginNow.FullyQualifiedName, functionNames); + Assert.Contains(this._timePluginDate.FullyQualifiedName, functionNames); + } + private static ChatHistory CreateSampleChatHistory() { var chatHistory = new ChatHistory(); diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_multiple_function_calls_response.json b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_multiple_function_calls_response.json new file mode 100644 index 000000000000..1b512bf63380 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/TestData/chat_multiple_function_calls_response.json @@ -0,0 +1,75 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I'll help you get the current time and date. Let me call both functions for you." + }, + { + "functionCall": { + "name": "TimePlugin%nameSeparator%Now", + "args": { + "param1": "current time" + } + } + }, + { + "functionCall": { + "name": "TimePlugin%nameSeparator%Date", + "args": { + "format": "yyyy-MM-dd" + } + } + } + ], + "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": 50, + "candidatesTokenCount": 25, + "totalTokenCount": 75 + } +} \ No newline at end of file From 58e8139320b2e7f842dede50f11d4f3d56663bf2 Mon Sep 17 00:00:00 2001 From: James Abbott Date: Fri, 17 Oct 2025 16:35:35 +0100 Subject: [PATCH 4/5] chore: resolve formatting --- .../Core/Gemini/Clients/GeminiChatCompletionClient.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 9bd1aeaeb60d..fbd5b86ee863 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -535,13 +535,13 @@ private async Task ProcessSingleToolCallAndReturnRespo if (state.ExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && !IsRequestableTool(state.GeminiRequest.Tools![0].Functions, toolCall)) { - return CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Function call request for a function that wasn't defined."); + return this.CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Function call request for a function that wasn't defined."); } // Ensure the provided function exists for calling if (!state.Kernel!.Plugins.TryGetFunctionAndArguments(toolCall, out KernelFunction? function, out KernelArguments? functionArgs)) { - return CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Requested function could not be found."); + return this.CreateToolResponseMessage(toolCall, functionResponse: null, "Error: Requested function could not be found."); } // Now, invoke the function, and create the resulting tool call message. @@ -559,14 +559,14 @@ private async Task ProcessSingleToolCallAndReturnRespo catch (Exception e) #pragma warning restore CA1031 { - return CreateToolResponseMessage(toolCall, functionResponse: null, $"Error: Exception while invoking function. {e.Message}"); + return this.CreateToolResponseMessage(toolCall, functionResponse: null, $"Error: Exception while invoking function. {e.Message}"); } finally { s_inflightAutoInvokes.Value--; } - return CreateToolResponseMessage(toolCall, functionResponse: functionResult, errorMessage: null); + return this.CreateToolResponseMessage(toolCall, functionResponse: functionResult, errorMessage: null); } private GeminiChatMessageContent CreateToolResponseMessage( From 2e5d7095dbe7655d59f63e86a538129f7cfe45c6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:49:55 +0100 Subject: [PATCH 5/5] Add missing IT --- .../Gemini/GeminiFunctionCallingTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs index 883b8eb724b1..6e7b06429131 100644 --- a/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Google/Gemini/GeminiFunctionCallingTests.cs @@ -316,6 +316,43 @@ public async Task ChatStreamingAutoInvokeTwoPluginsShouldGetDateAndReturnTasksBy Assert.Contains("5", content, StringComparison.OrdinalIgnoreCase); } + [RetryTheory(Skip = SkipMessage)] + [InlineData(ServiceType.GoogleAI, true)] + [InlineData(ServiceType.VertexAI, false)] + public async Task ChatStreamingAutoInvokeTwoPluginsShouldGetDateAndReturnWeatherResponseAsync(ServiceType serviceType, bool isBeta) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromType(nameof(DateTimePlugin)); + kernel.ImportPluginFromType(nameof(WeatherPlugin)); + kernel.ImportPluginFromType(nameof(TaskPlugin)); + var sut = this.GetChatService(serviceType, isBeta); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Whats the time and weather in Seattle?"); + var executionSettings = new GeminiPromptExecutionSettings() + { + MaxTokens = 2000, + ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions, + }; + + // Act + var responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel) + .ToListAsync(); + + string content = string.Concat(responses.Select(c => c.Content)); + this.Output.WriteLine(content); + Assert.Contains("sunny", content, StringComparison.OrdinalIgnoreCase); + + chatHistory.AddUserMessage("How many tasks I have for today, using the same date I checked weather in seattle"); + responses = await sut.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel) + .ToListAsync(); + + // Assert + content = string.Concat(responses.Select(c => c.Content)); + this.Output.WriteLine(content); + Assert.Contains("5", content, StringComparison.OrdinalIgnoreCase); + } + [RetryTheory(Skip = SkipMessage)] [InlineData(ServiceType.GoogleAI, true)] [InlineData(ServiceType.VertexAI, false)] @@ -414,6 +451,16 @@ public int GetTaskCount([Description("Date to get tasks")] DateTime date) } } + public sealed class WeatherPlugin + { + [KernelFunction(nameof(GetWeather))] + [Description("Get the weather for a given location.")] + public string GetWeather([Description("Location to get the weather for")] string location) + { + return $"The weather in {location} is sunny."; + } + } + public sealed class DatePlugin { [KernelFunction(nameof(GetDate))] @@ -457,6 +504,16 @@ public string DateMatchingLastDayName( } } + public sealed class DateTimePlugin + { + [KernelFunction(nameof(GetCurrentDateTime))] + [Description("Get current UTC date and time.")] + public string GetCurrentDateTime() + { + return DateTime.UtcNow.ToString("u"); + } + } + public sealed class MathPlugin { [KernelFunction(nameof(Sum))]