From 908333be8c12c5128a65e25404457f95dcda9908 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:29:51 +0000 Subject: [PATCH 01/22] .Net: SK integration with MEAI Abstractions (Service Selector + Contents) Phase 1 (#10651) ### Motivation and Context - Resolves partially #10319 This change adds a new `IChatClientSelector` abstraction to work without breaking current `IAIServiceSelector` implementations. This was necessary because `Kernel` now will be able to resolve `IChatClient`s which are not `IAIService` implementations, and `OrderedAIServiceSelector` now can select not only `AIServices` but `ChatClients` as well by implementing the `IChatClientSelector` interface. All other changes are related to test accepting `IChatClient`s added to the `Service` DI container, ensuring they work as expected together with current `IChatCOmpletionService` and `ITextGenerationService` in the `Kernel`. --- dotnet/samples/Concepts/Concepts.csproj | 2 +- .../Kernel/CustomAIServiceSelector.cs | 73 +- .../samples/Demos/HomeAutomation/Program.cs | 1 - dotnet/src/Agents/Core/ChatCompletionAgent.cs | 43 +- .../KernelCore/KernelTests.cs | 4 +- .../KernelCore/KernelTests.cs | 4 +- .../ProcessCloudEventsTests.cs | 3 - .../ProcessTests.cs | 2 - .../OpenAI/OpenAIChatCompletionTests.cs | 73 ++ .../IntegrationTests/IntegrationTests.csproj | 1 + .../AI/ChatClient/ChatClientAIService.cs | 60 ++ .../AI/ChatClient/ChatClientExtensions.cs | 69 ++ .../AI/ChatClient/ChatMessageExtensions.cs | 71 ++ .../ChatResponseUpdateExtensions.cs | 45 ++ .../ChatClientChatCompletionService.cs | 243 +----- .../ChatCompletionServiceChatClient.cs | 52 +- .../ChatCompletionServiceExtensions.cs | 144 ---- .../ChatCompletion/ChatHistoryExtensions.cs | 4 + .../AI/PromptExecutionSettingsExtensions.cs | 204 +++++ .../CompatibilitySuppressions.xml | 18 + .../Contents/ChatMessageContentExtensions.cs | 71 ++ .../StreamingChatMessageContentExtensions.cs | 53 ++ .../Functions/FunctionResult.cs | 100 +++ .../Services/AIServiceExtensions.cs | 2 +- .../Services/IAIServiceSelector.cs | 2 +- .../Services/IChatClientSelector.cs | 38 + .../Services/OrderedAIServiceSelector.cs | 29 +- .../Functions/KernelFunctionFromPrompt.cs | 237 +++++- .../AI/ServiceConversionExtensionsTests.cs | 2 +- .../CustomAIChatClientSelectorTests.cs | 95 +++ .../Functions/CustomAIServiceSelectorTests.cs | 11 +- .../Functions/FunctionResultTests.cs | 170 ++++ .../KernelFunctionFromPromptTests.cs | 733 +++++++++++++++++- .../Functions/MultipleModelTests.cs | 2 +- .../OrderedAIServiceSelectorTests.cs | 290 ++++++- .../SemanticKernel.UnitTests/KernelTests.cs | 121 +++ 36 files changed, 2565 insertions(+), 507 deletions(-) create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml create mode 100644 dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/Services/IChatClientSelector.cs create mode 100644 dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs diff --git a/dotnet/samples/Concepts/Concepts.csproj b/dotnet/samples/Concepts/Concepts.csproj index b5cfce829772..fc2cb101c126 100644 --- a/dotnet/samples/Concepts/Concepts.csproj +++ b/dotnet/samples/Concepts/Concepts.csproj @@ -8,7 +8,7 @@ false true - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,CA1724 + $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,CA1724,IDE1006 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs b/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs index b0fdcad2e86f..d4631323c24d 100644 --- a/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs +++ b/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; @@ -8,29 +9,38 @@ namespace KernelExamples; +/// +/// This sample shows how to use a custom AI service selector to select a specific model by matching it's id. +/// public class CustomAIServiceSelector(ITestOutputHelper output) : BaseTest(output) { - /// - /// Show how to use a custom AI service selector to select a specific model - /// [Fact] - public async Task RunAsync() + public async Task UsingCustomSelectToSelectServiceByMatchingModelId() { - Console.WriteLine($"======== {nameof(CustomAIServiceSelector)} ========"); + Console.WriteLine($"======== {nameof(UsingCustomSelectToSelectServiceByMatchingModelId)} ========"); - // Build a kernel with multiple chat completion services + // Use the custom AI service selector to select any registered service starting with "gpt" on it's model id + var customSelector = new GptAIServiceSelector(modelNameStartsWith: "gpt", this.Output); + + // Build a kernel with multiple chat services var builder = Kernel.CreateBuilder() .AddAzureOpenAIChatCompletion( deploymentName: TestConfiguration.AzureOpenAI.ChatDeploymentName, endpoint: TestConfiguration.AzureOpenAI.Endpoint, apiKey: TestConfiguration.AzureOpenAI.ApiKey, serviceId: "AzureOpenAIChat", - modelId: TestConfiguration.AzureOpenAI.ChatModelId) + modelId: "o1-mini") .AddOpenAIChatCompletion( - modelId: TestConfiguration.OpenAI.ChatModelId, + modelId: "o1-mini", apiKey: TestConfiguration.OpenAI.ApiKey, serviceId: "OpenAIChat"); - builder.Services.AddSingleton(new GptAIServiceSelector(this.Output)); // Use the custom AI service selector to select the GPT model + + // The kernel also allows you to use a IChatClient chat service as well + builder.Services + .AddSingleton(customSelector) + .AddKeyedChatClient("OpenAIChatClient", new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey) + .AsChatClient("gpt-4o")); // Add a IChatClient to the kernel + Kernel kernel = builder.Build(); // This invocation is done with the model selected by the custom selector @@ -45,20 +55,35 @@ public async Task RunAsync() /// a completion model whose name starts with "gpt". But this logic could /// be as elaborate as needed to apply your own selection criteria. /// - private sealed class GptAIServiceSelector(ITestOutputHelper output) : IAIServiceSelector + private sealed class GptAIServiceSelector(string modelNameStartsWith, ITestOutputHelper output) : IAIServiceSelector, IChatClientSelector { private readonly ITestOutputHelper _output = output; + private readonly string _modelNameStartsWith = modelNameStartsWith; - public bool TrySelectAIService( + /// + private bool TrySelect( Kernel kernel, KernelFunction function, KernelArguments arguments, - [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) where T : class, IAIService + [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) where T : class { foreach (var serviceToCheck in kernel.GetAllServices()) { + string? serviceModelId = null; + string? endpoint = null; + + if (serviceToCheck is IAIService aiService) + { + serviceModelId = aiService.GetModelId(); + endpoint = aiService.GetEndpoint(); + } + else if (serviceToCheck is IChatClient chatClient) + { + var metadata = chatClient.GetService(); + serviceModelId = metadata?.ModelId; + endpoint = metadata?.ProviderUri?.ToString(); + } + // Find the first service that has a model id that starts with "gpt" - var serviceModelId = serviceToCheck.GetModelId(); - var endpoint = serviceToCheck.GetEndpoint(); - if (!string.IsNullOrEmpty(serviceModelId) && serviceModelId.StartsWith("gpt", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(serviceModelId) && serviceModelId.StartsWith(this._modelNameStartsWith, StringComparison.OrdinalIgnoreCase)) { this._output.WriteLine($"Selected model: {serviceModelId} {endpoint}"); service = serviceToCheck; @@ -71,5 +96,23 @@ public bool TrySelectAIService( serviceSettings = null; return false; } + + /// + public bool TrySelectAIService( + Kernel kernel, + KernelFunction function, + KernelArguments arguments, + [NotNullWhen(true)] out T? service, + out PromptExecutionSettings? serviceSettings) where T : class, IAIService + => this.TrySelect(kernel, function, arguments, out service, out serviceSettings); + + /// + public bool TrySelectChatClient( + Kernel kernel, + KernelFunction function, + KernelArguments arguments, + [NotNullWhen(true)] out T? service, + out PromptExecutionSettings? serviceSettings) where T : class, IChatClient + => this.TrySelect(kernel, function, arguments, out service, out serviceSettings); } } diff --git a/dotnet/samples/Demos/HomeAutomation/Program.cs b/dotnet/samples/Demos/HomeAutomation/Program.cs index 3b8d1f009c2f..5b6f61cb5c2d 100644 --- a/dotnet/samples/Demos/HomeAutomation/Program.cs +++ b/dotnet/samples/Demos/HomeAutomation/Program.cs @@ -21,7 +21,6 @@ Example that demonstrates how to use Semantic Kernel in conjunction with depende using Microsoft.SemanticKernel.ChatCompletion; // For Azure OpenAI configuration #pragma warning disable IDE0005 // Using directive is unnecessary. -using Microsoft.SemanticKernel.Connectors.AzureOpenAI; using Microsoft.SemanticKernel.Connectors.OpenAI; namespace HomeAutomation; diff --git a/dotnet/src/Agents/Core/ChatCompletionAgent.cs b/dotnet/src/Agents/Core/ChatCompletionAgent.cs index 015b0a22b0f1..59eb482ff976 100644 --- a/dotnet/src/Agents/Core/ChatCompletionAgent.cs +++ b/dotnet/src/Agents/Core/ChatCompletionAgent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -9,7 +10,6 @@ using Microsoft.SemanticKernel.Agents.Extensions; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Diagnostics; -using Microsoft.SemanticKernel.Services; namespace Microsoft.SemanticKernel.Agents; @@ -101,13 +101,42 @@ internal static (IChatCompletionService service, PromptExecutionSettings? execut { // Need to provide a KernelFunction to the service selector as a container for the execution-settings. KernelFunction nullPrompt = KernelFunctionFactory.CreateFromPrompt("placeholder", arguments?.ExecutionSettings?.Values); - (IChatCompletionService chatCompletionService, PromptExecutionSettings? executionSettings) = - kernel.ServiceSelector.SelectAIService( - kernel, - nullPrompt, - arguments ?? []); - return (chatCompletionService, executionSettings); + kernel.ServiceSelector.TrySelectAIService(kernel, nullPrompt, arguments ?? [], out IChatCompletionService? chatCompletionService, out PromptExecutionSettings? executionSettings); + +#pragma warning disable CA2000 // Dispose objects before losing scope + if (chatCompletionService is null + && kernel.ServiceSelector is IChatClientSelector chatClientSelector + && chatClientSelector.TrySelectChatClient(kernel, nullPrompt, arguments ?? [], out var chatClient, out executionSettings) + && chatClient is not null) + { + // This change is temporary until Agents support IChatClient natively in near future. + chatCompletionService = chatClient!.AsChatCompletionService(); + } +#pragma warning restore CA2000 // Dispose objects before losing scope + + if (chatCompletionService is null) + { + var message = new StringBuilder().Append("No service was found for any of the supported types: ").Append(typeof(IChatCompletionService)).Append(", ").Append(typeof(Microsoft.Extensions.AI.IChatClient)).Append('.'); + if (nullPrompt.ExecutionSettings is not null) + { + string serviceIds = string.Join("|", nullPrompt.ExecutionSettings.Keys); + if (!string.IsNullOrEmpty(serviceIds)) + { + message.Append(" Expected serviceIds: ").Append(serviceIds).Append('.'); + } + + string modelIds = string.Join("|", nullPrompt.ExecutionSettings.Values.Select(model => model.ModelId)); + if (!string.IsNullOrEmpty(modelIds)) + { + message.Append(" Expected modelIds: ").Append(modelIds).Append('.'); + } + } + + throw new KernelException(message.ToString()); + } + + return (chatCompletionService!, executionSettings); } #region private diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs index 61685bb1daec..cd1c2a549003 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/KernelCore/KernelTests.cs @@ -62,13 +62,13 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() // Set up a MeterListener to capture the measurements using MeterListener listener = EnableTelemetryMeters(); - var measurements = new Dictionary> + var measurements = new Dictionary> { ["semantic_kernel.function.invocation.token_usage.prompt"] = [], ["semantic_kernel.function.invocation.token_usage.completion"] = [], }; - listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { if (instrument.Name == "semantic_kernel.function.invocation.token_usage.prompt" || instrument.Name == "semantic_kernel.function.invocation.token_usage.completion") diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs index fdf17710b77c..fd97491adf25 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/KernelCore/KernelTests.cs @@ -63,13 +63,13 @@ public async Task FunctionUsageMetricsAreCapturedByTelemetryAsExpected() // Set up a MeterListener to capture the measurements using MeterListener listener = EnableTelemetryMeters(); - var measurements = new Dictionary> + var measurements = new Dictionary> { ["semantic_kernel.function.invocation.token_usage.prompt"] = [], ["semantic_kernel.function.invocation.token_usage.completion"] = [], }; - listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { if (instrument.Name == "semantic_kernel.function.invocation.token_usage.prompt" || instrument.Name == "semantic_kernel.function.invocation.token_usage.completion") diff --git a/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessCloudEventsTests.cs b/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessCloudEventsTests.cs index ee262b50f7e9..0433b88f367b 100644 --- a/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessCloudEventsTests.cs +++ b/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessCloudEventsTests.cs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. #pragma warning disable IDE0005 // Using directive is unnecessary. -using System; -using System.Linq; -using System.Runtime.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.SemanticKernel; diff --git a/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessTests.cs b/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessTests.cs index d5d2ca19934e..5964ed1a1773 100644 --- a/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessTests.cs +++ b/dotnet/src/Experimental/Process.IntegrationTests.Shared/ProcessTests.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. #pragma warning disable IDE0005 // Using directive is unnecessary. -using System; using System.Linq; -using System.Runtime.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.SemanticKernel; diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs index 1359b701e29c..1e1b58133c83 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs @@ -8,12 +8,14 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; +using OpenAI; using OpenAI.Chat; using SemanticKernel.IntegrationTests.TestSettings; using Xunit; @@ -43,6 +45,40 @@ public async Task ItCanUseOpenAiChatForTextGenerationAsync() Assert.Contains("Uranus", result.GetValue(), StringComparison.InvariantCultureIgnoreCase); } + [Fact] + public async Task ItCanUseOpenAiChatClientAndContentsAsync() + { + var OpenAIConfiguration = this._configuration.GetSection("OpenAI").Get(); + Assert.NotNull(OpenAIConfiguration); + Assert.NotNull(OpenAIConfiguration.ChatModelId); + Assert.NotNull(OpenAIConfiguration.ApiKey); + Assert.NotNull(OpenAIConfiguration.ServiceId); + + // Arrange + var openAIClient = new OpenAIClient(OpenAIConfiguration.ApiKey); + var builder = Kernel.CreateBuilder(); + builder.Services.AddChatClient(openAIClient.AsChatClient(OpenAIConfiguration.ChatModelId)); + var kernel = builder.Build(); + + var func = kernel.CreateFunctionFromPrompt( + "List the two planets after '{{$input}}', excluding moons, using bullet points.", + new OpenAIPromptExecutionSettings()); + + // Act + var result = await func.InvokeAsync(kernel, new() { [InputParameterName] = "Jupiter" }); + + // Assert + Assert.NotNull(result); + Assert.Contains("Saturn", result.GetValue(), StringComparison.InvariantCultureIgnoreCase); + Assert.Contains("Uranus", result.GetValue(), StringComparison.InvariantCultureIgnoreCase); + var chatResponse = Assert.IsType(result.GetValue()); + Assert.Contains("Saturn", chatResponse.Message.Text, StringComparison.InvariantCultureIgnoreCase); + var chatMessage = Assert.IsType(result.GetValue()); + Assert.Contains("Uranus", chatMessage.Text, StringComparison.InvariantCultureIgnoreCase); + var chatMessageContent = Assert.IsType(result.GetValue()); + Assert.Contains("Uranus", chatMessageContent.Content, StringComparison.InvariantCultureIgnoreCase); + } + [Fact] public async Task OpenAIStreamingTestAsync() { @@ -65,6 +101,43 @@ public async Task OpenAIStreamingTestAsync() Assert.Contains("Pike Place", fullResult.ToString(), StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ItCanUseOpenAiStreamingChatClientAndContentsAsync() + { + var OpenAIConfiguration = this._configuration.GetSection("OpenAI").Get(); + Assert.NotNull(OpenAIConfiguration); + Assert.NotNull(OpenAIConfiguration.ChatModelId); + Assert.NotNull(OpenAIConfiguration.ApiKey); + Assert.NotNull(OpenAIConfiguration.ServiceId); + + // Arrange + var openAIClient = new OpenAIClient(OpenAIConfiguration.ApiKey); + var builder = Kernel.CreateBuilder(); + builder.Services.AddChatClient(openAIClient.AsChatClient(OpenAIConfiguration.ChatModelId)); + var kernel = builder.Build(); + + var plugins = TestHelpers.ImportSamplePlugins(kernel, "ChatPlugin"); + + StringBuilder fullResultSK = new(); + StringBuilder fullResultMEAI = new(); + + var prompt = "Where is the most famous fish market in Seattle, Washington, USA?"; + + // Act + await foreach (var content in kernel.InvokeStreamingAsync(plugins["ChatPlugin"]["Chat"], new() { [InputParameterName] = prompt })) + { + fullResultSK.Append(content); + } + await foreach (var content in kernel.InvokeStreamingAsync(plugins["ChatPlugin"]["Chat"], new() { [InputParameterName] = prompt })) + { + fullResultMEAI.Append(content); + } + + // Assert + Assert.Contains("Pike Place", fullResultSK.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("Pike Place", fullResultMEAI.ToString(), StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task OpenAIHttpRetryPolicyTestAsync() { diff --git a/dotnet/src/IntegrationTests/IntegrationTests.csproj b/dotnet/src/IntegrationTests/IntegrationTests.csproj index cd4f12741f96..a88dc3386d97 100644 --- a/dotnet/src/IntegrationTests/IntegrationTests.csproj +++ b/dotnet/src/IntegrationTests/IntegrationTests.csproj @@ -41,6 +41,7 @@ + diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs new file mode 100644 index 000000000000..af8217d5e1fa --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.AI.ChatCompletion; + +/// +/// Allow to be used as an in a +/// +internal sealed class ChatClientAIService : IAIService, IChatClient +{ + private readonly IChatClient _chatClient; + + /// + /// Storage for AI service attributes. + /// + internal Dictionary _internalAttributes { get; } = []; + + /// + /// Initializes a new instance of the class. + /// + /// Target . + internal ChatClientAIService(IChatClient chatClient) + { + Verify.NotNull(chatClient); + this._chatClient = chatClient; + + var metadata = this._chatClient.GetService(); + Verify.NotNull(metadata); + + this._internalAttributes[nameof(metadata.ModelId)] = metadata.ModelId; + this._internalAttributes[nameof(metadata.ProviderName)] = metadata.ProviderName; + this._internalAttributes[nameof(metadata.ProviderUri)] = metadata.ProviderUri; + } + + /// + public IReadOnlyDictionary Attributes => this._internalAttributes; + + /// + public void Dispose() + { + } + + /// + public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._chatClient.GetResponseAsync(chatMessages, options, cancellationToken); + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + => this._chatClient.GetService(serviceType, serviceKey); + + /// + public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._chatClient.GetStreamingResponseAsync(chatMessages, options, cancellationToken); +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs new file mode 100644 index 000000000000..92bf6b9db105 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel.ChatCompletion; + +/// Provides extension methods for . +public static class ChatClientExtensions +{ + /// + /// Get chat response which may contain multiple choices for the prompt and settings. + /// + /// Target chat client service. + /// The standardized prompt input. + /// The AI execution settings (optional). + /// The containing services, plugins, and other state for use throughout the operation. + /// The to monitor for cancellation requests. The default is . + /// Get chat response with choices generated by the remote model + internal static Task GetResponseAsync( + this IChatClient chatClient, + string prompt, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var chatOptions = executionSettings.ToChatOptions(kernel); + + // Try to parse the text as a chat history + if (ChatPromptParser.TryParse(prompt, out var chatHistoryFromPrompt)) + { + var messageList = chatHistoryFromPrompt.ToChatMessageList(); + return chatClient.GetResponseAsync(messageList, chatOptions, cancellationToken); + } + + return chatClient.GetResponseAsync(prompt, chatOptions, cancellationToken); + } + + /// Creates an for the specified . + /// The chat client to be represented as a chat completion service. + /// An optional that can be used to resolve services to use in the instance. + /// + /// The . If is an , will + /// be returned. Otherwise, a new will be created that wraps . + /// + [Experimental("SKEXP0001")] + public static IChatCompletionService AsChatCompletionService(this IChatClient client, IServiceProvider? serviceProvider = null) + { + Verify.NotNull(client); + + return client is IChatCompletionService chatCompletionService ? + chatCompletionService : + new ChatClientChatCompletionService(client, serviceProvider); + } + + /// + /// Get the model identifier for the specified . + /// + [Experimental("SKEXP0001")] + public static string? GetModelId(this IChatClient client) + { + Verify.NotNull(client); + + return client.GetService()?.ModelId; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs new file mode 100644 index 000000000000..24117a28b2ee --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.Extensions.AI; + +internal static class ChatMessageExtensions +{ + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + internal static ChatMessageContent ToChatMessageContent(this ChatMessage message, ChatResponse? response = null) + { + ChatMessageContent result = new() + { + ModelId = response?.ModelId, + AuthorName = message.AuthorName, + InnerContent = response?.RawRepresentation ?? message.RawRepresentation, + Metadata = message.AdditionalProperties, + Role = new AuthorRole(message.Role.Value), + }; + + foreach (AIContent content in message.Contents) + { + KernelContent? resultContent = null; + switch (content) + { + case Microsoft.Extensions.AI.TextContent tc: + resultContent = new Microsoft.SemanticKernel.TextContent(tc.Text); + break; + + case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("image/"): + resultContent = dc.Data is not null ? + new Microsoft.SemanticKernel.ImageContent(dc.Uri) : + new Microsoft.SemanticKernel.ImageContent(new Uri(dc.Uri)); + break; + + case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("audio/"): + resultContent = dc.Data is not null ? + new Microsoft.SemanticKernel.AudioContent(dc.Uri) : + new Microsoft.SemanticKernel.AudioContent(new Uri(dc.Uri)); + break; + + case Microsoft.Extensions.AI.DataContent dc: + resultContent = dc.Data is not null ? + new Microsoft.SemanticKernel.BinaryContent(dc.Uri) : + new Microsoft.SemanticKernel.BinaryContent(new Uri(dc.Uri)); + break; + + case Microsoft.Extensions.AI.FunctionCallContent fcc: + resultContent = new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null); + break; + + case Microsoft.Extensions.AI.FunctionResultContent frc: + resultContent = new Microsoft.SemanticKernel.FunctionResultContent(callId: frc.CallId, result: frc.Result); + break; + } + + if (resultContent is not null) + { + resultContent.Metadata = content.AdditionalProperties; + resultContent.InnerContent = content.RawRepresentation; + resultContent.ModelId = response?.ModelId; + result.Items.Add(resultContent); + } + } + + return result; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs new file mode 100644 index 000000000000..da505c4d131b --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for . +internal static class ChatResponseUpdateExtensions +{ + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + internal static StreamingChatMessageContent ToStreamingChatMessageContent(this ChatResponseUpdate update) + { + StreamingChatMessageContent content = new( + update.Role is not null ? new AuthorRole(update.Role.Value.Value) : null, + null) + { + InnerContent = update.RawRepresentation, + ChoiceIndex = update.ChoiceIndex, + Metadata = update.AdditionalProperties, + ModelId = update.ModelId + }; + + foreach (AIContent item in update.Contents) + { + StreamingKernelContent? resultContent = + item is Microsoft.Extensions.AI.TextContent tc ? new Microsoft.SemanticKernel.StreamingTextContent(tc.Text) : + item is Microsoft.Extensions.AI.FunctionCallContent fcc ? + new Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent(fcc.CallId, fcc.Name, fcc.Arguments is not null ? + JsonSerializer.Serialize(fcc.Arguments!, AbstractionsJsonContext.Default.IDictionaryStringObject!) : + null) : + null; + + if (resultContent is not null) + { + resultContent.ModelId = update.ModelId; + content.Items.Add(resultContent); + } + } + + return content; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs index 419dca381015..3a270a453bfe 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs @@ -3,12 +3,8 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Linq; using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -23,7 +19,7 @@ internal sealed class ChatClientChatCompletionService : IChatCompletionService private readonly IChatClient _chatClient; /// Initializes the for . - public ChatClientChatCompletionService(IChatClient chatClient, IServiceProvider? serviceProvider) + internal ChatClientChatCompletionService(IChatClient chatClient, IServiceProvider? serviceProvider) { Verify.NotNull(chatClient); @@ -54,20 +50,20 @@ public async Task> GetChatMessageContentsAsync { Verify.NotNull(chatHistory); - var messageList = ChatCompletionServiceExtensions.ToChatMessageList(chatHistory); + var messageList = chatHistory.ToChatMessageList(); var currentSize = messageList.Count; var completion = await this._chatClient.GetResponseAsync( messageList, - ToChatOptions(executionSettings, kernel), + executionSettings.ToChatOptions(kernel), cancellationToken).ConfigureAwait(false); chatHistory.AddRange( messageList .Skip(currentSize) - .Select(m => ChatCompletionServiceExtensions.ToChatMessageContent(m))); + .Select(m => m.ToChatMessageContent())); - return completion.Choices.Select(m => ChatCompletionServiceExtensions.ToChatMessageContent(m, completion)).ToList(); + return completion.Choices.Select(m => m.ToChatMessageContent(completion)).ToList(); } /// @@ -77,234 +73,11 @@ public async IAsyncEnumerable GetStreamingChatMessa Verify.NotNull(chatHistory); await foreach (var update in this._chatClient.GetStreamingResponseAsync( - ChatCompletionServiceExtensions.ToChatMessageList(chatHistory), - ToChatOptions(executionSettings, kernel), + chatHistory.ToChatMessageList(), + executionSettings.ToChatOptions(kernel), cancellationToken).ConfigureAwait(false)) { - yield return ToStreamingChatMessageContent(update); + yield return update.ToStreamingChatMessageContent(); } } - - /// Converts a pair of and to a . - private static ChatOptions? ToChatOptions(PromptExecutionSettings? settings, Kernel? kernel) - { - if (settings is null) - { - return null; - } - - if (settings.GetType() != typeof(PromptExecutionSettings)) - { - // If the settings are of a derived type, roundtrip through JSON to the base type in order to try - // to get the derived strongly-typed properties to show up in the loosely-typed ExtensionData dictionary. - // This has the unfortunate effect of making all the ExtensionData values into JsonElements, so we lose - // some type fidelity. (As an alternative, we could introduce new interfaces that could be queried for - // in this method and implemented by the derived settings types to control how they're converted to - // ChatOptions.) - settings = JsonSerializer.Deserialize( - JsonSerializer.Serialize(settings, AbstractionsJsonContext.GetTypeInfo(settings.GetType(), null)), - AbstractionsJsonContext.Default.PromptExecutionSettings); - } - - ChatOptions options = new() - { - ModelId = settings!.ModelId - }; - - if (settings!.ExtensionData is IDictionary extensionData) - { - foreach (var entry in extensionData) - { - if (entry.Key.Equals("temperature", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float temperature)) - { - options.Temperature = temperature; - } - else if (entry.Key.Equals("top_p", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float topP)) - { - options.TopP = topP; - } - else if (entry.Key.Equals("top_k", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out int topK)) - { - options.TopK = topK; - } - else if (entry.Key.Equals("seed", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out long seed)) - { - options.Seed = seed; - } - else if (entry.Key.Equals("max_tokens", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out int maxTokens)) - { - options.MaxOutputTokens = maxTokens; - } - else if (entry.Key.Equals("frequency_penalty", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float frequencyPenalty)) - { - options.FrequencyPenalty = frequencyPenalty; - } - else if (entry.Key.Equals("presence_penalty", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float presencePenalty)) - { - options.PresencePenalty = presencePenalty; - } - else if (entry.Key.Equals("stop_sequences", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out IList? stopSequences)) - { - options.StopSequences = stopSequences; - } - else if (entry.Key.Equals("response_format", StringComparison.OrdinalIgnoreCase) && - entry.Value is { } responseFormat) - { - if (TryConvert(responseFormat, out string? responseFormatString)) - { - options.ResponseFormat = responseFormatString switch - { - "text" => ChatResponseFormat.Text, - "json_object" => ChatResponseFormat.Json, - _ => null, - }; - } - else - { - options.ResponseFormat = responseFormat is JsonElement e ? ChatResponseFormat.ForJsonSchema(e) : null; - } - } - else - { - // Roundtripping a derived PromptExecutionSettings through the base type will have put all the - // object values in AdditionalProperties into JsonElements. Convert them back where possible. - object? value = entry.Value; - if (value is JsonElement jsonElement) - { - value = jsonElement.ValueKind switch - { - JsonValueKind.String => jsonElement.GetString(), - JsonValueKind.Number => jsonElement.GetDouble(), // not perfect, but a reasonable heuristic - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => value, - }; - - if (jsonElement.ValueKind == JsonValueKind.Array) - { - var enumerator = jsonElement.EnumerateArray(); - - var enumeratorType = enumerator.MoveNext() ? enumerator.Current.ValueKind : JsonValueKind.Null; - - switch (enumeratorType) - { - case JsonValueKind.String: - value = enumerator.Select(e => e.GetString()); - break; - case JsonValueKind.Number: - value = enumerator.Select(e => e.GetDouble()); - break; - case JsonValueKind.True or JsonValueKind.False: - value = enumerator.Select(e => e.ValueKind == JsonValueKind.True); - break; - } - } - } - - (options.AdditionalProperties ??= [])[entry.Key] = value; - } - } - } - - if (settings.FunctionChoiceBehavior?.GetConfiguration(new([]) { Kernel = kernel }).Functions is { Count: > 0 } functions) - { - options.ToolMode = settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior ? ChatToolMode.RequireAny : ChatToolMode.Auto; - options.Tools = functions.Select(f => f.AsAIFunction(kernel)).Cast().ToList(); - } - - return options; - - // Be a little lenient on the types of the values used in the extension data, - // e.g. allow doubles even when requesting floats. - static bool TryConvert(object? value, [NotNullWhen(true)] out T? result) - { - if (value is not null) - { - // If the value is a T, use it. - if (value is T typedValue) - { - result = typedValue; - return true; - } - - if (value is JsonElement json) - { - // If the value is JsonElement, it likely resulted from JSON serializing as object. - // Try to deserialize it as a T. This currently will only be successful either when - // reflection-based serialization is enabled or T is one of the types special-cased - // in the AbstractionsJsonContext. For other cases with NativeAOT, we would need to - // have a JsonSerializationOptions with the relevant type information. - if (AbstractionsJsonContext.TryGetTypeInfo(typeof(T), firstOptions: null, out JsonTypeInfo? jti)) - { - try - { - result = (T)json.Deserialize(jti)!; - return true; - } - catch (Exception e) when (e is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) - { - } - } - } - else - { - // Otherwise, try to convert it to a T using Convert, in particular to handle conversions between numeric primitive types. - try - { - result = (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); - return true; - } - catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) - { - } - } - } - - result = default; - return false; - } - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - private static StreamingChatMessageContent ToStreamingChatMessageContent(ChatResponseUpdate update) - { - StreamingChatMessageContent content = new( - update.Role is not null ? new AuthorRole(update.Role.Value.Value) : null, - null) - { - InnerContent = update.RawRepresentation, - ChoiceIndex = update.ChoiceIndex, - Metadata = update.AdditionalProperties, - ModelId = update.ModelId - }; - - foreach (AIContent item in update.Contents) - { - StreamingKernelContent? resultContent = - item is Microsoft.Extensions.AI.TextContent tc ? new Microsoft.SemanticKernel.StreamingTextContent(tc.Text) : - item is Microsoft.Extensions.AI.FunctionCallContent fcc ? - new Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent(fcc.CallId, fcc.Name, fcc.Arguments is not null ? - JsonSerializer.Serialize(fcc.Arguments!, AbstractionsJsonContext.Default.IDictionaryStringObject!) : - null) : - null; - - if (resultContent is not null) - { - resultContent.ModelId = update.ModelId; - content.Items.Add(resultContent); - } - } - - return content; - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs index 862239ccd505..a038c169184e 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs @@ -19,7 +19,7 @@ internal sealed class ChatCompletionServiceChatClient : IChatClient private readonly IChatCompletionService _chatCompletionService; /// Initializes the for . - public ChatCompletionServiceChatClient(IChatCompletionService chatCompletionService) + internal ChatCompletionServiceChatClient(IChatCompletionService chatCompletionService) { Verify.NotNull(chatCompletionService); @@ -40,12 +40,12 @@ public ChatCompletionServiceChatClient(IChatCompletionService chatCompletionServ Verify.NotNull(chatMessages); var response = await this._chatCompletionService.GetChatMessageContentAsync( - new ChatHistory(chatMessages.Select(m => ChatCompletionServiceExtensions.ToChatMessageContent(m))), + new ChatHistory(chatMessages.Select(m => m.ToChatMessageContent())), ToPromptExecutionSettings(options), kernel: null, cancellationToken).ConfigureAwait(false); - return new(ChatCompletionServiceExtensions.ToChatMessage(response)) + return new(response.ToChatMessage()) { ModelId = response.ModelId, RawRepresentation = response.InnerContent, @@ -58,12 +58,12 @@ public async IAsyncEnumerable GetStreamingResponseAsync(ILis Verify.NotNull(chatMessages); await foreach (var update in this._chatCompletionService.GetStreamingChatMessageContentsAsync( - new ChatHistory(chatMessages.Select(m => ChatCompletionServiceExtensions.ToChatMessageContent(m))), + new ChatHistory(chatMessages.Select(m => m.ToChatMessageContent())), ToPromptExecutionSettings(options), kernel: null, cancellationToken).ConfigureAwait(false)) { - yield return ToStreamingChatCompletionUpdate(update); + yield return update.ToChatResponseUpdate(); } } @@ -191,46 +191,4 @@ public void Dispose() return settings; } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - private static ChatResponseUpdate ToStreamingChatCompletionUpdate(StreamingChatMessageContent content) - { - ChatResponseUpdate update = new() - { - AdditionalProperties = content.Metadata is not null ? new AdditionalPropertiesDictionary(content.Metadata) : null, - AuthorName = content.AuthorName, - ChoiceIndex = content.ChoiceIndex, - ModelId = content.ModelId, - RawRepresentation = content, - Role = content.Role is not null ? new ChatRole(content.Role.Value.Label) : null, - }; - - foreach (var item in content.Items) - { - AIContent? aiContent = null; - switch (item) - { - case Microsoft.SemanticKernel.StreamingTextContent tc: - aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); - break; - - case Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent fcc: - aiContent = new Microsoft.Extensions.AI.FunctionCallContent( - fcc.CallId ?? string.Empty, - fcc.Name ?? string.Empty, - fcc.Arguments is not null ? JsonSerializer.Deserialize>(fcc.Arguments, AbstractionsJsonContext.Default.IDictionaryStringObject!) : null); - break; - } - - if (aiContent is not null) - { - aiContent.RawRepresentation = content; - - update.Contents.Add(aiContent); - } - } - - return update; - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs index cf5834725700..844d940e5e54 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs @@ -128,148 +128,4 @@ public static IChatClient AsChatClient(this IChatCompletionService service) chatClient : new ChatCompletionServiceChatClient(service); } - - /// Creates an for the specified . - /// The chat client to be represented as a chat completion service. - /// An optional that can be used to resolve services to use in the instance. - /// - /// The . If is an , will - /// be returned. Otherwise, a new will be created that wraps . - /// - [Experimental("SKEXP0001")] - public static IChatCompletionService AsChatCompletionService(this IChatClient client, IServiceProvider? serviceProvider = null) - { - Verify.NotNull(client); - - return client is IChatCompletionService chatCompletionService ? - chatCompletionService : - new ChatClientChatCompletionService(client, serviceProvider); - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - internal static ChatMessage ToChatMessage(ChatMessageContent content) - { - ChatMessage message = new() - { - AdditionalProperties = content.Metadata is not null ? new(content.Metadata) : null, - AuthorName = content.AuthorName, - RawRepresentation = content.InnerContent, - Role = content.Role.Label is string label ? new ChatRole(label) : ChatRole.User, - }; - - foreach (var item in content.Items) - { - AIContent? aiContent = null; - switch (item) - { - case Microsoft.SemanticKernel.TextContent tc: - aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); - break; - - case Microsoft.SemanticKernel.ImageContent ic: - aiContent = - ic.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ic.DataUri, ic.MimeType ?? "image/*") : - ic.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ic.Uri, ic.MimeType ?? "image/*") : - null; - break; - - case Microsoft.SemanticKernel.AudioContent ac: - aiContent = - ac.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ac.DataUri, ac.MimeType ?? "audio/*") : - ac.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ac.Uri, ac.MimeType ?? "audio/*") : - null; - break; - - case Microsoft.SemanticKernel.BinaryContent bc: - aiContent = - bc.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(bc.DataUri, bc.MimeType) : - bc.Uri is not null ? new Microsoft.Extensions.AI.DataContent(bc.Uri, bc.MimeType) : - null; - break; - - case Microsoft.SemanticKernel.FunctionCallContent fcc: - aiContent = new Microsoft.Extensions.AI.FunctionCallContent(fcc.Id ?? string.Empty, fcc.FunctionName, fcc.Arguments); - break; - - case Microsoft.SemanticKernel.FunctionResultContent frc: - aiContent = new Microsoft.Extensions.AI.FunctionResultContent(frc.CallId ?? string.Empty, frc.Result); - break; - } - - if (aiContent is not null) - { - aiContent.RawRepresentation = item.InnerContent; - aiContent.AdditionalProperties = item.Metadata is not null ? new(item.Metadata) : null; - - message.Contents.Add(aiContent); - } - } - - return message; - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - internal static ChatMessageContent ToChatMessageContent(ChatMessage message, Microsoft.Extensions.AI.ChatResponse? response = null) - { - ChatMessageContent result = new() - { - ModelId = response?.ModelId, - AuthorName = message.AuthorName, - InnerContent = response?.RawRepresentation ?? message.RawRepresentation, - Metadata = message.AdditionalProperties, - Role = new AuthorRole(message.Role.Value), - }; - - foreach (AIContent content in message.Contents) - { - KernelContent? resultContent = null; - switch (content) - { - case Microsoft.Extensions.AI.TextContent tc: - resultContent = new Microsoft.SemanticKernel.TextContent(tc.Text); - break; - - case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("image/"): - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.ImageContent(dc.Uri) : - new Microsoft.SemanticKernel.ImageContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("audio/"): - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.AudioContent(dc.Uri) : - new Microsoft.SemanticKernel.AudioContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.DataContent dc: - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.BinaryContent(dc.Uri) : - new Microsoft.SemanticKernel.BinaryContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.FunctionCallContent fcc: - resultContent = new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null); - break; - - case Microsoft.Extensions.AI.FunctionResultContent frc: - resultContent = new Microsoft.SemanticKernel.FunctionResultContent(callId: frc.CallId, result: frc.Result); - break; - } - - if (resultContent is not null) - { - resultContent.Metadata = content.AdditionalProperties; - resultContent.InnerContent = content.RawRepresentation; - resultContent.ModelId = response?.ModelId; - result.Items.Add(resultContent); - } - } - - return result; - } - - internal static List ToChatMessageList(ChatHistory chatHistory) - => chatHistory.Select(ToChatMessage).ToList(); } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs index faf11b2fe450..a238e77417da 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; namespace Microsoft.SemanticKernel.ChatCompletion; @@ -80,4 +81,7 @@ public static async Task ReduceAsync(this ChatHistory chatHistory, return chatHistory; } + + internal static List ToChatMessageList(this ChatHistory chatHistory) + => chatHistory.Select(m => m.ToChatMessage()).ToList(); } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs new file mode 100644 index 000000000000..98bb09be6f85 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel; + +internal static class PromptExecutionSettingsExtensions +{ + /// Converts a pair of and to a . + internal static ChatOptions? ToChatOptions(this PromptExecutionSettings? settings, Kernel? kernel) + { + if (settings is null) + { + return null; + } + + if (settings.GetType() != typeof(PromptExecutionSettings)) + { + // If the settings are of a derived type, roundtrip through JSON to the base type in order to try + // to get the derived strongly-typed properties to show up in the loosely-typed ExtensionData dictionary. + // This has the unfortunate effect of making all the ExtensionData values into JsonElements, so we lose + // some type fidelity. (As an alternative, we could introduce new interfaces that could be queried for + // in this method and implemented by the derived settings types to control how they're converted to + // ChatOptions.) + settings = JsonSerializer.Deserialize( + JsonSerializer.Serialize(settings, AbstractionsJsonContext.GetTypeInfo(settings.GetType(), null)), + AbstractionsJsonContext.Default.PromptExecutionSettings); + } + + ChatOptions options = new() + { + ModelId = settings!.ModelId + }; + + if (settings!.ExtensionData is IDictionary extensionData) + { + foreach (var entry in extensionData) + { + if (entry.Key.Equals("temperature", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out float temperature)) + { + options.Temperature = temperature; + } + else if (entry.Key.Equals("top_p", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out float topP)) + { + options.TopP = topP; + } + else if (entry.Key.Equals("top_k", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out int topK)) + { + options.TopK = topK; + } + else if (entry.Key.Equals("seed", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out long seed)) + { + options.Seed = seed; + } + else if (entry.Key.Equals("max_tokens", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out int maxTokens)) + { + options.MaxOutputTokens = maxTokens; + } + else if (entry.Key.Equals("frequency_penalty", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out float frequencyPenalty)) + { + options.FrequencyPenalty = frequencyPenalty; + } + else if (entry.Key.Equals("presence_penalty", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out float presencePenalty)) + { + options.PresencePenalty = presencePenalty; + } + else if (entry.Key.Equals("stop_sequences", StringComparison.OrdinalIgnoreCase) && + TryConvert(entry.Value, out IList? stopSequences)) + { + options.StopSequences = stopSequences; + } + else if (entry.Key.Equals("response_format", StringComparison.OrdinalIgnoreCase) && + entry.Value is { } responseFormat) + { + if (TryConvert(responseFormat, out string? responseFormatString)) + { + options.ResponseFormat = responseFormatString switch + { + "text" => ChatResponseFormat.Text, + "json_object" => ChatResponseFormat.Json, + _ => null, + }; + } + else + { + options.ResponseFormat = responseFormat is JsonElement e ? ChatResponseFormat.ForJsonSchema(e) : null; + } + } + else + { + // Roundtripping a derived PromptExecutionSettings through the base type will have put all the + // object values in AdditionalProperties into JsonElements. Convert them back where possible. + object? value = entry.Value; + if (value is JsonElement jsonElement) + { + value = jsonElement.ValueKind switch + { + JsonValueKind.String => jsonElement.GetString(), + JsonValueKind.Number => jsonElement.GetDouble(), // not perfect, but a reasonable heuristic + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => value, + }; + + if (jsonElement.ValueKind == JsonValueKind.Array) + { + var enumerator = jsonElement.EnumerateArray(); + + var enumeratorType = enumerator.MoveNext() ? enumerator.Current.ValueKind : JsonValueKind.Null; + + switch (enumeratorType) + { + case JsonValueKind.String: + value = enumerator.Select(e => e.GetString()); + break; + case JsonValueKind.Number: + value = enumerator.Select(e => e.GetDouble()); + break; + case JsonValueKind.True or JsonValueKind.False: + value = enumerator.Select(e => e.ValueKind == JsonValueKind.True); + break; + } + } + } + + (options.AdditionalProperties ??= [])[entry.Key] = value; + } + } + } + + if (settings.FunctionChoiceBehavior?.GetConfiguration(new([]) { Kernel = kernel }).Functions is { Count: > 0 } functions) + { + options.ToolMode = settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior ? ChatToolMode.RequireAny : ChatToolMode.Auto; + options.Tools = functions.Select(f => f.AsAIFunction(kernel)).Cast().ToList(); + } + + return options; + + // Be a little lenient on the types of the values used in the extension data, + // e.g. allow doubles even when requesting floats. + static bool TryConvert(object? value, [NotNullWhen(true)] out T? result) + { + if (value is not null) + { + // If the value is a T, use it. + if (value is T typedValue) + { + result = typedValue; + return true; + } + + if (value is JsonElement json) + { + // If the value is JsonElement, it likely resulted from JSON serializing as object. + // Try to deserialize it as a T. This currently will only be successful either when + // reflection-based serialization is enabled or T is one of the types special-cased + // in the AbstractionsJsonContext. For other cases with NativeAOT, we would need to + // have a JsonSerializationOptions with the relevant type information. + if (AbstractionsJsonContext.TryGetTypeInfo(typeof(T), firstOptions: null, out JsonTypeInfo? jti)) + { + try + { + result = (T)json.Deserialize(jti)!; + return true; + } + catch (Exception e) when (e is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) + { + } + } + } + else + { + // Otherwise, try to convert it to a T using Convert, in particular to handle conversions between numeric primitive types. + try + { + result = (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) + { + } + } + } + + result = default; + return false; + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml b/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml new file mode 100644 index 000000000000..da61649a30bd --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/CompatibilitySuppressions.xml @@ -0,0 +1,18 @@ + + + + + CP0002 + M:Microsoft.SemanticKernel.ChatCompletion.ChatCompletionServiceExtensions.AsChatCompletionService(Microsoft.Extensions.AI.IChatClient,System.IServiceProvider) + lib/net8.0/Microsoft.SemanticKernel.Abstractions.dll + lib/net8.0/Microsoft.SemanticKernel.Abstractions.dll + true + + + CP0002 + M:Microsoft.SemanticKernel.ChatCompletion.ChatCompletionServiceExtensions.AsChatCompletionService(Microsoft.Extensions.AI.IChatClient,System.IServiceProvider) + lib/netstandard2.0/Microsoft.SemanticKernel.Abstractions.dll + lib/netstandard2.0/Microsoft.SemanticKernel.Abstractions.dll + true + + \ No newline at end of file diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs new file mode 100644 index 000000000000..2e8d45ea89e0 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel; + +internal static class ChatMessageContentExtensions +{ + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + internal static ChatMessage ToChatMessage(this ChatMessageContent content) + { + ChatMessage message = new() + { + AdditionalProperties = content.Metadata is not null ? new(content.Metadata) : null, + AuthorName = content.AuthorName, + RawRepresentation = content.InnerContent, + Role = content.Role.Label is string label ? new ChatRole(label) : ChatRole.User, + }; + + foreach (var item in content.Items) + { + AIContent? aiContent = null; + switch (item) + { + case Microsoft.SemanticKernel.TextContent tc: + aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); + break; + + case Microsoft.SemanticKernel.ImageContent ic: + aiContent = + ic.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ic.DataUri, ic.MimeType ?? "image/*") : + ic.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ic.Uri, ic.MimeType ?? "image/*") : + null; + break; + + case Microsoft.SemanticKernel.AudioContent ac: + aiContent = + ac.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ac.DataUri, ac.MimeType ?? "audio/*") : + ac.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ac.Uri, ac.MimeType ?? "audio/*") : + null; + break; + + case Microsoft.SemanticKernel.BinaryContent bc: + aiContent = + bc.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(bc.DataUri, bc.MimeType) : + bc.Uri is not null ? new Microsoft.Extensions.AI.DataContent(bc.Uri, bc.MimeType) : + null; + break; + + case Microsoft.SemanticKernel.FunctionCallContent fcc: + aiContent = new Microsoft.Extensions.AI.FunctionCallContent(fcc.Id ?? string.Empty, fcc.FunctionName, fcc.Arguments); + break; + + case Microsoft.SemanticKernel.FunctionResultContent frc: + aiContent = new Microsoft.Extensions.AI.FunctionResultContent(frc.CallId ?? string.Empty, frc.Result); + break; + } + + if (aiContent is not null) + { + aiContent.RawRepresentation = item.InnerContent; + aiContent.AdditionalProperties = item.Metadata is not null ? new(item.Metadata) : null; + + message.Contents.Add(aiContent); + } + } + + return message; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs new file mode 100644 index 000000000000..ae955bfad14f --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel; + +/// Provides extension methods for . +internal static class StreamingChatMessageContentExtensions +{ + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + internal static ChatResponseUpdate ToChatResponseUpdate(this StreamingChatMessageContent content) + { + ChatResponseUpdate update = new() + { + AdditionalProperties = content.Metadata is not null ? new AdditionalPropertiesDictionary(content.Metadata) : null, + AuthorName = content.AuthorName, + ChoiceIndex = content.ChoiceIndex, + ModelId = content.ModelId, + RawRepresentation = content.InnerContent, + Role = content.Role is not null ? new ChatRole(content.Role.Value.Label) : null, + }; + + foreach (var item in content.Items) + { + AIContent? aiContent = null; + switch (item) + { + case Microsoft.SemanticKernel.StreamingTextContent tc: + aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); + break; + + case Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent fcc: + aiContent = new Microsoft.Extensions.AI.FunctionCallContent( + fcc.CallId ?? string.Empty, + fcc.Name ?? string.Empty, + fcc.Arguments is not null ? JsonSerializer.Deserialize>(fcc.Arguments, AbstractionsJsonContext.Default.IDictionaryStringObject!) : null); + break; + } + + if (aiContent is not null) + { + aiContent.RawRepresentation = content; + + update.Contents.Add(aiContent); + } + } + + return update; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs index 0902a4f80c98..945e9cc7a74e 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; +using Microsoft.Extensions.AI; namespace Microsoft.SemanticKernel; @@ -101,6 +103,104 @@ public FunctionResult(FunctionResult result, object? value = null) { return innerContent; } + + // Attempting to use the new Microsoft.Extensions.AI Chat types will trigger automatic conversion of SK chat contents. + + // ChatMessageContent as ChatMessage + if (typeof(T) == typeof(ChatMessage) + && content is ChatMessageContent chatMessageContent) + { + return (T?)(object)chatMessageContent.ToChatMessage(); + } + + // ChatMessageContent as ChatResponse + if (typeof(T) == typeof(ChatResponse) + && content is ChatMessageContent singleChoiceMessageContent) + { + return (T?)(object)new Microsoft.Extensions.AI.ChatResponse(singleChoiceMessageContent.ToChatMessage()); + } + } + + if (this.Value is IReadOnlyList messageContentList) + { + if (messageContentList.Count == 0) + { + throw new InvalidCastException($"Cannot cast a response with no choices to {typeof(T)}"); + } + + if (typeof(T) == typeof(ChatResponse)) + { + return (T)(object)new ChatResponse(messageContentList.Select(m => m.ToChatMessage()).ToList()); + } + + var firstMessage = messageContentList[0]; + if (typeof(T) == typeof(ChatMessage)) + { + return (T)(object)firstMessage.ToChatMessage(); + } + } + + if (this.Value is Microsoft.Extensions.AI.ChatResponse chatResponse) + { + // If no choices are present, return default + if (chatResponse.Choices.Count == 0) + { + throw new InvalidCastException($"Cannot cast a response with no choices to {typeof(T)}"); + } + + var chatMessage = chatResponse.Message; + if (typeof(T) == typeof(string)) + { + return (T?)(object?)chatMessage.ToString(); + } + + // ChatMessage from a ChatResponse + if (typeof(T) == typeof(ChatMessage)) + { + return (T?)(object)chatMessage; + } + + if (typeof(Microsoft.Extensions.AI.AIContent).IsAssignableFrom(typeof(T))) + { + // Return the first matching content type of a message if any + var updateContent = chatMessage.Contents.FirstOrDefault(c => c is T); + if (updateContent is not null) + { + return (T)(object)updateContent; + } + } + + if (chatMessage.Contents is T contentsList) + { + return contentsList; + } + + if (chatResponse.RawRepresentation is T rawResponseRepresentation) + { + return rawResponseRepresentation; + } + + if (chatMessage.RawRepresentation is T rawMessageRepresentation) + { + return rawMessageRepresentation; + } + + if (typeof(Microsoft.Extensions.AI.AIContent).IsAssignableFrom(typeof(T))) + { + // Return the first matching content type of a message if any + var updateContent = chatMessage.Contents.FirstOrDefault(c => c is T); + if (updateContent is not null) + { + return (T)(object)updateContent; + } + } + + // Avoid breaking changes this transformation will be dropped once we migrate fully to Microsoft.Extensions.AI abstractions. + // This is also necessary to don't break existing code using KernelContents when using IChatClient connectors. + if (typeof(KernelContent).IsAssignableFrom(typeof(T))) + { + return (T)(object)chatMessage.ToChatMessageContent(); + } } throw new InvalidCastException($"Cannot cast {this.Value.GetType()} to {typeof(T)}"); diff --git a/dotnet/src/SemanticKernel.Abstractions/Services/AIServiceExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Services/AIServiceExtensions.cs index 30a3ee7794e5..679864841dbb 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Services/AIServiceExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Services/AIServiceExtensions.cs @@ -64,7 +64,7 @@ public static class AIServiceExtensions /// /// /// Specifies the type of the required. This must be the same type - /// with which the service was registered in the orvia + /// with which the service was registered in the or via /// the . /// /// The to use to select a service from the . diff --git a/dotnet/src/SemanticKernel.Abstractions/Services/IAIServiceSelector.cs b/dotnet/src/SemanticKernel.Abstractions/Services/IAIServiceSelector.cs index 93064508d118..353abb9715cc 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Services/IAIServiceSelector.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Services/IAIServiceSelector.cs @@ -16,7 +16,7 @@ public interface IAIServiceSelector /// /// /// Specifies the type of the required. This must be the same type - /// with which the service was registered in the orvia + /// with which the service was registered in the or via /// the . /// /// The containing services, plugins, and other state for use throughout the operation. diff --git a/dotnet/src/SemanticKernel.Abstractions/Services/IChatClientSelector.cs b/dotnet/src/SemanticKernel.Abstractions/Services/IChatClientSelector.cs new file mode 100644 index 000000000000..30f8e2bcb4e6 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/Services/IChatClientSelector.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.SemanticKernel; + +#pragma warning disable CA1716 // Identifiers should not match keywords + +/// +/// Represents a selector which will return a combination of the containing instances of T and it's pairing +/// from the specified provider based on the model settings. +/// +[Experimental("SKEXP0001")] +public interface IChatClientSelector +{ + /// + /// Resolves an and associated from the specified + /// based on a and associated . + /// + /// + /// Specifies the type of the required. This must be the same type + /// with which the service was registered in the or via + /// the . + /// + /// The containing services, plugins, and other state for use throughout the operation. + /// The function. + /// The function arguments. + /// The selected service, or null if none was selected. + /// The settings associated with the selected service. This may be null even if a service is selected. + /// true if a matching service was selected; otherwise, false. + bool TrySelectChatClient( + Kernel kernel, + KernelFunction function, + KernelArguments arguments, + [NotNullWhen(true)] out T? service, + out PromptExecutionSettings? serviceSettings) where T : class, IChatClient; +} diff --git a/dotnet/src/SemanticKernel.Abstractions/Services/OrderedAIServiceSelector.cs b/dotnet/src/SemanticKernel.Abstractions/Services/OrderedAIServiceSelector.cs index 1200acd3a803..c11851c4d46d 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Services/OrderedAIServiceSelector.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Services/OrderedAIServiceSelector.cs @@ -3,7 +3,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel.Services; @@ -11,15 +13,23 @@ namespace Microsoft.SemanticKernel.Services; /// Implementation of that selects the AI service based on the order of the execution settings. /// Uses the service id or model id to select the preferred service provider and then returns the service and associated execution settings. /// -internal sealed class OrderedAIServiceSelector : IAIServiceSelector +internal sealed class OrderedAIServiceSelector : IAIServiceSelector, IChatClientSelector { public static OrderedAIServiceSelector Instance { get; } = new(); /// - public bool TrySelectAIService( + [Experimental("SKEXP0001")] + public bool TrySelectChatClient(Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) where T : class, IChatClient + => this.TrySelect(kernel, function, arguments, out service, out serviceSettings); + + /// + public bool TrySelectAIService(Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) where T : class, IAIService + => this.TrySelect(kernel, function, arguments, out service, out serviceSettings); + + private bool TrySelect( Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, - out PromptExecutionSettings? serviceSettings) where T : class, IAIService + out PromptExecutionSettings? serviceSettings) where T : class { // Allow the execution settings from the kernel arguments to take precedence var executionSettings = arguments.ExecutionSettings ?? function.ExecutionSettings; @@ -94,11 +104,20 @@ kernel.Services is IKeyedServiceProvider ? kernel.Services.GetService(); } - private T? GetServiceByModelId(Kernel kernel, string modelId) where T : class, IAIService + private T? GetServiceByModelId(Kernel kernel, string modelId) where T : class { foreach (var service in kernel.GetAllServices()) { - string? serviceModelId = service.GetModelId(); + string? serviceModelId = null; + if (service is IAIService aiService) + { + serviceModelId = aiService.GetModelId(); + } + else if (service is IChatClient chatClient) + { + serviceModelId = chatClient.GetModelId(); + } + if (!string.IsNullOrEmpty(serviceModelId) && serviceModelId == modelId) { return service; diff --git a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs index 367e5e7a2553..3fc35c0f3d15 100644 --- a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs +++ b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs @@ -7,11 +7,14 @@ using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.AI.ChatCompletion; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Services; using Microsoft.SemanticKernel.TextGeneration; @@ -252,6 +255,7 @@ protected override async ValueTask InvokeCoreAsync( { IChatCompletionService chatCompletion => await this.GetChatCompletionResultAsync(chatCompletion, kernel, promptRenderingResult, cancellationToken).ConfigureAwait(false), ITextGenerationService textGeneration => await this.GetTextGenerationResultAsync(textGeneration, kernel, promptRenderingResult, cancellationToken).ConfigureAwait(false), + IChatClient chatClient => await this.GetChatClientResultAsync(chatClient, kernel, promptRenderingResult, cancellationToken).ConfigureAwait(false), // The service selector didn't find an appropriate service. This should only happen with a poorly implemented selector. _ => throw new NotSupportedException($"The AI service {promptRenderingResult.AIService.GetType()} is not supported. Supported services are {typeof(IChatCompletionService)} and {typeof(ITextGenerationService)}") }; @@ -271,7 +275,7 @@ protected override async IAsyncEnumerable InvokeStreamingCoreAsync? asyncReference = null; + IAsyncEnumerable? asyncReference = null; if (result.AIService is IChatCompletionService chatCompletion) { @@ -281,32 +285,114 @@ protected override async IAsyncEnumerable InvokeStreamingCoreAsync (TResult)(object)content.ToString(), - - _ when content is TResult contentAsT - => contentAsT, - - _ when content.InnerContent is TResult innerContentAsT - => innerContentAsT, - - _ when typeof(TResult) == typeof(byte[]) - => (TResult)(object)content.ToByteArray(), + if (typeof(TResult) == typeof(string)) + { + yield return (TResult)(object)kernelContent.ToString(); + continue; + } + + if (content is TResult contentAsT) + { + yield return contentAsT; + continue; + } + + if (kernelContent.InnerContent is TResult innerContentAsT) + { + yield return innerContentAsT; + continue; + } + + if (typeof(TResult) == typeof(byte[])) + { + if (content is StreamingKernelContent byteKernelContent) + { + yield return (TResult)(object)byteKernelContent.ToByteArray(); + continue; + } + } + + // Attempting to use the new Microsoft Extensions AI types will trigger automatic conversion of SK chat contents. + if (typeof(ChatResponseUpdate).IsAssignableFrom(typeof(TResult)) + && content is StreamingChatMessageContent streamingChatMessageContent) + { + yield return (TResult)(object)streamingChatMessageContent.ToChatResponseUpdate(); + continue; + } + } + else if (content is ChatResponseUpdate chatUpdate) + { + if (typeof(TResult) == typeof(string)) + { + yield return (TResult)(object)chatUpdate.ToString(); + continue; + } + + if (chatUpdate is TResult contentAsT) + { + yield return contentAsT; + continue; + } + + if (chatUpdate.Contents is TResult contentListsAsT) + { + yield return contentListsAsT; + continue; + } + + if (chatUpdate.RawRepresentation is TResult rawRepresentationAsT) + { + yield return rawRepresentationAsT; + continue; + } + + if (typeof(Microsoft.Extensions.AI.AIContent).IsAssignableFrom(typeof(TResult))) + { + // Return the first matching content type of an update if any + var updateContent = chatUpdate.Contents.FirstOrDefault(c => c is TResult); + if (updateContent is not null) + { + yield return (TResult)(object)updateContent; + continue; + } + } + + if (typeof(TResult) == typeof(byte[])) + { + DataContent? dataContent = (DataContent?)chatUpdate.Contents.FirstOrDefault(c => c is DataContent dataContent && dataContent.Data.HasValue); + if (dataContent is not null) + { + yield return (TResult)(object)dataContent.Data!.Value.ToArray(); + continue; + } + } + + // Avoid breaking changes this transformation will be dropped once we migrate fully to Microsoft Extensions AI abstractions. + // This is also necessary to don't break existing code using KernelContents when using IChatClient connectors. + if (typeof(StreamingKernelContent).IsAssignableFrom(typeof(TResult))) + { + yield return (TResult)(object)chatUpdate.ToStreamingChatMessageContent(); + continue; + } + } - _ => throw new NotSupportedException($"The specific type {typeof(TResult)} is not supported. Support types are {typeof(StreamingTextContent)}, string, byte[], or a matching type for {typeof(StreamingTextContent)}.{nameof(StreamingTextContent.InnerContent)} property") - }; + throw new NotSupportedException($"The specific type {typeof(TResult)} is not supported. Support types are derivations of {typeof(StreamingKernelContent)}, {typeof(StreamingKernelContent)}, string, byte[], or a matching type for {typeof(StreamingKernelContent)}.{nameof(StreamingKernelContent.InnerContent)} property"); } // There is no post cancellation check to override the result as the stream data was already sent. @@ -450,13 +536,13 @@ private KernelFunctionFromPrompt( private const string MeasurementModelTagName = "semantic_kernel.function.model_id"; /// to record function invocation prompt token usage. - private static readonly Histogram s_invocationTokenUsagePrompt = s_meter.CreateHistogram( + private static readonly Histogram s_invocationTokenUsagePrompt = s_meter.CreateHistogram( name: "semantic_kernel.function.invocation.token_usage.prompt", unit: "{token}", description: "Measures the prompt token usage"); /// to record function invocation completion token usage. - private static readonly Histogram s_invocationTokenUsageCompletion = s_meter.CreateHistogram( + private static readonly Histogram s_invocationTokenUsageCompletion = s_meter.CreateHistogram( name: "semantic_kernel.function.invocation.token_usage.completion", unit: "{token}", description: "Measures the completion token usage"); @@ -481,7 +567,7 @@ private async Task RenderPromptAsync( { var serviceSelector = kernel.ServiceSelector; - IAIService? aiService; + IAIService? aiService = null; string renderedPrompt = string.Empty; // Try to use IChatCompletionService. @@ -491,12 +577,41 @@ private async Task RenderPromptAsync( { aiService = chatService; } - else + else if (serviceSelector.TrySelectAIService( + kernel, this, arguments, + out ITextGenerationService? textService, out executionSettings)) { - // If IChatCompletionService isn't available, try to fallback to ITextGenerationService, - // throwing if it's not available. - (aiService, executionSettings) = serviceSelector.SelectAIService(kernel, this, arguments); + aiService = textService; + } +#pragma warning disable CA2000 // Dispose objects before losing scope + else if (serviceSelector is IChatClientSelector chatClientServiceSelector + && chatClientServiceSelector.TrySelectChatClient(kernel, this, arguments, out var chatClient, out executionSettings)) + { + // Resolves a ChatClient as AIService so it don't need to implement IChatCompletionService. + aiService = new ChatClientAIService(chatClient); + } + + if (aiService is null) + { + var message = new StringBuilder().Append("No service was found for any of the supported types: ").Append(typeof(IChatCompletionService)).Append(", ").Append(typeof(ITextGenerationService)).Append(", ").Append(typeof(IChatClient)).Append('.'); + if (this.ExecutionSettings is not null) + { + string serviceIds = string.Join("|", this.ExecutionSettings.Keys); + if (!string.IsNullOrEmpty(serviceIds)) + { + message.Append(" Expected serviceIds: ").Append(serviceIds).Append('.'); + } + + string modelIds = string.Join("|", this.ExecutionSettings.Values.Select(model => model.ModelId)); + if (!string.IsNullOrEmpty(modelIds)) + { + message.Append(" Expected modelIds: ").Append(modelIds).Append('.'); + } + } + + throw new KernelException(message.ToString()); } +#pragma warning restore CA2000 // Dispose objects before losing scope Verify.NotNull(aiService); @@ -615,6 +730,46 @@ JsonElement SerializeToElement(object? value) } } + /// + /// Captures usage details, including token information. + /// + private void CaptureUsageDetails(string? modelId, UsageDetails? usageDetails, ILogger logger) + { + if (!logger.IsEnabled(LogLevel.Information) && + !s_invocationTokenUsageCompletion.Enabled && + !s_invocationTokenUsagePrompt.Enabled) + { + // Bail early to avoid unnecessary work. + return; + } + + if (string.IsNullOrWhiteSpace(modelId)) + { + logger.LogInformation("No model ID provided to capture usage details."); + return; + } + + if (usageDetails is null) + { + logger.LogInformation("No usage details was provided."); + return; + } + + if (usageDetails.InputTokenCount.HasValue && usageDetails.OutputTokenCount.HasValue) + { + TagList tags = new() { + { MeasurementFunctionTagName, this.Name }, + { MeasurementModelTagName, modelId } + }; + s_invocationTokenUsagePrompt.Record(usageDetails.InputTokenCount.Value, in tags); + s_invocationTokenUsageCompletion.Record(usageDetails.OutputTokenCount.Value, in tags); + } + else + { + logger.LogWarning("Unable to get token details from model result."); + } + } + private async Task GetChatCompletionResultAsync( IChatCompletionService chatCompletion, Kernel kernel, @@ -646,6 +801,40 @@ private async Task GetChatCompletionResultAsync( return new FunctionResult(this, chatContents, kernel.Culture) { RenderedPrompt = promptRenderingResult.RenderedPrompt }; } + private async Task GetChatClientResultAsync( + IChatClient chatClient, + Kernel kernel, + PromptRenderingResult promptRenderingResult, + CancellationToken cancellationToken) + { + var chatResponse = await chatClient.GetResponseAsync( + promptRenderingResult.RenderedPrompt, + promptRenderingResult.ExecutionSettings, + kernel, + cancellationToken).ConfigureAwait(false); + + if (chatResponse.Choices is { Count: 0 }) + { + return new FunctionResult(this, chatResponse) + { + Culture = kernel.Culture, + RenderedPrompt = promptRenderingResult.RenderedPrompt + }; + } + + var modelId = chatClient.GetService()?.ModelId; + + // Usage details are global and duplicated for each chat message content, use first one to get usage information + this.CaptureUsageDetails(chatClient.GetService()?.ModelId, chatResponse.Usage, this._logger); + + return new FunctionResult(this, chatResponse) + { + Culture = kernel.Culture, + RenderedPrompt = promptRenderingResult.RenderedPrompt, + Metadata = chatResponse.AdditionalProperties, + }; + } + private async Task GetTextGenerationResultAsync( ITextGenerationService textGeneration, Kernel kernel, diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs index 556799ecc85e..7b13b2f999e8 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs @@ -25,7 +25,7 @@ public void InvalidArgumentsThrow() Assert.Throws("generator", () => EmbeddingGenerationExtensions.AsEmbeddingGenerationService(null!)); Assert.Throws("service", () => ChatCompletionServiceExtensions.AsChatClient(null!)); - Assert.Throws("client", () => ChatCompletionServiceExtensions.AsChatCompletionService(null!)); + Assert.Throws("client", () => Microsoft.SemanticKernel.ChatCompletion.ChatClientExtensions.AsChatCompletionService(null!)); } [Fact] diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs new file mode 100644 index 000000000000..5a67a0ecf370 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Services; +using Xunit; + +namespace SemanticKernel.UnitTests.Functions; + +public class CustomAIChatClientSelectorTests +{ + [Fact] + public void ItGetsChatClientUsingModelIdAttribute() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient = new ChatClientTest(); + builder.Services.AddKeyedSingleton("service1", chatClient); + Kernel kernel = builder.Build(); + + var function = kernel.CreateFunctionFromPrompt("Hello AI"); + IChatClientSelector chatClientSelector = new CustomChatClientSelector(); + + // Act + chatClientSelector.TrySelectChatClient(kernel, function, [], out var selectedChatClient, out var defaultExecutionSettings); + + // Assert + Assert.NotNull(selectedChatClient); + Assert.Equal("Value1", selectedChatClient.GetModelId()); + Assert.Null(defaultExecutionSettings); + selectedChatClient.Dispose(); + } + + private sealed class CustomChatClientSelector : IChatClientSelector + { +#pragma warning disable CS8769 // Nullability of reference types in value doesn't match target type. Cannot use [NotNullWhen] because of access to internals from abstractions. + public bool TrySelectChatClient(Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) + where T : class, IChatClient + { + var keyedService = (kernel.Services as IKeyedServiceProvider)?.GetKeyedService("service1"); + if (keyedService is null || keyedService.GetModelId() is null) + { + service = null; + serviceSettings = null; + return false; + } + + service = string.Equals(keyedService.GetModelId(), "Value1", StringComparison.OrdinalIgnoreCase) ? keyedService as T : null; + serviceSettings = null; + + if (service is null) + { + throw new InvalidOperationException("Service not found"); + } + + return true; + } + } + + private sealed class ChatClientTest : IChatClient + { + private readonly ChatClientMetadata _metadata; + + public ChatClientTest() + { + this._metadata = new ChatClientMetadata(modelId: "Value1"); + } + + public void Dispose() + { + } + + public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return this._metadata; + } + + public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIServiceSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIServiceSelectorTests.cs index a53d8550c4d7..4697a0958c64 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIServiceSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIServiceSelectorTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Services; @@ -33,7 +35,8 @@ public void ItGetsAIServiceUsingArbitraryAttributes() private sealed class CustomAIServiceSelector : IAIServiceSelector { #pragma warning disable CS8769 // Nullability of reference types in value doesn't match target type. Cannot use [NotNullWhen] because of access to internals from abstractions. - bool IAIServiceSelector.TrySelectAIService(Kernel kernel, KernelFunction function, KernelArguments arguments, out T? service, out PromptExecutionSettings? serviceSettings) where T : class + public bool TrySelectAIService(Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) + where T : class, IAIService { var keyedService = (kernel.Services as IKeyedServiceProvider)?.GetKeyedService("service1"); if (keyedService is null || keyedService.Attributes is null) @@ -45,6 +48,12 @@ bool IAIServiceSelector.TrySelectAIService(Kernel kernel, KernelFunction func service = keyedService.Attributes.ContainsKey("Key1") ? keyedService as T : null; serviceSettings = null; + + if (service is null) + { + throw new InvalidOperationException("Service not found"); + } + return true; } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs index 787718b6e8e4..4d2f5e14d763 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Globalization; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; using Xunit; +using MEAI = Microsoft.Extensions.AI; namespace SemanticKernel.UnitTests.Functions; @@ -134,4 +136,172 @@ public void GetValueWhenValueIsKernelContentGenericTypeMatchShouldReturn() Assert.Equal(valueType, target.GetValue()); Assert.Equal(valueType, target.GetValue()); } + + [Fact] + public void GetValueConvertsFromMEAIChatMessageToSKChatMessageContent() + { + // Arrange + string expectedValue = Guid.NewGuid().ToString(); + var openAICompletion = OpenAI.Chat.OpenAIChatModelFactory.ChatCompletion( + role: OpenAI.Chat.ChatMessageRole.User, + content: new OpenAI.Chat.ChatMessageContent(expectedValue)); + + var valueType = new MEAI.ChatResponse( + [ + new MEAI.ChatMessage(MEAI.ChatRole.User, expectedValue) + { + RawRepresentation = openAICompletion.Content + }, + new MEAI.ChatMessage(MEAI.ChatRole.Assistant, expectedValue) + { + RawRepresentation = openAICompletion.Content + } + ]) + { + RawRepresentation = openAICompletion + }; + + FunctionResult target = new(s_nopFunction, valueType); + + // Act and Assert + var message = target.GetValue()!; + Assert.Equal(valueType.Message.Text, message.Content); + Assert.Same(valueType.Message.RawRepresentation, message.InnerContent); + } + + [Fact] + public void GetValueConvertsFromSKChatMessageContentToMEAIChatMessage() + { + // Arrange + string expectedValue = Guid.NewGuid().ToString(); + var openAIChatMessage = OpenAI.Chat.ChatMessage.CreateUserMessage(expectedValue); + var valueType = new ChatMessageContent(AuthorRole.User, expectedValue) { InnerContent = openAIChatMessage }; + FunctionResult target = new(s_nopFunction, valueType); + + // Act and Assert + Assert.Equal(valueType.Content, target.GetValue()!.Text); + Assert.Same(valueType.InnerContent, target.GetValue()!.RawRepresentation); + } + + [Fact] + public void GetValueConvertsFromSKChatMessageContentToMEAIChatResponse() + { + // Arrange + string expectedValue = Guid.NewGuid().ToString(); + var openAIChatMessage = OpenAI.Chat.ChatMessage.CreateUserMessage(expectedValue); + var valueType = new ChatMessageContent(AuthorRole.User, expectedValue) { InnerContent = openAIChatMessage }; + FunctionResult target = new(s_nopFunction, valueType); + + // Act and Assert + + Assert.Equal(valueType.Content, target.GetValue()!.Message.Text); + Assert.Same(valueType.InnerContent, target.GetValue()!.Message.RawRepresentation); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + public void GetValueConvertsFromSKChatMessageContentListToMEAIChatResponse(int listSize) + { + // Arrange + List multipleChoiceResponse = []; + for (int i = 0; i < listSize; i++) + { + multipleChoiceResponse.Add(new ChatMessageContent(AuthorRole.User, Guid.NewGuid().ToString()) + { + InnerContent = OpenAI.Chat.ChatMessage.CreateUserMessage(i.ToString()) + }); + } + FunctionResult target = new(KernelFunctionFactory.CreateFromMethod(() => { }), (IReadOnlyList)multipleChoiceResponse); + + // Act and Assert + // Ensure returns the ChatResponse for no choices as well + var result = target.GetValue()!; + for (int i = 0; i < listSize; i++) + { + Assert.Equal(multipleChoiceResponse[i].Content, result.Choices[i].Text); + Assert.Same(multipleChoiceResponse[i].InnerContent, result.Choices[i].RawRepresentation); + } + Assert.Equal(multipleChoiceResponse.Count, result.Choices.Count); + + if (listSize > 0) + { + // Ensure the conversion to the first message works in one or multiple choice response + Assert.Equal(multipleChoiceResponse[0].Content, target.GetValue()!.Text); + Assert.Same(multipleChoiceResponse[0].InnerContent, target.GetValue()!.RawRepresentation); + } + } + + [Fact] + public void GetValueThrowsForEmptyChoicesFromSKChatMessageContentListToMEAITypes() + { + // Arrange + List multipleChoiceResponse = []; + FunctionResult target = new(KernelFunctionFactory.CreateFromMethod(() => { }), (IReadOnlyList)multipleChoiceResponse); + + // Act and Assert + var exception = Assert.Throws(target.GetValue); + Assert.Contains("no choices", exception.Message); + + exception = Assert.Throws(target.GetValue); + Assert.Contains("no choices", exception.Message); + } + + [Fact] + public void GetValueCanRetrieveMEAITypes() + { + // Arrange + string expectedValue = Guid.NewGuid().ToString(); + var openAICompletion = OpenAI.Chat.OpenAIChatModelFactory.ChatCompletion( + role: OpenAI.Chat.ChatMessageRole.User, + content: new OpenAI.Chat.ChatMessageContent(expectedValue)); + + var valueType = new MEAI.ChatResponse( + new MEAI.ChatMessage(MEAI.ChatRole.User, expectedValue) + { + RawRepresentation = openAICompletion.Content + }) + { + RawRepresentation = openAICompletion + }; + + FunctionResult target = new(s_nopFunction, valueType); + + // Act and Assert + Assert.Same(valueType, target.GetValue()); + Assert.Same(valueType.Message, target.GetValue()); + Assert.Same(valueType.Message.Contents[0], target.GetValue()); + Assert.Same(valueType.Message.Contents[0], target.GetValue()); + + // Check the the content list is returned + Assert.Same(valueType.Message.Contents, target.GetValue>()!); + Assert.Same(valueType.Message.Contents[0], target.GetValue>()![0]); + Assert.IsType(target.GetValue>()![0]); + + // Check the raw representations are returned + Assert.Same(valueType.RawRepresentation, target.GetValue()!); + Assert.Same(valueType.Message.RawRepresentation, target.GetValue()!); + } + + [Fact] + public void GetValueThrowsForEmptyChoicesToMEAITypes() + { + // Arrange + string expectedValue = Guid.NewGuid().ToString(); + var valueType = new MEAI.ChatResponse([]); + FunctionResult target = new(s_nopFunction, valueType); + + // Act and Assert + Assert.Empty(target.GetValue()!.Choices); + + var exception = Assert.Throws(target.GetValue); + Assert.Contains("no choices", exception.Message); + + exception = Assert.Throws(target.GetValue); + Assert.Contains("no choices", exception.Message); + + exception = Assert.Throws(target.GetValue); + Assert.Contains("no choices", exception.Message); + } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs index 72dc5199dafb..2ec0c214b2a5 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs @@ -17,8 +17,7 @@ using Moq; using SemanticKernel.UnitTests.Functions.JsonSerializerContexts; using Xunit; - -// ReSharper disable StringLiteralTypo +using MEAI = Microsoft.Extensions.AI; namespace SemanticKernel.UnitTests.Functions; @@ -150,18 +149,18 @@ public async Task ItUsesServiceIdWhenProvidedInMethodAsync() public async Task ItUsesChatServiceIdWhenProvidedInMethodAsync() { // Arrange - var mockTextGeneration1 = new Mock(); - var mockTextGeneration2 = new Mock(); + var mockTextGeneration = new Mock(); + var mockChatCompletion = new Mock(); var fakeTextContent = new TextContent("llmResult"); var fakeChatContent = new ChatMessageContent(AuthorRole.User, "content"); - mockTextGeneration1.Setup(c => c.GetTextContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeTextContent]); - mockTextGeneration2.Setup(c => c.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeChatContent]); + mockTextGeneration.Setup(c => c.GetTextContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeTextContent]); + mockChatCompletion.Setup(c => c.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeChatContent]); IKernelBuilder builder = Kernel.CreateBuilder(); - builder.Services.AddKeyedSingleton("service1", mockTextGeneration1.Object); - builder.Services.AddKeyedSingleton("service2", mockTextGeneration2.Object); - builder.Services.AddKeyedSingleton("service3", mockTextGeneration1.Object); + builder.Services.AddKeyedSingleton("service1", mockTextGeneration.Object); + builder.Services.AddKeyedSingleton("service2", mockChatCompletion.Object); + builder.Services.AddKeyedSingleton("service3", mockTextGeneration.Object); Kernel kernel = builder.Build(); var func = kernel.CreateFunctionFromPrompt("my prompt", [new PromptExecutionSettings { ServiceId = "service2" }]); @@ -170,8 +169,41 @@ public async Task ItUsesChatServiceIdWhenProvidedInMethodAsync() await kernel.InvokeAsync(func); // Assert - mockTextGeneration1.Verify(a => a.GetTextContentsAsync("my prompt", It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); - mockTextGeneration2.Verify(a => a.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + mockTextGeneration.Verify(a => a.GetTextContentsAsync("my prompt", It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + mockChatCompletion.Verify(a => a.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ItUsesChatClientIdWhenProvidedInMethodAsync() + { + // Arrange + var mockTextGeneration = new Mock(); + var mockChatCompletion = new Mock(); + var mockChatClient = new Mock(); + var fakeTextContent = new TextContent("llmResult"); + var fakeChatContent = new ChatMessageContent(AuthorRole.User, "content"); + var fakeChatResponse = new MEAI.ChatResponse(new MEAI.ChatMessage()); + + mockTextGeneration.Setup(c => c.GetTextContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeTextContent]); + mockChatCompletion.Setup(c => c.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([fakeChatContent]); + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(fakeChatResponse); + mockChatClient.Setup(c => c.GetService(typeof(MEAI.ChatClientMetadata), It.IsAny())).Returns(new MEAI.ChatClientMetadata()); + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddKeyedSingleton("service1", mockTextGeneration.Object); + builder.Services.AddKeyedSingleton("service2", mockChatClient.Object); + builder.Services.AddKeyedSingleton("service3", mockChatCompletion.Object); + Kernel kernel = builder.Build(); + + var func = kernel.CreateFunctionFromPrompt("my prompt", [new PromptExecutionSettings { ServiceId = "service2" }]); + + // Act + await kernel.InvokeAsync(func); + + // Assert + mockTextGeneration.Verify(a => a.GetTextContentsAsync("my prompt", It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + mockChatCompletion.Verify(a => a.GetChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + mockChatClient.Verify(a => a.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once()); } [Fact] @@ -194,7 +226,7 @@ public async Task ItFailsIfInvalidServiceIdIsProvidedAsync() var exception = await Assert.ThrowsAsync(() => kernel.InvokeAsync(func)); // Assert - Assert.Equal("Required service of type Microsoft.SemanticKernel.TextGeneration.ITextGenerationService not registered. Expected serviceIds: service3.", exception.Message); + Assert.Contains("Expected serviceIds: service3.", exception.Message); } [Fact] @@ -219,6 +251,28 @@ public async Task ItParsesStandardizedPromptWhenServiceIsChatCompletionAsync() Assert.Equal("How many 20 cents can I get from 1 dollar?", fakeService.ChatHistory[1].Content); } + [Fact] + public async Task ItParsesStandardizedPromptWhenServiceIsChatClientAsync() + { + using var fakeService = new FakeChatClient(); + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + await kernel.InvokeAsync(function); + + Assert.NotNull(fakeService.ChatMessages); + Assert.Equal(2, fakeService.ChatMessages.Count); + Assert.Equal("You are a helpful assistant.", fakeService.ChatMessages[0].Text); + Assert.Equal("How many 20 cents can I get from 1 dollar?", fakeService.ChatMessages[1].Text); + } + [Fact] public async Task ItParsesStandardizedPromptWhenServiceIsStreamingChatCompletionAsync() { @@ -342,6 +396,76 @@ public async Task InvokeAsyncReturnsTheConnectorChatResultWhenInServiceIsOnlyCha Assert.Equal("something", result.GetValue()!.ToString()); } + [Fact] + public async Task InvokeAsyncReturnsTheConnectorChatResultWhenInServiceIsOnlyChatClientAsync() + { + var customTestType = new CustomTestType(); + var fakeChatMessage = new MEAI.ChatMessage(MEAI.ChatRole.User, "something") { RawRepresentation = customTestType }; + var fakeChatResponse = new MEAI.ChatResponse(fakeChatMessage); + Mock mockChatClient = new(); + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(fakeChatResponse); + mockChatClient.Setup(c => c.GetService(typeof(MEAI.ChatClientMetadata), It.IsAny())).Returns(new MEAI.ChatClientMetadata()); + + using var chatClient = mockChatClient.Object; + KernelBuilder builder = new(); + builder.Services.AddTransient((sp) => chatClient); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt("Anything"); + + var result = await kernel.InvokeAsync(function); + + Assert.Equal("something", result.GetValue()); + Assert.Equal("something", result.GetValue()!.Text); + Assert.Equal(MEAI.ChatRole.User, result.GetValue()!.Role); + Assert.Same(customTestType, result.GetValue()!); + Assert.Equal("something", result.GetValue()!.ToString()); + Assert.Equal("something", result.GetValue()!.ToString()); + } + + [Fact] + public async Task InvokeAsyncReturnsTheConnectorChatResultChoicesWhenInServiceIsOnlyChatClientAsync() + { + var customTestType = new CustomTestType(); + var fakeChatResponse = new MEAI.ChatResponse([ + new MEAI.ChatMessage(MEAI.ChatRole.User, "something 1") { RawRepresentation = customTestType }, + new MEAI.ChatMessage(MEAI.ChatRole.Assistant, "something 2") + ]); + + Mock mockChatClient = new(); + mockChatClient.Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(fakeChatResponse); + mockChatClient.Setup(c => c.GetService(typeof(MEAI.ChatClientMetadata), It.IsAny())).Returns(new MEAI.ChatClientMetadata()); + + using var chatClient = mockChatClient.Object; + KernelBuilder builder = new(); + builder.Services.AddTransient((sp) => chatClient); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt("Anything"); + + var result = await kernel.InvokeAsync(function); + + var response = result.GetValue(); + Assert.NotNull(response); + Assert.Collection(response.Choices, + item1 => + { + Assert.Equal("something 1", item1.Text); Assert.Equal(MEAI.ChatRole.User, item1.Role); + }, + item2 => + { + Assert.Equal("something 2", item2.Text); Assert.Equal(MEAI.ChatRole.Assistant, item2.Role); + }); + + // Other specific types will be checked against the first choice + Assert.Equal("something 1", result.GetValue()); + Assert.Equal("something 1", result.GetValue()!.Text); + Assert.Equal(MEAI.ChatRole.User, result.GetValue()!.Role); + Assert.Same(customTestType, result.GetValue()!); + Assert.Equal("something 1", result.GetValue()!.ToString()); + Assert.Equal("something 1", result.GetValue()!.ToString()); + } + [Fact] public async Task InvokeAsyncReturnsTheConnectorChatResultWhenInServiceIsChatAndTextCompletionAsync() { @@ -947,6 +1071,500 @@ public async Task ItCanBeCloned(JsonSerializerOptions? jsos) Assert.Equal("Prompt with a variable", result); } + [Fact] + public async Task ItCanRetrieveDirectMEAIChatMessageUpdatesAsync() + { + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can " + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?" + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(KernelFunctionFromPrompt.Create("Prompt with {{$A}} variable"))) + { + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex], update); + Assert.Equal(fakeService.GetStreamingResponseResult![updateIndex].Text, update.Text); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItCanRetrieveDirectMEAITextContentAsync() + { + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can " + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?" + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(KernelFunctionFromPrompt.Create("Prompt with {{$A}} variable"))) + { + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex].Contents[0], update); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItCanRetrieveDirectMEAIStringAsync() + { + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can " + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?" + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(KernelFunctionFromPrompt.Create("Prompt with {{$A}} variable"))) + { + Assert.Equal(fakeService.GetStreamingResponseResult![updateIndex].Text, update); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItCanRetrieveDirectMEAIRawRepresentationAsync() + { + var rawRepresentation = OpenAI.Chat.OpenAIChatModelFactory.StreamingChatCompletionUpdate(contentUpdate: new OpenAI.Chat.ChatMessageContent("Hi!")); + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can ", + RawRepresentation = rawRepresentation + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?", + RawRepresentation = rawRepresentation + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(KernelFunctionFromPrompt.Create("Prompt with {{$A}} variable"))) + { + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex].RawRepresentation, update); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItCanRetrieveDirectMEAIContentListAsync() + { + var rawRepresentation = OpenAI.Chat.OpenAIChatModelFactory.StreamingChatCompletionUpdate(contentUpdate: new OpenAI.Chat.ChatMessageContent("Hi!")); + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can ", + RawRepresentation = rawRepresentation + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?", + RawRepresentation = rawRepresentation + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync>(KernelFunctionFromPrompt.Create("Prompt with {{$A}} variable"))) + { + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex].Contents, update); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingChatMessageContentAsync() + { + var rawRepresentation = new { test = "a" }; + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can ", + RawRepresentation = rawRepresentation + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?", + RawRepresentation = rawRepresentation + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Equal(fakeService.GetStreamingResponseResult![updateIndex].Text, update.Content); + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex].RawRepresentation, update.InnerContent); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingContentAsync() + { + var rawRepresentation = new { test = "a" }; + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can ", + RawRepresentation = rawRepresentation + }, + new MEAI.ChatResponseUpdate + { + Text = "I assist you today?", + RawRepresentation = rawRepresentation + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + var streamingChatContent = Assert.IsType(update); + Assert.Same(fakeService.GetStreamingResponseResult![updateIndex].RawRepresentation, update.InnerContent); + + Assert.Equal(fakeService.GetStreamingResponseResult![updateIndex].Text, streamingChatContent.Content); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingResponseResult.Count); + } + + [Fact] + public async Task ItConvertsFromSKStreamingChatMessageContentToMEAIChatResponseUpdate() + { + var innerContent = new { test = "a" }; + var fakeService = new FakeChatCompletionService() + { + GetStreamingChatMessageContentsResult = [ + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") + { + InnerContent = innerContent + }, + new StreamingChatMessageContent(null, "I assist you today?") + { + InnerContent = innerContent + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Same(fakeService.GetStreamingChatMessageContentsResult![updateIndex].InnerContent, update.RawRepresentation); + + Assert.Equal(fakeService.GetStreamingChatMessageContentsResult![updateIndex].Content, update.Text); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingChatMessageContentsResult.Count); + } + + [Fact] + public async Task ItConvertsFromSKStreamingChatMessageContentToStringAsync() + { + var innerContent = new { test = "a" }; + var fakeService = new FakeChatCompletionService() + { + GetStreamingChatMessageContentsResult = [ + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") + { + InnerContent = innerContent + }, + new StreamingChatMessageContent(null, "I assist you today?") + { + InnerContent = innerContent + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Equal(fakeService.GetStreamingChatMessageContentsResult![updateIndex].Content, update); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingChatMessageContentsResult.Count); + } + + [Fact] + public async Task ItConvertsFromSKStreamingChatMessageContentToItselfAsync() + { + var innerContent = new { test = "a" }; + var fakeService = new FakeChatCompletionService() + { + GetStreamingChatMessageContentsResult = [ + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") + { + InnerContent = innerContent + }, + new StreamingChatMessageContent(null, "I assist you today?") + { + InnerContent = innerContent + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Same(fakeService.GetStreamingChatMessageContentsResult![updateIndex], update); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingChatMessageContentsResult.Count); + } + + [Fact] + public async Task ItConvertsFromSKStreamingChatMessageContentToInnerContentAsync() + { + var innerContent = new Random(); + var fakeService = new FakeChatCompletionService() + { + GetStreamingChatMessageContentsResult = [ + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") + { + InnerContent = innerContent + }, + new StreamingChatMessageContent(null, "I assist you today?") + { + InnerContent = innerContent + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Same(fakeService.GetStreamingChatMessageContentsResult![updateIndex].InnerContent, update); + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingChatMessageContentsResult.Count); + } + + [Fact] + public async Task ItConvertsFromSKStreamingChatMessageContentToBytesAsync() + { + var innerContent = new Random(); + var fakeService = new FakeChatCompletionService() + { + GetStreamingChatMessageContentsResult = [ + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") + { + InnerContent = innerContent + }, + new StreamingChatMessageContent(null, "I assist you today?") + { + InnerContent = innerContent + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + var updateIndex = 0; + await foreach (var update in kernel.InvokeStreamingAsync(function)) + { + Assert.Equal(fakeService.GetStreamingChatMessageContentsResult![updateIndex].Content, + fakeService.GetStreamingChatMessageContentsResult![updateIndex].Encoding.GetString(update)); + + updateIndex++; + } + + Assert.Equal(updateIndex, fakeService.GetStreamingChatMessageContentsResult.Count); + } + + /// + /// This scenario covers scenarios on attempting to get a ChatResponseUpdate from a ITextGenerationService. + /// + [Fact] + public async Task ItThrowsConvertingFromNonChatSKStreamingContentToMEAIChatResponseUpdate() + { + var fakeService = new FakeTextGenerationService() + { + GetStreamingTextContentsResult = [new StreamingTextContent("Hi!")] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt("How many 20 cents can I get from 1 dollar?"); + + // Act + Assert + await Assert.ThrowsAsync( + () => kernel.InvokeStreamingAsync(function).GetAsyncEnumerator().MoveNextAsync().AsTask()); + } + + [Fact] + public async Task ItThrowsWhenConvertingFromMEAIChatMessageUpdateWithNoDataContentToBytesAsync() + { + using var fakeService = new FakeChatClient() + { + GetStreamingResponseResult = [ + new MEAI.ChatResponseUpdate + { + Role = MEAI.ChatRole.Assistant, + Text = "Hi! How can ", + }] + }; + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => fakeService); + Kernel kernel = builder.Build(); + + KernelFunction function = KernelFunctionFactory.CreateFromPrompt(""" + You are a helpful assistant. + How many 20 cents can I get from 1 dollar? + """); + + // Act + Assert + await Assert.ThrowsAsync( + () => kernel.InvokeStreamingAsync(function).GetAsyncEnumerator().MoveNextAsync().AsTask()); + } + public enum KernelInvocationType { InvokePrompt, @@ -990,6 +1608,93 @@ public Task> GetTextContentsAsync(string prompt, Prom } } + private sealed class FakeChatCompletionService : IChatCompletionService + { + public IReadOnlyDictionary Attributes => throw new NotImplementedException(); + + public IList? GetStreamingChatMessageContentsResult { get; set; } + + public Task> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in this.GetStreamingChatMessageContentsResult ?? [new StreamingChatMessageContent(AuthorRole.Assistant, "Something")]) + { + yield return item; + } + } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + } + + private sealed class FakeTextGenerationService : ITextGenerationService + { + public IReadOnlyDictionary Attributes => throw new NotImplementedException(); + + public IList? GetStreamingTextContentsResult { get; set; } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetStreamingTextContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in this.GetStreamingTextContentsResult ?? [new StreamingTextContent("Something")]) + { + yield return item; + } + } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private sealed class FakeChatClient : MEAI.IChatClient + { + public IList? ChatMessages { get; private set; } + public IList? GetStreamingResponseResult { get; set; } + + public void Dispose() + { + } + + public Task GetResponseAsync(IList chatMessages, MEAI.ChatOptions? options = null, CancellationToken cancellationToken = default) + { + this.ChatMessages = chatMessages; + return Task.FromResult(new MEAI.ChatResponse(new MEAI.ChatMessage(MEAI.ChatRole.Assistant, "Something"))); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return new MEAI.ChatClientMetadata(); + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async IAsyncEnumerable GetStreamingResponseAsync( + IList chatMessages, + MEAI.ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.ChatMessages = chatMessages; + foreach (var item in this.GetStreamingResponseResult ?? [new MEAI.ChatResponseUpdate { Role = MEAI.ChatRole.Assistant, Text = "Something" }]) + { + yield return item; + } + } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + } + private Mock GetMockTextGenerationService(IReadOnlyList? textContents = null) { var mockTextGenerationService = new Mock(); @@ -1012,5 +1717,9 @@ private Mock GetMockChatCompletionService(IReadOnlyList< return mockChatCompletionService; } + private sealed class CustomTestType + { + public string Name { get; set; } = "MyCustomType"; + } #endregion } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/MultipleModelTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/MultipleModelTests.cs index 40121103ce69..1c585726f82d 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/MultipleModelTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/MultipleModelTests.cs @@ -60,7 +60,7 @@ public async Task ItFailsIfInvalidServiceIdIsProvidedAsync() var exception = await Assert.ThrowsAsync(() => kernel.InvokeAsync(func)); // Assert - Assert.Equal("Required service of type Microsoft.SemanticKernel.TextGeneration.ITextGenerationService not registered. Expected serviceIds: service3.", exception.Message); + Assert.Contains("Expected serviceIds: service3.", exception.Message); } [Theory] diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs index eafac8ac5ca3..b31a98c3f1f3 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs @@ -4,10 +4,13 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Services; using Microsoft.SemanticKernel.TextGeneration; +using Moq; using Xunit; namespace SemanticKernel.UnitTests.Functions; @@ -25,6 +28,7 @@ public void ItThrowsAKernelExceptionForNoServices() // Act // Assert Assert.Throws(() => serviceSelector.SelectAIService(kernel, function, [])); + Assert.Throws(() => serviceSelector.SelectAIService(kernel, function, [])); } [Fact] @@ -46,6 +50,27 @@ public void ItGetsAIServiceConfigurationForSingleAIService() Assert.Null(defaultExecutionSettings); } + [Fact] + public void ItGetsChatClientConfigurationForSingleChatClient() + { + // Arrange + var mockChat = new Mock(); + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddKeyedSingleton("chat1", mockChat.Object); + Kernel kernel = builder.Build(); + + var function = kernel.CreateFunctionFromPrompt("Hello AI"); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var chatClient, out var defaultExecutionSettings); + chatClient?.Dispose(); + + // Assert + Assert.NotNull(chatClient); + Assert.Null(defaultExecutionSettings); + } + [Fact] public void ItGetsAIServiceConfigurationForSingleTextGeneration() { @@ -90,13 +115,41 @@ public void ItGetsAIServiceConfigurationForTextGenerationByServiceId() Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); } + [Fact] + public void ItGetsChatClientConfigurationForChatClientByServiceId() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model_id_1"); + using var chatClient2 = new ChatClient("model_id_2"); + builder.Services.AddKeyedSingleton("chat1", chatClient1); + builder.Services.AddKeyedSingleton("chat2", chatClient2); + Kernel kernel = builder.Build(); + + var promptConfig = new PromptTemplateConfig() { Template = "Hello AI" }; + var executionSettings = new PromptExecutionSettings(); + promptConfig.AddExecutionSettings(executionSettings, "chat2"); + var function = kernel.CreateFunctionFromPrompt(promptConfig); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.Equal(kernel.GetRequiredService("chat2"), aiService); + var expectedExecutionSettings = executionSettings.Clone(); + expectedExecutionSettings.Freeze(); + Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); + } + [Fact] public void ItThrowsAKernelExceptionForNotFoundService() { // Arrange IKernelBuilder builder = Kernel.CreateBuilder(); builder.Services.AddKeyedSingleton("service1", new TextGenerationService("model_id_1")); - builder.Services.AddKeyedSingleton("service2", new TextGenerationService("model_id_2")); + builder.Services.AddKeyedSingleton("service2", new ChatCompletionService("model_id_2")); Kernel kernel = builder.Build(); var promptConfig = new PromptTemplateConfig() { Template = "Hello AI" }; @@ -107,6 +160,7 @@ public void ItThrowsAKernelExceptionForNotFoundService() // Act // Assert Assert.Throws(() => serviceSelector.SelectAIService(kernel, function, [])); + Assert.Throws(() => serviceSelector.SelectAIService(kernel, function, [])); } [Fact] @@ -129,6 +183,30 @@ public void ItGetsDefaultServiceForNotFoundModel() Assert.Equal(kernel.GetRequiredService("service2"), aiService); } + [Fact] + public void ItGetsDefaultChatClientForNotFoundModel() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model_id_1"); + using var chatClient2 = new ChatClient("model_id_2"); + builder.Services.AddKeyedSingleton("chat1", chatClient1); + builder.Services.AddKeyedSingleton("chat2", chatClient2); + Kernel kernel = builder.Build(); + + var promptConfig = new PromptTemplateConfig() { Template = "Hello AI" }; + promptConfig.AddExecutionSettings(new PromptExecutionSettings { ModelId = "notfound" }); + var function = kernel.CreateFunctionFromPrompt(promptConfig); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + // Assert + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + Assert.Equal(kernel.GetRequiredService("chat2"), aiService); + } + [Fact] public void ItUsesDefaultServiceForNoExecutionSettings() { @@ -148,6 +226,28 @@ public void ItUsesDefaultServiceForNoExecutionSettings() Assert.Null(defaultExecutionSettings); } + [Fact] + public void ItUsesDefaultChatClientForNoExecutionSettings() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model_id_1"); + using var chatClient2 = new ChatClient("model_id_2"); + builder.Services.AddKeyedSingleton("chat1", chatClient1); + builder.Services.AddKeyedSingleton("chat2", chatClient2); + Kernel kernel = builder.Build(); + var function = kernel.CreateFunctionFromPrompt("Hello AI"); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.Equal(kernel.GetRequiredService("chat2"), aiService); + Assert.Null(defaultExecutionSettings); + } + [Fact] public void ItUsesDefaultServiceAndSettingsForDefaultExecutionSettings() { @@ -171,6 +271,32 @@ public void ItUsesDefaultServiceAndSettingsForDefaultExecutionSettings() Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); } + [Fact] + public void ItUsesDefaultChatClientAndSettingsForDefaultExecutionSettings() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model_id_1"); + using var chatClient2 = new ChatClient("model_id_2"); + builder.Services.AddKeyedSingleton("chat1", chatClient1); + builder.Services.AddKeyedSingleton("chat2", chatClient2); + Kernel kernel = builder.Build(); + + var executionSettings = new PromptExecutionSettings(); + var function = kernel.CreateFunctionFromPrompt("Hello AI", executionSettings); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.Equal(kernel.GetRequiredService("chat2"), aiService); + var expectedExecutionSettings = executionSettings.Clone(); + expectedExecutionSettings.Freeze(); + Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); + } + [Fact] public void ItUsesDefaultServiceAndSettingsForDefaultId() { @@ -194,6 +320,32 @@ public void ItUsesDefaultServiceAndSettingsForDefaultId() Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); } + [Fact] + public void ItUsesDefaultChatClientAndSettingsForDefaultId() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model_id_1"); + using var chatClient2 = new ChatClient("model_id_2"); + builder.Services.AddKeyedSingleton("chat1", chatClient1); + builder.Services.AddKeyedSingleton("chat2", chatClient2); + Kernel kernel = builder.Build(); + + var executionSettings = new PromptExecutionSettings(); + var function = kernel.CreateFunctionFromPrompt("Hello AI", executionSettings); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.Equal(kernel.GetRequiredService("chat2"), aiService); + var expectedExecutionSettings = executionSettings.Clone(); + expectedExecutionSettings.Freeze(); + Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); + } + [Theory] [InlineData(new string[] { "modelid_1" }, "modelid_1")] [InlineData(new string[] { "modelid_2" }, "modelid_2")] @@ -228,6 +380,44 @@ public void ItGetsAIServiceConfigurationByOrder(string[] serviceIds, string expe } } + [Theory] + [InlineData(new string[] { "modelid_1" }, "modelid_1")] + [InlineData(new string[] { "modelid_2" }, "modelid_2")] + [InlineData(new string[] { "modelid_3" }, "modelid_3")] + [InlineData(new string[] { "modelid_4", "modelid_1" }, "modelid_1")] + [InlineData(new string[] { "modelid_4", "" }, "modelid_3")] + public void ItGetsChatClientConfigurationByOrder(string[] serviceIds, string expectedModelId) + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("modelid_1"); + using var chatClient2 = new ChatClient("modelid_2"); + using var chatClient3 = new ChatClient("modelid_3"); + builder.Services.AddKeyedSingleton("modelid_1", chatClient1); + builder.Services.AddKeyedSingleton("modelid_2", chatClient2); + builder.Services.AddKeyedSingleton("modelid_3", chatClient3); + Kernel kernel = builder.Build(); + + var executionSettings = new Dictionary(); + foreach (var serviceId in serviceIds) + { + executionSettings.Add(serviceId, new PromptExecutionSettings() { ModelId = serviceId }); + } + var function = kernel.CreateFunctionFromPrompt(promptConfig: new PromptTemplateConfig() { Template = "Hello AI", ExecutionSettings = executionSettings }); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, [], out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.Equal(kernel.GetRequiredService(expectedModelId), aiService); + if (!string.IsNullOrEmpty(defaultExecutionSettings!.ModelId)) + { + Assert.Equal(expectedModelId, defaultExecutionSettings!.ModelId); + } + } + [Fact] public void ItGetsAIServiceConfigurationForTextGenerationByModelId() { @@ -253,6 +443,34 @@ public void ItGetsAIServiceConfigurationForTextGenerationByModelId() Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); } + [Fact] + public void ItGetsChatClientConfigurationForChatClientByModelId() + { + // Arrange + IKernelBuilder builder = Kernel.CreateBuilder(); + using var chatClient1 = new ChatClient("model1"); + using var chatClient2 = new ChatClient("model2"); + builder.Services.AddKeyedSingleton(null, chatClient1); + builder.Services.AddKeyedSingleton(null, chatClient2); + Kernel kernel = builder.Build(); + + var arguments = new KernelArguments(); + var executionSettings = new PromptExecutionSettings() { ModelId = "model2" }; + var function = kernel.CreateFunctionFromPrompt("Hello AI", executionSettings: executionSettings); + var serviceSelector = new OrderedAIServiceSelector(); + + // Act + serviceSelector.TrySelectChatClient(kernel, function, arguments, out var aiService, out var defaultExecutionSettings); + aiService?.Dispose(); + + // Assert + Assert.NotNull(aiService); + Assert.Equal("model2", aiService.GetModelId()); + var expectedExecutionSettings = executionSettings.Clone(); + expectedExecutionSettings.Freeze(); + Assert.Equivalent(expectedExecutionSettings, defaultExecutionSettings); + } + #region private private sealed class AIService : IAIService { @@ -270,7 +488,7 @@ public TextGenerationService(string modelId) this._attributes.Add("ModelId", modelId); } - public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } @@ -280,5 +498,73 @@ public IAsyncEnumerable GetStreamingTextContentsAsync(stri throw new NotImplementedException(); } } + + private sealed class ChatCompletionService : IChatCompletionService + { + public IReadOnlyDictionary Attributes => this._attributes; + + private readonly Dictionary _attributes = []; + + public ChatCompletionService(string modelId) + { + this._attributes.Add("ModelId", modelId); + } + + public Task> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetStreamingChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private sealed class ChatClient : IChatClient + { + public ChatClientMetadata Metadata { get; } + + public ChatClient(string modelId) + { + this.Metadata = new ChatClientMetadata(modelId: modelId); + } + + public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetStreamingTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + Verify.NotNull(serviceType); + + return + serviceKey is not null ? null : + serviceType.IsInstanceOfType(this) ? this : + serviceType.IsInstanceOfType(this.Metadata) ? this.Metadata : + null; + } + + public void Dispose() + { + } + } #endregion } diff --git a/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs b/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs index b7ed4fc4a480..ccb96a28466c 100644 --- a/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs @@ -17,6 +17,7 @@ using Microsoft.SemanticKernel.TextGeneration; using Moq; using Xunit; +using MEAI = Microsoft.Extensions.AI; #pragma warning disable CS0618 // Events are deprecated @@ -257,6 +258,110 @@ public async Task InvokeStreamingAsyncCallsConnectorStreamingApiAsync() mockTextCompletion.Verify(m => m.GetStreamingTextContentsAsync(It.IsIn("Write a simple phrase about UnitTests importance"), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); } + [Fact] + public async Task InvokeStreamingAsyncCallsWithMEAIContentsAndChatCompletionApiAsync() + { + // Arrange + var mockChatCompletion = this.SetupStreamingChatCompletionMocks( + new StreamingChatMessageContent(AuthorRole.User, "chunk1"), + new StreamingChatMessageContent(AuthorRole.User, "chunk2")); + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(mockChatCompletion.Object); + Kernel kernel = builder.Build(); + var prompt = "Write a simple phrase about UnitTests {{$input}}"; + var expectedPrompt = prompt.Replace("{{$input}}", "importance"); + var sut = KernelFunctionFactory.CreateFromPrompt(prompt); + var variables = new KernelArguments() { [InputParameterName] = "importance" }; + + var chunkCount = 0; + + // Act & Assert + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk.Text); + chunkCount++; + } + + Assert.Equal(2, chunkCount); + mockChatCompletion.Verify(m => m.GetStreamingChatMessageContentsAsync(It.Is((m) => m[0].Content == expectedPrompt), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Fact] + public async Task InvokeStreamingAsyncGenericPermutationsCallsConnectorChatClientAsync() + { + // Arrange + var customRawItem = new MEAI.ChatOptions(); + var mockChatClient = this.SetupStreamingChatClientMock( + new MEAI.ChatResponseUpdate() { Text = "chunk1", RawRepresentation = customRawItem }, + new MEAI.ChatResponseUpdate() { Text = "chunk2", RawRepresentation = customRawItem }); + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(mockChatClient.Object); + Kernel kernel = builder.Build(); + var prompt = "Write a simple phrase about UnitTests {{$input}}"; + var expectedPrompt = prompt.Replace("{{$input}}", "importance"); + var sut = KernelFunctionFactory.CreateFromPrompt(prompt); + var variables = new KernelArguments() { [InputParameterName] = "importance" }; + + var totalChunksExpected = 0; + var totalInvocationTimesExpected = 0; + + // Act & Assert + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk); + totalChunksExpected++; + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + totalChunksExpected++; + Assert.Same(customRawItem, chunk.InnerContent); + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk.Content); + Assert.Same(customRawItem, chunk.InnerContent); + totalChunksExpected++; + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk.Text); + Assert.Same(customRawItem, chunk.RawRepresentation); + totalChunksExpected++; + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk.ToString()); + totalChunksExpected++; + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync>(kernel, variables)) + { + Assert.Contains("chunk", chunk[0].ToString()); + totalChunksExpected++; + } + + totalInvocationTimesExpected++; + await foreach (var chunk in sut.InvokeStreamingAsync(kernel, variables)) + { + Assert.Contains("chunk", chunk.Text); + totalChunksExpected++; + } + + Assert.Equal(totalInvocationTimesExpected * 2, totalChunksExpected); + mockChatClient.Verify(m => m.GetStreamingResponseAsync(It.Is>((m) => m[0].Text == expectedPrompt), It.IsAny(), It.IsAny()), Times.Exactly(totalInvocationTimesExpected)); + } + [Fact] public async Task ValidateInvokeAsync() { @@ -316,6 +421,13 @@ public async IAsyncEnumerable GetStreamingChatMessa return (mockTextContent, mockTextCompletion); } + private Mock SetupStreamingChatCompletionMocks(params StreamingChatMessageContent[] streamingContents) + { + var mockChatCompletion = new Mock(); + mockChatCompletion.Setup(m => m.GetStreamingChatMessageContentsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(streamingContents.ToAsyncEnumerable()); + return mockChatCompletion; + } + private Mock SetupStreamingMocks(params StreamingTextContent[] streamingContents) { var mockTextCompletion = new Mock(); @@ -324,6 +436,15 @@ private Mock SetupStreamingMocks(params StreamingTextCon return mockTextCompletion; } + private Mock SetupStreamingChatClientMock(params MEAI.ChatResponseUpdate[] chatResponseUpdates) + { + var mockChatClient = new Mock(); + mockChatClient.Setup(m => m.GetStreamingResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())).Returns(chatResponseUpdates.ToAsyncEnumerable()); + mockChatClient.Setup(c => c.GetService(typeof(MEAI.ChatClientMetadata), It.IsAny())).Returns(new MEAI.ChatClientMetadata()); + + return mockChatClient; + } + private void AssertFilters(Kernel kernel1, Kernel kernel2) { var functionFilters1 = kernel1.GetAllServices().ToArray(); From dd0472702dae530526b9c34d4ecb2ee85591204a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:17:36 +0000 Subject: [PATCH 02/22] Adjusting conflicts and adapting to new ChatClient.Messages --- .../OpenAI/OpenAIChatCompletionTests.cs | 2 +- .../AI/ChatClient/ChatClientAIService.cs | 8 +- .../AI/ChatClient/ChatClientExtensions.cs | 2 +- .../AI/ChatClient/ChatMessageExtensions.cs | 46 +--- .../ChatResponseUpdateExtensions.cs | 1 - .../ChatClientChatCompletionService.cs | 222 ------------------ .../ChatCompletionServiceChatClient.cs | 43 +--- .../ChatCompletionServiceExtensions.cs | 124 ---------- .../Contents/ChatMessageContentExtensions.cs | 10 +- .../StreamingChatMessageContentExtensions.cs | 1 - .../Functions/FunctionResult.cs | 11 +- .../Functions/KernelFunctionFromPrompt.cs | 6 +- .../CustomAIChatClientSelectorTests.cs | 4 +- .../Functions/FunctionResultTests.cs | 51 ++-- .../KernelFunctionFromPromptTests.cs | 195 +++++---------- .../OrderedAIServiceSelectorTests.cs | 4 +- .../SemanticKernel.UnitTests/KernelTests.cs | 4 +- 17 files changed, 133 insertions(+), 601 deletions(-) diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs index 1e1b58133c83..fe8ff155d9c5 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs @@ -72,7 +72,7 @@ public async Task ItCanUseOpenAiChatClientAndContentsAsync() Assert.Contains("Saturn", result.GetValue(), StringComparison.InvariantCultureIgnoreCase); Assert.Contains("Uranus", result.GetValue(), StringComparison.InvariantCultureIgnoreCase); var chatResponse = Assert.IsType(result.GetValue()); - Assert.Contains("Saturn", chatResponse.Message.Text, StringComparison.InvariantCultureIgnoreCase); + Assert.Contains("Saturn", chatResponse.Text, StringComparison.InvariantCultureIgnoreCase); var chatMessage = Assert.IsType(result.GetValue()); Assert.Contains("Uranus", chatMessage.Text, StringComparison.InvariantCultureIgnoreCase); var chatMessageContent = Assert.IsType(result.GetValue()); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs index af8217d5e1fa..949f516f0592 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs @@ -47,14 +47,14 @@ public void Dispose() } /// - public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => this._chatClient.GetResponseAsync(chatMessages, options, cancellationToken); + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._chatClient.GetResponseAsync(messages, options, cancellationToken); /// public object? GetService(Type serviceType, object? serviceKey = null) => this._chatClient.GetService(serviceType, serviceKey); /// - public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => this._chatClient.GetStreamingResponseAsync(chatMessages, options, cancellationToken); + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => this._chatClient.GetStreamingResponseAsync(messages, options, cancellationToken); } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs index 92bf6b9db105..281399f58438 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs @@ -27,7 +27,7 @@ internal static Task GetResponseAsync( Kernel? kernel = null, CancellationToken cancellationToken = default) { - var chatOptions = executionSettings.ToChatOptions(kernel); + var chatOptions = executionSettings?.ToChatOptions(kernel); // Try to parse the text as a chat history if (ChatPromptParser.TryParse(prompt, out var chatHistoryFromPrompt)) diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs index 24117a28b2ee..a7aa4e160552 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs @@ -10,7 +10,7 @@ internal static class ChatMessageExtensions { /// Converts a to a . /// This conversion should not be necessary once SK eventually adopts the shared content types. - internal static ChatMessageContent ToChatMessageContent(this ChatMessage message, ChatResponse? response = null) + internal static ChatMessageContent ToChatMessageContent(this ChatMessage message, Microsoft.Extensions.AI.ChatResponse? response = null) { ChatMessageContent result = new() { @@ -23,39 +23,19 @@ internal static ChatMessageContent ToChatMessageContent(this ChatMessage message foreach (AIContent content in message.Contents) { - KernelContent? resultContent = null; - switch (content) + KernelContent? resultContent = content switch { - case Microsoft.Extensions.AI.TextContent tc: - resultContent = new Microsoft.SemanticKernel.TextContent(tc.Text); - break; - - case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("image/"): - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.ImageContent(dc.Uri) : - new Microsoft.SemanticKernel.ImageContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.DataContent dc when dc.MediaTypeStartsWith("audio/"): - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.AudioContent(dc.Uri) : - new Microsoft.SemanticKernel.AudioContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.DataContent dc: - resultContent = dc.Data is not null ? - new Microsoft.SemanticKernel.BinaryContent(dc.Uri) : - new Microsoft.SemanticKernel.BinaryContent(new Uri(dc.Uri)); - break; - - case Microsoft.Extensions.AI.FunctionCallContent fcc: - resultContent = new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null); - break; - - case Microsoft.Extensions.AI.FunctionResultContent frc: - resultContent = new Microsoft.SemanticKernel.FunctionResultContent(callId: frc.CallId, result: frc.Result); - break; - } + Microsoft.Extensions.AI.TextContent tc => new Microsoft.SemanticKernel.TextContent(tc.Text), + Microsoft.Extensions.AI.DataContent dc when dc.HasTopLevelMediaType("image") => new Microsoft.SemanticKernel.ImageContent(dc.Uri), + Microsoft.Extensions.AI.UriContent uc when uc.HasTopLevelMediaType("image") => new Microsoft.SemanticKernel.ImageContent(uc.Uri), + Microsoft.Extensions.AI.DataContent dc when dc.HasTopLevelMediaType("audio") => new Microsoft.SemanticKernel.AudioContent(dc.Uri), + Microsoft.Extensions.AI.UriContent uc when uc.HasTopLevelMediaType("audio") => new Microsoft.SemanticKernel.AudioContent(uc.Uri), + Microsoft.Extensions.AI.DataContent dc => new Microsoft.SemanticKernel.BinaryContent(dc.Uri), + Microsoft.Extensions.AI.UriContent uc => new Microsoft.SemanticKernel.BinaryContent(uc.Uri), + Microsoft.Extensions.AI.FunctionCallContent fcc => new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null), + Microsoft.Extensions.AI.FunctionResultContent frc => new Microsoft.SemanticKernel.FunctionResultContent(callId: frc.CallId, result: frc.Result), + _ => null + }; if (resultContent is not null) { diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs index da505c4d131b..8ec9698484b0 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatResponseUpdateExtensions.cs @@ -18,7 +18,6 @@ internal static StreamingChatMessageContent ToStreamingChatMessageContent(this C null) { InnerContent = update.RawRepresentation, - ChoiceIndex = update.ChoiceIndex, Metadata = update.AdditionalProperties, ModelId = update.ModelId }; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs index d177afcbc107..b7ed711be65e 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs @@ -97,226 +97,4 @@ public async IAsyncEnumerable GetStreamingChatMessa // Add function call content/results to chat history, as other IChatCompletionService streaming implementations do. chatHistory.Add(new ChatMessage(role ?? ChatRole.Assistant, fcContents).ToChatMessageContent()); } - - /// Converts a pair of and to a . - private static ChatOptions? ToChatOptions(PromptExecutionSettings? settings, Kernel? kernel) - { - if (settings is null) - { - return null; - } - - if (settings.GetType() != typeof(PromptExecutionSettings)) - { - // If the settings are of a derived type, roundtrip through JSON to the base type in order to try - // to get the derived strongly-typed properties to show up in the loosely-typed ExtensionData dictionary. - // This has the unfortunate effect of making all the ExtensionData values into JsonElements, so we lose - // some type fidelity. (As an alternative, we could introduce new interfaces that could be queried for - // in this method and implemented by the derived settings types to control how they're converted to - // ChatOptions.) - settings = JsonSerializer.Deserialize( - JsonSerializer.Serialize(settings, AbstractionsJsonContext.GetTypeInfo(settings.GetType(), null)), - AbstractionsJsonContext.Default.PromptExecutionSettings); - } - - ChatOptions options = new() - { - ModelId = settings!.ModelId - }; - - if (settings!.ExtensionData is IDictionary extensionData) - { - foreach (var entry in extensionData) - { - if (entry.Key.Equals("temperature", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float temperature)) - { - options.Temperature = temperature; - } - else if (entry.Key.Equals("top_p", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float topP)) - { - options.TopP = topP; - } - else if (entry.Key.Equals("top_k", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out int topK)) - { - options.TopK = topK; - } - else if (entry.Key.Equals("seed", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out long seed)) - { - options.Seed = seed; - } - else if (entry.Key.Equals("max_tokens", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out int maxTokens)) - { - options.MaxOutputTokens = maxTokens; - } - else if (entry.Key.Equals("frequency_penalty", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float frequencyPenalty)) - { - options.FrequencyPenalty = frequencyPenalty; - } - else if (entry.Key.Equals("presence_penalty", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out float presencePenalty)) - { - options.PresencePenalty = presencePenalty; - } - else if (entry.Key.Equals("stop_sequences", StringComparison.OrdinalIgnoreCase) && - TryConvert(entry.Value, out IList? stopSequences)) - { - options.StopSequences = stopSequences; - } - else if (entry.Key.Equals("response_format", StringComparison.OrdinalIgnoreCase) && - entry.Value is { } responseFormat) - { - if (TryConvert(responseFormat, out string? responseFormatString)) - { - options.ResponseFormat = responseFormatString switch - { - "text" => ChatResponseFormat.Text, - "json_object" => ChatResponseFormat.Json, - _ => null, - }; - } - else - { - options.ResponseFormat = responseFormat is JsonElement e ? ChatResponseFormat.ForJsonSchema(e) : null; - } - } - else - { - // Roundtripping a derived PromptExecutionSettings through the base type will have put all the - // object values in AdditionalProperties into JsonElements. Convert them back where possible. - object? value = entry.Value; - if (value is JsonElement jsonElement) - { - value = jsonElement.ValueKind switch - { - JsonValueKind.String => jsonElement.GetString(), - JsonValueKind.Number => jsonElement.GetDouble(), // not perfect, but a reasonable heuristic - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => value, - }; - - if (jsonElement.ValueKind == JsonValueKind.Array) - { - var enumerator = jsonElement.EnumerateArray(); - - var enumeratorType = enumerator.MoveNext() ? enumerator.Current.ValueKind : JsonValueKind.Null; - - switch (enumeratorType) - { - case JsonValueKind.String: - value = enumerator.Select(e => e.GetString()); - break; - case JsonValueKind.Number: - value = enumerator.Select(e => e.GetDouble()); - break; - case JsonValueKind.True or JsonValueKind.False: - value = enumerator.Select(e => e.ValueKind == JsonValueKind.True); - break; - } - } - } - - (options.AdditionalProperties ??= [])[entry.Key] = value; - } - } - } - - if (settings.FunctionChoiceBehavior?.GetConfiguration(new([]) { Kernel = kernel }).Functions is { Count: > 0 } functions) - { - options.ToolMode = settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior ? ChatToolMode.RequireAny : ChatToolMode.Auto; - options.Tools = functions.Select(f => f.AsAIFunction(kernel)).Cast().ToList(); - } - - return options; - - // Be a little lenient on the types of the values used in the extension data, - // e.g. allow doubles even when requesting floats. - static bool TryConvert(object? value, [NotNullWhen(true)] out T? result) - { - if (value is not null) - { - // If the value is a T, use it. - if (value is T typedValue) - { - result = typedValue; - return true; - } - - if (value is JsonElement json) - { - // If the value is JsonElement, it likely resulted from JSON serializing as object. - // Try to deserialize it as a T. This currently will only be successful either when - // reflection-based serialization is enabled or T is one of the types special-cased - // in the AbstractionsJsonContext. For other cases with NativeAOT, we would need to - // have a JsonSerializationOptions with the relevant type information. - if (AbstractionsJsonContext.TryGetTypeInfo(typeof(T), firstOptions: null, out JsonTypeInfo? jti)) - { - try - { - result = (T)json.Deserialize(jti)!; - return true; - } - catch (Exception e) when (e is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) - { - } - } - } - else - { - // Otherwise, try to convert it to a T using Convert, in particular to handle conversions between numeric primitive types. - try - { - result = (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); - return true; - } - catch (Exception e) when (e is ArgumentException or FormatException or InvalidCastException or OverflowException) - { - } - } - } - - result = default; - return false; - } - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - private static StreamingChatMessageContent ToStreamingChatMessageContent(ChatResponseUpdate update) - { - StreamingChatMessageContent content = new( - update.Role is not null ? new AuthorRole(update.Role.Value.Value) : null, - null) - { - InnerContent = update.RawRepresentation, - Metadata = update.AdditionalProperties, - ModelId = update.ModelId - }; - - foreach (AIContent item in update.Contents) - { - StreamingKernelContent? resultContent = - item is Microsoft.Extensions.AI.TextContent tc ? new Microsoft.SemanticKernel.StreamingTextContent(tc.Text) : - item is Microsoft.Extensions.AI.FunctionCallContent fcc ? - new Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent(fcc.CallId, fcc.Name, fcc.Arguments is not null ? - JsonSerializer.Serialize(fcc.Arguments!, AbstractionsJsonContext.Default.IDictionaryStringObject!) : - null) : - null; - - if (resultContent is not null) - { - resultContent.ModelId = update.ModelId; - content.Items.Add(resultContent); - } - } - - return content; - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs index 6a51672b4a50..4fe963a49363 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs @@ -72,7 +72,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync(IEnu Verify.NotNull(messages); await foreach (var update in this._chatCompletionService.GetStreamingChatMessageContentsAsync( - new ChatHistory(chatMessages.Select(m => m.ToChatMessageContent())), + new ChatHistory(messages.Select(m => m.ToChatMessageContent())), ToPromptExecutionSettings(options), kernel: null, cancellationToken).ConfigureAwait(false)) @@ -205,45 +205,4 @@ public void Dispose() return settings; } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - private static ChatResponseUpdate ToStreamingChatCompletionUpdate(StreamingChatMessageContent content) - { - ChatResponseUpdate update = new() - { - AdditionalProperties = content.Metadata is not null ? new AdditionalPropertiesDictionary(content.Metadata) : null, - AuthorName = content.AuthorName, - ModelId = content.ModelId, - RawRepresentation = content, - Role = content.Role is not null ? new ChatRole(content.Role.Value.Label) : null, - }; - - foreach (var item in content.Items) - { - AIContent? aiContent = null; - switch (item) - { - case Microsoft.SemanticKernel.StreamingTextContent tc: - aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); - break; - - case Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent fcc: - aiContent = new Microsoft.Extensions.AI.FunctionCallContent( - fcc.CallId ?? string.Empty, - fcc.Name ?? string.Empty, - fcc.Arguments is not null ? JsonSerializer.Deserialize>(fcc.Arguments, AbstractionsJsonContext.Default.IDictionaryStringObject!) : null); - break; - } - - if (aiContent is not null) - { - aiContent.RawRepresentation = content; - - update.Contents.Add(aiContent); - } - } - - return update; - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs index f61a61a6687e..844d940e5e54 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceExtensions.cs @@ -128,128 +128,4 @@ public static IChatClient AsChatClient(this IChatCompletionService service) chatClient : new ChatCompletionServiceChatClient(service); } - - /// Creates an for the specified . - /// The chat client to be represented as a chat completion service. - /// An optional that can be used to resolve services to use in the instance. - /// - /// The . If is an , will - /// be returned. Otherwise, a new will be created that wraps . - /// - [Experimental("SKEXP0001")] - public static IChatCompletionService AsChatCompletionService(this IChatClient client, IServiceProvider? serviceProvider = null) - { - Verify.NotNull(client); - - return client is IChatCompletionService chatCompletionService ? - chatCompletionService : - new ChatClientChatCompletionService(client, serviceProvider); - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - internal static ChatMessage ToChatMessage(ChatMessageContent content) - { - ChatMessage message = new() - { - AdditionalProperties = content.Metadata is not null ? new(content.Metadata) : null, - AuthorName = content.AuthorName, - RawRepresentation = content.InnerContent, - Role = content.Role.Label is string label ? new ChatRole(label) : ChatRole.User, - }; - - foreach (var item in content.Items) - { - AIContent? aiContent = null; - switch (item) - { - case Microsoft.SemanticKernel.TextContent tc: - aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); - break; - - case Microsoft.SemanticKernel.ImageContent ic: - aiContent = - ic.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ic.DataUri, ic.MimeType) : - ic.Uri is not null ? new Microsoft.Extensions.AI.UriContent(ic.Uri, ic.MimeType ?? "image/*") : - null; - break; - - case Microsoft.SemanticKernel.AudioContent ac: - aiContent = - ac.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ac.DataUri, ac.MimeType) : - ac.Uri is not null ? new Microsoft.Extensions.AI.UriContent(ac.Uri, ac.MimeType ?? "audio/*") : - null; - break; - - case Microsoft.SemanticKernel.BinaryContent bc: - aiContent = - bc.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(bc.DataUri, bc.MimeType) : - bc.Uri is not null ? new Microsoft.Extensions.AI.UriContent(bc.Uri, bc.MimeType ?? "application/octet-stream") : - null; - break; - - case Microsoft.SemanticKernel.FunctionCallContent fcc: - aiContent = new Microsoft.Extensions.AI.FunctionCallContent(fcc.Id ?? string.Empty, fcc.FunctionName, fcc.Arguments); - break; - - case Microsoft.SemanticKernel.FunctionResultContent frc: - aiContent = new Microsoft.Extensions.AI.FunctionResultContent(frc.CallId ?? string.Empty, frc.Result); - break; - } - - if (aiContent is not null) - { - aiContent.RawRepresentation = item.InnerContent; - aiContent.AdditionalProperties = item.Metadata is not null ? new(item.Metadata) : null; - - message.Contents.Add(aiContent); - } - } - - return message; - } - - /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. - internal static ChatMessageContent ToChatMessageContent(ChatMessage message, Microsoft.Extensions.AI.ChatResponse? response = null) - { - ChatMessageContent result = new() - { - ModelId = response?.ModelId, - AuthorName = message.AuthorName, - InnerContent = response?.RawRepresentation ?? message.RawRepresentation, - Metadata = message.AdditionalProperties, - Role = new AuthorRole(message.Role.Value), - }; - - foreach (AIContent content in message.Contents) - { - KernelContent? resultContent = content switch - { - Microsoft.Extensions.AI.TextContent tc => new Microsoft.SemanticKernel.TextContent(tc.Text), - Microsoft.Extensions.AI.DataContent dc when dc.HasTopLevelMediaType("image") => new Microsoft.SemanticKernel.ImageContent(dc.Uri), - Microsoft.Extensions.AI.UriContent uc when uc.HasTopLevelMediaType("image") => new Microsoft.SemanticKernel.ImageContent(uc.Uri), - Microsoft.Extensions.AI.DataContent dc when dc.HasTopLevelMediaType("audio") => new Microsoft.SemanticKernel.AudioContent(dc.Uri), - Microsoft.Extensions.AI.UriContent uc when uc.HasTopLevelMediaType("audio") => new Microsoft.SemanticKernel.AudioContent(uc.Uri), - Microsoft.Extensions.AI.DataContent dc => new Microsoft.SemanticKernel.BinaryContent(dc.Uri), - Microsoft.Extensions.AI.UriContent uc => new Microsoft.SemanticKernel.BinaryContent(uc.Uri), - Microsoft.Extensions.AI.FunctionCallContent fcc => new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null), - Microsoft.Extensions.AI.FunctionResultContent frc => new Microsoft.SemanticKernel.FunctionResultContent(callId: frc.CallId, result: frc.Result), - _ => null - }; - - if (resultContent is not null) - { - resultContent.Metadata = content.AdditionalProperties; - resultContent.InnerContent = content.RawRepresentation; - resultContent.ModelId = response?.ModelId; - result.Items.Add(resultContent); - } - } - - return result; - } - - internal static List ToChatMessageList(ChatHistory chatHistory) - => chatHistory.Select(ToChatMessage).ToList(); } diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs index 2e8d45ea89e0..276d500ce787 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/ChatMessageContentExtensions.cs @@ -29,22 +29,22 @@ internal static ChatMessage ToChatMessage(this ChatMessageContent content) case Microsoft.SemanticKernel.ImageContent ic: aiContent = - ic.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ic.DataUri, ic.MimeType ?? "image/*") : - ic.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ic.Uri, ic.MimeType ?? "image/*") : + ic.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ic.DataUri, ic.MimeType) : + ic.Uri is not null ? new Microsoft.Extensions.AI.UriContent(ic.Uri, ic.MimeType ?? "image/*") : null; break; case Microsoft.SemanticKernel.AudioContent ac: aiContent = - ac.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ac.DataUri, ac.MimeType ?? "audio/*") : - ac.Uri is not null ? new Microsoft.Extensions.AI.DataContent(ac.Uri, ac.MimeType ?? "audio/*") : + ac.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(ac.DataUri, ac.MimeType) : + ac.Uri is not null ? new Microsoft.Extensions.AI.UriContent(ac.Uri, ac.MimeType ?? "audio/*") : null; break; case Microsoft.SemanticKernel.BinaryContent bc: aiContent = bc.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(bc.DataUri, bc.MimeType) : - bc.Uri is not null ? new Microsoft.Extensions.AI.DataContent(bc.Uri, bc.MimeType) : + bc.Uri is not null ? new Microsoft.Extensions.AI.UriContent(bc.Uri, bc.MimeType ?? "application/octet-stream") : null; break; diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs index ae955bfad14f..cc41596adf08 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs @@ -17,7 +17,6 @@ internal static ChatResponseUpdate ToChatResponseUpdate(this StreamingChatMessag { AdditionalProperties = content.Metadata is not null ? new AdditionalPropertiesDictionary(content.Metadata) : null, AuthorName = content.AuthorName, - ChoiceIndex = content.ChoiceIndex, ModelId = content.ModelId, RawRepresentation = content.InnerContent, Role = content.Role is not null ? new ChatRole(content.Role.Value.Label) : null, diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs index 945e9cc7a74e..4bc58f6c7156 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs @@ -128,12 +128,13 @@ public FunctionResult(FunctionResult result, object? value = null) throw new InvalidCastException($"Cannot cast a response with no choices to {typeof(T)}"); } + var firstMessage = messageContentList[0]; if (typeof(T) == typeof(ChatResponse)) { - return (T)(object)new ChatResponse(messageContentList.Select(m => m.ToChatMessage()).ToList()); + // Ignore multiple choices when converting to Microsoft.Extensions.AI.ChatResponse + return (T)(object)new ChatResponse(firstMessage.ToChatMessage()); } - var firstMessage = messageContentList[0]; if (typeof(T) == typeof(ChatMessage)) { return (T)(object)firstMessage.ToChatMessage(); @@ -143,12 +144,12 @@ public FunctionResult(FunctionResult result, object? value = null) if (this.Value is Microsoft.Extensions.AI.ChatResponse chatResponse) { // If no choices are present, return default - if (chatResponse.Choices.Count == 0) + if (chatResponse.Messages.Count == 0) { - throw new InvalidCastException($"Cannot cast a response with no choices to {typeof(T)}"); + throw new InvalidCastException($"Cannot cast a response with no messages to {typeof(T)}"); } - var chatMessage = chatResponse.Message; + var chatMessage = chatResponse.Messages.Last(); if (typeof(T) == typeof(string)) { return (T?)(object?)chatMessage.ToString(); diff --git a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs index 691c61a109fd..8970251e614a 100644 --- a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs +++ b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs @@ -375,10 +375,10 @@ protected override async IAsyncEnumerable InvokeStreamingCoreAsync c is DataContent dataContent && dataContent.Data.HasValue); + DataContent? dataContent = (DataContent?)chatUpdate.Contents.FirstOrDefault(c => c is DataContent dataContent); if (dataContent is not null) { - yield return (TResult)(object)dataContent.Data!.Value.ToArray(); + yield return (TResult)(object)dataContent.Data.ToArray(); continue; } } @@ -813,7 +813,7 @@ private async Task GetChatClientResultAsync( kernel, cancellationToken).ConfigureAwait(false); - if (chatResponse.Choices is { Count: 0 }) + if (chatResponse.Messages is { Count: 0 }) { return new FunctionResult(this, chatResponse) { diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs index 5a67a0ecf370..322c5f3b935f 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs @@ -77,7 +77,7 @@ public void Dispose() { } - public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } @@ -87,7 +87,7 @@ public Task GetResponseAsync(IList chatMessages, Chat return this._metadata; } - public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs index 4d2f5e14d763..44ba05c5e547 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionResultTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Xunit; @@ -165,8 +166,8 @@ public void GetValueConvertsFromMEAIChatMessageToSKChatMessageContent() // Act and Assert var message = target.GetValue()!; - Assert.Equal(valueType.Message.Text, message.Content); - Assert.Same(valueType.Message.RawRepresentation, message.InnerContent); + Assert.Equal(valueType.Messages.Last().Text, message.Content); + Assert.Same(valueType.Messages.Last().RawRepresentation, message.InnerContent); } [Fact] @@ -194,8 +195,8 @@ public void GetValueConvertsFromSKChatMessageContentToMEAIChatResponse() // Act and Assert - Assert.Equal(valueType.Content, target.GetValue()!.Message.Text); - Assert.Same(valueType.InnerContent, target.GetValue()!.Message.RawRepresentation); + Assert.Equal(valueType.Content, target.GetValue()!.Text); + Assert.Same(valueType.InnerContent, target.GetValue()!.Messages[0].RawRepresentation); } [Theory] @@ -218,12 +219,26 @@ public void GetValueConvertsFromSKChatMessageContentListToMEAIChatResponse(int l // Act and Assert // Ensure returns the ChatResponse for no choices as well var result = target.GetValue()!; + Assert.NotNull(result); + for (int i = 0; i < listSize; i++) { - Assert.Equal(multipleChoiceResponse[i].Content, result.Choices[i].Text); - Assert.Same(multipleChoiceResponse[i].InnerContent, result.Choices[i].RawRepresentation); + // Ensure the other choices are not added as messages, only the first choice is considered + Assert.Single(result.Messages); + + if (i == 0) + { + // The first choice is converted to a message + Assert.Equal(multipleChoiceResponse[i].Content, result.Messages.Last().Text); + Assert.Same(multipleChoiceResponse[i].InnerContent, result.Messages.Last().RawRepresentation); + } + else + { + // Any following choices messages are ignored and should not match the result message + Assert.NotEqual(multipleChoiceResponse[i].Content, result.Text); + Assert.NotSame(multipleChoiceResponse[i].InnerContent, result.Messages.Last().RawRepresentation); + } } - Assert.Equal(multipleChoiceResponse.Count, result.Choices.Count); if (listSize > 0) { @@ -270,22 +285,22 @@ public void GetValueCanRetrieveMEAITypes() // Act and Assert Assert.Same(valueType, target.GetValue()); - Assert.Same(valueType.Message, target.GetValue()); - Assert.Same(valueType.Message.Contents[0], target.GetValue()); - Assert.Same(valueType.Message.Contents[0], target.GetValue()); + Assert.Same(valueType.Messages[0], target.GetValue()); + Assert.Same(valueType.Messages[0].Contents[0], target.GetValue()); + Assert.Same(valueType.Messages[0].Contents[0], target.GetValue()); // Check the the content list is returned - Assert.Same(valueType.Message.Contents, target.GetValue>()!); - Assert.Same(valueType.Message.Contents[0], target.GetValue>()![0]); + Assert.Same(valueType.Messages[0].Contents, target.GetValue>()!); + Assert.Same(valueType.Messages[0].Contents[0], target.GetValue>()![0]); Assert.IsType(target.GetValue>()![0]); // Check the raw representations are returned Assert.Same(valueType.RawRepresentation, target.GetValue()!); - Assert.Same(valueType.Message.RawRepresentation, target.GetValue()!); + Assert.Same(valueType.Messages[0].RawRepresentation, target.GetValue()!); } [Fact] - public void GetValueThrowsForEmptyChoicesToMEAITypes() + public void GetValueThrowsForEmptyMessagesToMEAITypes() { // Arrange string expectedValue = Guid.NewGuid().ToString(); @@ -293,15 +308,15 @@ public void GetValueThrowsForEmptyChoicesToMEAITypes() FunctionResult target = new(s_nopFunction, valueType); // Act and Assert - Assert.Empty(target.GetValue()!.Choices); + Assert.Empty(target.GetValue()!.Messages); var exception = Assert.Throws(target.GetValue); - Assert.Contains("no choices", exception.Message); + Assert.Contains("no messages", exception.Message); exception = Assert.Throws(target.GetValue); - Assert.Contains("no choices", exception.Message); + Assert.Contains("no messages", exception.Message); exception = Assert.Throws(target.GetValue); - Assert.Contains("no choices", exception.Message); + Assert.Contains("no messages", exception.Message); } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs index 2ec0c214b2a5..7d2789a3591c 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs @@ -424,12 +424,15 @@ public async Task InvokeAsyncReturnsTheConnectorChatResultWhenInServiceIsOnlyCha } [Fact] - public async Task InvokeAsyncReturnsTheConnectorChatResultChoicesWhenInServiceIsOnlyChatClientAsync() + public async Task InvokeAsyncReturnsTheConnectorChatResultMessagesWhenInServiceIsOnlyChatClientAsync() { + var firstMessageContent = "something 1"; + var lastMessageContent = "something 2"; + var customTestType = new CustomTestType(); var fakeChatResponse = new MEAI.ChatResponse([ - new MEAI.ChatMessage(MEAI.ChatRole.User, "something 1") { RawRepresentation = customTestType }, - new MEAI.ChatMessage(MEAI.ChatRole.Assistant, "something 2") + new MEAI.ChatMessage(MEAI.ChatRole.User, firstMessageContent), + new MEAI.ChatMessage(MEAI.ChatRole.Assistant, lastMessageContent) { RawRepresentation = customTestType } ]); Mock mockChatClient = new(); @@ -447,23 +450,23 @@ public async Task InvokeAsyncReturnsTheConnectorChatResultChoicesWhenInServiceIs var response = result.GetValue(); Assert.NotNull(response); - Assert.Collection(response.Choices, + Assert.Collection(response.Messages, item1 => { - Assert.Equal("something 1", item1.Text); Assert.Equal(MEAI.ChatRole.User, item1.Role); + Assert.Equal(firstMessageContent, item1.Text); Assert.Equal(MEAI.ChatRole.User, item1.Role); }, item2 => { - Assert.Equal("something 2", item2.Text); Assert.Equal(MEAI.ChatRole.Assistant, item2.Role); + Assert.Equal(lastMessageContent, item2.Text); Assert.Equal(MEAI.ChatRole.Assistant, item2.Role); }); - // Other specific types will be checked against the first choice - Assert.Equal("something 1", result.GetValue()); - Assert.Equal("something 1", result.GetValue()!.Text); - Assert.Equal(MEAI.ChatRole.User, result.GetValue()!.Role); + // Other specific types will be checked against the first choice and last message + Assert.Equal(lastMessageContent, result.GetValue()); + Assert.Equal(lastMessageContent, result.GetValue()!.Text); + Assert.Equal(MEAI.ChatRole.Assistant, result.GetValue()!.Role); Assert.Same(customTestType, result.GetValue()!); - Assert.Equal("something 1", result.GetValue()!.ToString()); - Assert.Equal("something 1", result.GetValue()!.ToString()); + Assert.Equal(lastMessageContent, result.GetValue()!.ToString()); + Assert.Equal(lastMessageContent, result.GetValue()!.ToString()); } [Fact] @@ -1077,15 +1080,9 @@ public async Task ItCanRetrieveDirectMEAIChatMessageUpdatesAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can " - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?" - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can "), + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1111,15 +1108,9 @@ public async Task ItCanRetrieveDirectMEAITextContentAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can " - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?" - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can "), + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1144,15 +1135,9 @@ public async Task ItCanRetrieveDirectMEAIStringAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can " - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?" - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can "), + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1178,17 +1163,9 @@ public async Task ItCanRetrieveDirectMEAIRawRepresentationAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can ", - RawRepresentation = rawRepresentation - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?", - RawRepresentation = rawRepresentation - }] + new MEAI.ChatResponseUpdate(role: MEAI.ChatRole.Assistant, content: "Hi! How can ") { RawRepresentation = rawRepresentation }, + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1214,17 +1191,9 @@ public async Task ItCanRetrieveDirectMEAIContentListAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can ", - RawRepresentation = rawRepresentation - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?", - RawRepresentation = rawRepresentation - }] + new MEAI.ChatResponseUpdate(role: MEAI.ChatRole.Assistant, content: "Hi! How can ") { RawRepresentation = rawRepresentation }, + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1250,17 +1219,9 @@ public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingChatMessageCon using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can ", - RawRepresentation = rawRepresentation - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?", - RawRepresentation = rawRepresentation - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can ") { RawRepresentation = rawRepresentation }, + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1291,17 +1252,9 @@ public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingContentAsync() using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can ", - RawRepresentation = rawRepresentation - }, - new MEAI.ChatResponseUpdate - { - Text = "I assist you today?", - RawRepresentation = rawRepresentation - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can ") { RawRepresentation = rawRepresentation }, + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1334,14 +1287,9 @@ public async Task ItConvertsFromSKStreamingChatMessageContentToMEAIChatResponseU var fakeService = new FakeChatCompletionService() { GetStreamingChatMessageContentsResult = [ - new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") - { - InnerContent = innerContent - }, - new StreamingChatMessageContent(null, "I assist you today?") - { - InnerContent = innerContent - }] + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") { InnerContent = innerContent }, + new StreamingChatMessageContent(null, "I assist you today?") { InnerContent = innerContent } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1373,14 +1321,9 @@ public async Task ItConvertsFromSKStreamingChatMessageContentToStringAsync() var fakeService = new FakeChatCompletionService() { GetStreamingChatMessageContentsResult = [ - new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") - { - InnerContent = innerContent - }, - new StreamingChatMessageContent(null, "I assist you today?") - { - InnerContent = innerContent - }] + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") { InnerContent = innerContent }, + new StreamingChatMessageContent(null, "I assist you today?") { InnerContent = innerContent } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1410,14 +1353,9 @@ public async Task ItConvertsFromSKStreamingChatMessageContentToItselfAsync() var fakeService = new FakeChatCompletionService() { GetStreamingChatMessageContentsResult = [ - new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") - { - InnerContent = innerContent - }, - new StreamingChatMessageContent(null, "I assist you today?") - { - InnerContent = innerContent - }] + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") { InnerContent = innerContent }, + new StreamingChatMessageContent(null, "I assist you today?") { InnerContent = innerContent } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1447,14 +1385,9 @@ public async Task ItConvertsFromSKStreamingChatMessageContentToInnerContentAsync var fakeService = new FakeChatCompletionService() { GetStreamingChatMessageContentsResult = [ - new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") - { - InnerContent = innerContent - }, - new StreamingChatMessageContent(null, "I assist you today?") - { - InnerContent = innerContent - }] + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") { InnerContent = innerContent }, + new StreamingChatMessageContent(null, "I assist you today?") { InnerContent = innerContent } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1484,14 +1417,9 @@ public async Task ItConvertsFromSKStreamingChatMessageContentToBytesAsync() var fakeService = new FakeChatCompletionService() { GetStreamingChatMessageContentsResult = [ - new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") - { - InnerContent = innerContent - }, - new StreamingChatMessageContent(null, "I assist you today?") - { - InnerContent = innerContent - }] + new StreamingChatMessageContent(AuthorRole.Assistant, "Hi! How can ") { InnerContent = innerContent }, + new StreamingChatMessageContent(null, "I assist you today?") { InnerContent = innerContent } + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1544,11 +1472,8 @@ public async Task ItThrowsWhenConvertingFromMEAIChatMessageUpdateWithNoDataConte using var fakeService = new FakeChatClient() { GetStreamingResponseResult = [ - new MEAI.ChatResponseUpdate - { - Role = MEAI.ChatRole.Assistant, - Text = "Hi! How can ", - }] + new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can ") + ] }; IKernelBuilder builder = Kernel.CreateBuilder(); @@ -1662,16 +1587,16 @@ public Task> GetTextContentsAsync(string prompt, Prom private sealed class FakeChatClient : MEAI.IChatClient { - public IList? ChatMessages { get; private set; } - public IList? GetStreamingResponseResult { get; set; } + public List? ChatMessages { get; private set; } + public List? GetStreamingResponseResult { get; set; } public void Dispose() { } - public Task GetResponseAsync(IList chatMessages, MEAI.ChatOptions? options = null, CancellationToken cancellationToken = default) + public Task GetResponseAsync(IEnumerable messages, MEAI.ChatOptions? options = null, CancellationToken cancellationToken = default) { - this.ChatMessages = chatMessages; + this.ChatMessages = messages.ToList(); return Task.FromResult(new MEAI.ChatResponse(new MEAI.ChatMessage(MEAI.ChatRole.Assistant, "Something"))); } @@ -1682,12 +1607,12 @@ public void Dispose() #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async IAsyncEnumerable GetStreamingResponseAsync( - IList chatMessages, + IEnumerable messages, MEAI.ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - this.ChatMessages = chatMessages; - foreach (var item in this.GetStreamingResponseResult ?? [new MEAI.ChatResponseUpdate { Role = MEAI.ChatRole.Assistant, Text = "Something" }]) + this.ChatMessages = messages.ToList(); + foreach (var item in this.GetStreamingResponseResult ?? [new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Something")]) { yield return item; } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs index b31a98c3f1f3..b0a57fa8cdf6 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs @@ -540,12 +540,12 @@ public IAsyncEnumerable GetStreamingTextContentsAsync(stri throw new NotImplementedException(); } - public Task GetResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } - public IAsyncEnumerable GetStreamingResponseAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs b/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs index ccb96a28466c..866d9d7c7d41 100644 --- a/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/KernelTests.cs @@ -293,8 +293,8 @@ public async Task InvokeStreamingAsyncGenericPermutationsCallsConnectorChatClien // Arrange var customRawItem = new MEAI.ChatOptions(); var mockChatClient = this.SetupStreamingChatClientMock( - new MEAI.ChatResponseUpdate() { Text = "chunk1", RawRepresentation = customRawItem }, - new MEAI.ChatResponseUpdate() { Text = "chunk2", RawRepresentation = customRawItem }); + new MEAI.ChatResponseUpdate(role: MEAI.ChatRole.Assistant, content: "chunk1") { RawRepresentation = customRawItem }, + new MEAI.ChatResponseUpdate(role: null, content: "chunk2") { RawRepresentation = customRawItem }); IKernelBuilder builder = Kernel.CreateBuilder(); builder.Services.AddSingleton(mockChatClient.Object); Kernel kernel = builder.Build(); From a77358677a6810c00bf4ee5f1bc4c47f09b28a3f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:47:19 +0000 Subject: [PATCH 03/22] Fix warnings --- ...rockTextEmbeddingGenerationServiceTests.cs | 23 ------------------- .../AI/ChatClient/ChatMessageExtensions.cs | 1 - 2 files changed, 24 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs index 8100633103e4..f0ad18dccbff 100644 --- a/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using Amazon.BedrockRuntime; using Microsoft.SemanticKernel.Embeddings; using Microsoft.SemanticKernel.Services; @@ -67,24 +64,4 @@ public void ShouldThrowExceptionForEmptyModelId() Assert.Throws(() => kernel.GetRequiredService()); } - - /// - /// Checks that an invalid BedrockRuntime object will throw an exception. - /// - [Fact] - public async Task ShouldThrowExceptionForNullBedrockRuntimeAsync() - { - // Arrange - string modelId = "amazon.titan-embed-text-v2:0"; - List prompts = new() { "King", "Queen", "Prince" }; - IAmazonBedrockRuntime? nullBedrockRuntime = null; - - // Act & Assert - await Assert.ThrowsAnyAsync(async () => - { - var kernel = Kernel.CreateBuilder().AddBedrockTextEmbeddingGenerationService(modelId, nullBedrockRuntime).Build(); - var service = kernel.GetRequiredService(); - await service.GenerateEmbeddingsAsync(prompts).ConfigureAwait(true); - }).ConfigureAwait(true); - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs index a7aa4e160552..5d37a3a496d5 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; From dfbf238f27a71bb7adddb0274e070beed410ee53 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Sat, 29 Mar 2025 08:47:49 -0400 Subject: [PATCH 04/22] .Net: MEAI - Add `IChatClient` support for `ChatCompletionAgents` (#11049) # Add ChatClient Support to ChatCompletionAgent - Resolve #10729 ## Motivation and Context This PR enhances ChatCompletionAgent to support `IChatClient` alongside existing IChatCompletionService, providing more flexibility in how chat completions are handled. This enables better integration with existing `Microsoft.Extensions.AI` types. ## Description ### Core Changes 1. Enhanced ChatCompletionAgent to: - Support both IChatClient and IChatCompletionService interfaces - Maintain backward compatibility with existing implementations 2. Updated Sample Code: - Added new examples in Concepts demonstrating ChatClient usage - Updated Getting Started With Agents samples to showcase both service types 3. Added Comprehensive Unit Tests: - Test coverage for Agents with ChatClient integration - Verification of both streaming and non-streaming scenarios 4. Added `IChatClient.AsKernelFunctionInvokingClient` extension method to enable Kernel Function Invocation support with `IChatClient` for solutions that target `.NET 8 LTS` only. --- dotnet/SK-dotnet.sln | 3 - .../ChatCompletion_FunctionTermination.cs | 43 +- .../Agents/ChatCompletion_HistoryReducer.cs | 56 +- .../Agents/ChatCompletion_Serialization.cs | 10 +- .../Agents/ChatCompletion_ServiceSelection.cs | 97 +- .../Agents/ChatCompletion_Streaming.cs | 20 +- .../Agents/ChatCompletion_Templating.cs | 44 +- .../Agents/ComplexChat_NestedShopper.cs | 10 +- .../Concepts/Agents/DeclarativeAgents.cs | 29 +- .../Concepts/Agents/MixedChat_Agents.cs | 10 +- .../Concepts/Agents/MixedChat_Files.cs | 10 +- .../Concepts/Agents/MixedChat_Images.cs | 10 +- .../Concepts/Agents/MixedChat_Reset.cs | 10 +- .../Agents/MixedChat_Serialization.cs | 10 +- .../Concepts/Agents/MixedChat_Streaming.cs | 10 +- dotnet/samples/Concepts/Concepts.csproj | 2 +- .../GettingStarted/GettingStarted.csproj | 1 + .../GettingStartedWithAgents.csproj | 3 +- .../GettingStartedWithAgents/Step01_Agent.cs | 20 +- .../Step02_Plugins.cs | 33 +- .../GettingStartedWithAgents/Step03_Chat.cs | 13 +- .../Step04_KernelFunctionStrategies.cs | 13 +- .../Step05_JsonResult.cs | 10 +- .../Step06_DependencyInjection.cs | 64 +- .../Step07_Telemetry.cs | 31 +- .../GettingStartedWithProcesses.csproj | 1 + .../GettingStartedWithTextSearch.csproj | 1 + .../GettingStartedWithVectorStores.csproj | 1 + .../LearnResources/LearnResources.csproj | 1 + dotnet/src/Agents/OpenAI/Agents.OpenAI.csproj | 2 +- .../Core/ChatCompletionAgentTests.cs | 115 +++ ...rockTextEmbeddingGenerationServiceTests.cs | 3 + .../Core/ClientCore.ChatCompletion.cs | 2 +- .../samples/InternalUtilities/BaseTest.cs | 50 +- .../EmptyReadonlyDictionary.cs | 61 ++ .../src/EmptyCollections/EmptyReadonlyList.cs | 56 ++ .../AI/ChatClient/AIFunctionFactory.cs | 631 +++++++++++++ .../AI/ChatClient/ChatClientAIService.cs | 2 +- .../AI/ChatClient/ChatClientExtensions.cs | 15 + .../AI/ChatClient/ChatMessageExtensions.cs | 5 +- .../AI/ChatClient/ChatOptionsExtensions.cs | 121 +++ .../AI/ChatClient/FunctionFactoryOptions.cs | 63 ++ .../KernelFunctionInvocationContext.cs | 106 +++ .../KernelFunctionInvokingChatClient.cs | 855 ++++++++++++++++++ .../ChatClientChatCompletionService.cs | 16 +- .../ChatCompletionServiceChatClient.cs | 111 +-- .../StreamingChatMessageContentExtensions.cs | 2 +- .../Functions/FunctionResult.cs | 1 + .../Functions/KernelFunctionFromPrompt.cs | 1 - 49 files changed, 2502 insertions(+), 282 deletions(-) create mode 100644 dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyDictionary.cs create mode 100644 dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyList.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs create mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index 0a4bf8c39da2..2d17ceaae72c 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -430,8 +430,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OllamaFunctionCalling", "sa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAIRealtime", "samples\Demos\OpenAIRealtime\OpenAIRealtime.csproj", "{6154129E-7A35-44A5-998E-B7001B5EDE14}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CreateChatGpt", "CreateChatGpt", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "VectorDataIntegrationTests", "VectorDataIntegrationTests", "{4F381919-F1BE-47D8-8558-3187ED04A84F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QdrantIntegrationTests", "src\VectorDataIntegrationTests\QdrantIntegrationTests\QdrantIntegrationTests.csproj", "{27D33AB3-4DFF-48BC-8D76-FB2CDF90B707}" @@ -1497,7 +1495,6 @@ Global {B35B1DEB-04DF-4141-9163-01031B22C5D1} = {0D8C6358-5DAA-4EA6-A924-C268A9A21BC9} {481A680F-476A-4627-83DE-2F56C484525E} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} {6154129E-7A35-44A5-998E-B7001B5EDE14} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} {4F381919-F1BE-47D8-8558-3187ED04A84F} = {831DDCA2-7D2C-4C31-80DB-6BDB3E1F7AE0} {27D33AB3-4DFF-48BC-8D76-FB2CDF90B707} = {4F381919-F1BE-47D8-8558-3187ED04A84F} {B29A972F-A774-4140-AECF-6B577C476627} = {4F381919-F1BE-47D8-8558-3187ED04A84F} diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs index c72ecdb79be8..55f0eb918ecf 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs @@ -13,15 +13,17 @@ namespace Agents; /// public class ChatCompletion_FunctionTermination(ITestOutputHelper output) : BaseAgentsTest(output) { - [Fact] - public async Task UseAutoFunctionInvocationFilterWithAgentInvocationAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseAutoFunctionInvocationFilterWithAgentInvocation(bool useChatClient) { // Define the agent ChatCompletionAgent agent = new() { Instructions = "Answer questions about the menu.", - Kernel = CreateKernelWithFilter(), + Kernel = CreateKernelWithFilter(useChatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -60,15 +62,17 @@ async Task InvokeAgentAsync(string input) } } - [Fact] - public async Task UseAutoFunctionInvocationFilterWithAgentChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseAutoFunctionInvocationFilterWithAgentChat(bool useChatClient) { // Define the agent ChatCompletionAgent agent = new() { Instructions = "Answer questions about the menu.", - Kernel = CreateKernelWithFilter(), + Kernel = CreateKernelWithFilter(useChatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -101,15 +105,17 @@ async Task InvokeAgentAsync(string input) } } - [Fact] - public async Task UseAutoFunctionInvocationFilterWithStreamingAgentInvocationAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseAutoFunctionInvocationFilterWithStreamingAgentInvocation(bool useChatClient) { // Define the agent ChatCompletionAgent agent = new() { Instructions = "Answer questions about the menu.", - Kernel = CreateKernelWithFilter(), + Kernel = CreateKernelWithFilter(useChatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -164,15 +170,17 @@ async Task InvokeAgentAsync(string input) } } - [Fact] - public async Task UseAutoFunctionInvocationFilterWithStreamingAgentChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseAutoFunctionInvocationFilterWithStreamingAgentChat(bool useChatClient) { // Define the agent ChatCompletionAgent agent = new() { Instructions = "Answer questions about the menu.", - Kernel = CreateKernelWithFilter(), + Kernel = CreateKernelWithFilter(useChatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -228,11 +236,18 @@ private void WriteChatHistory(IEnumerable chat) } } - private Kernel CreateKernelWithFilter() + private Kernel CreateKernelWithFilter(bool useChatClient) { IKernelBuilder builder = Kernel.CreateBuilder(); - base.AddChatCompletionToKernel(builder); + if (useChatClient) + { + base.AddChatClientToKernel(builder); + } + else + { + base.AddChatCompletionToKernel(builder); + } builder.Services.AddSingleton(new AutoInvocationFilter()); diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_HistoryReducer.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_HistoryReducer.cs index 540b54777cf9..fe93f82c9ac1 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_HistoryReducer.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_HistoryReducer.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; @@ -19,52 +20,68 @@ public class ChatCompletion_HistoryReducer(ITestOutputHelper output) : BaseTest( /// Demonstrate the use of when directly /// invoking a . /// - [Fact] - public async Task TruncatedAgentReductionAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TruncatedAgentReduction(bool useChatClient) { // Define the agent - ChatCompletionAgent agent = CreateTruncatingAgent(10, 10); + ChatCompletionAgent agent = CreateTruncatingAgent(10, 10, useChatClient, out var chatClient); await InvokeAgentAsync(agent, 50); + + chatClient?.Dispose(); } /// /// Demonstrate the use of when directly /// invoking a . /// - [Fact] - public async Task SummarizedAgentReductionAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SummarizedAgentReduction(bool useChatClient) { // Define the agent - ChatCompletionAgent agent = CreateSummarizingAgent(10, 10); + ChatCompletionAgent agent = CreateSummarizingAgent(10, 10, useChatClient, out var chatClient); await InvokeAgentAsync(agent, 50); + + chatClient?.Dispose(); } /// /// Demonstrate the use of when using /// to invoke a . /// - [Fact] - public async Task TruncatedChatReductionAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task TruncatedChatReduction(bool useChatClient) { // Define the agent - ChatCompletionAgent agent = CreateTruncatingAgent(10, 10); + ChatCompletionAgent agent = CreateTruncatingAgent(10, 10, useChatClient, out var chatClient); await InvokeChatAsync(agent, 50); + + chatClient?.Dispose(); } /// /// Demonstrate the use of when using /// to invoke a . /// - [Fact] - public async Task SummarizedChatReductionAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SummarizedChatReduction(bool useChatClient) { // Define the agent - ChatCompletionAgent agent = CreateSummarizingAgent(10, 10); + ChatCompletionAgent agent = CreateSummarizingAgent(10, 10, useChatClient, out var chatClient); await InvokeChatAsync(agent, 50); + + chatClient?.Dispose(); } // Proceed with dialog by directly invoking the agent and explicitly managing the history. @@ -149,25 +166,30 @@ private async Task InvokeChatAsync(ChatCompletionAgent agent, int messageCount) } } - private ChatCompletionAgent CreateSummarizingAgent(int reducerMessageCount, int reducerThresholdCount) + private ChatCompletionAgent CreateSummarizingAgent(int reducerMessageCount, int reducerThresholdCount, bool useChatClient, out IChatClient? chatClient) { - Kernel kernel = this.CreateKernelWithChatCompletion(); + Kernel kernel = this.CreateKernelWithChatCompletion(useChatClient, out chatClient); + + var service = useChatClient + ? kernel.GetRequiredService().AsChatCompletionService() + : kernel.GetRequiredService(); + return new() { Name = TranslatorName, Instructions = TranslatorInstructions, Kernel = kernel, - HistoryReducer = new ChatHistorySummarizationReducer(kernel.GetRequiredService(), reducerMessageCount, reducerThresholdCount), + HistoryReducer = new ChatHistorySummarizationReducer(service, reducerMessageCount, reducerThresholdCount), }; } - private ChatCompletionAgent CreateTruncatingAgent(int reducerMessageCount, int reducerThresholdCount) => + private ChatCompletionAgent CreateTruncatingAgent(int reducerMessageCount, int reducerThresholdCount, bool useChatClient, out IChatClient? chatClient) => new() { Name = TranslatorName, Instructions = TranslatorInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out chatClient), HistoryReducer = new ChatHistoryTruncationReducer(reducerMessageCount, reducerThresholdCount), }; } diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs index 1bc16f452d6c..9153e4b45cda 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_Serialization.cs @@ -13,8 +13,10 @@ public class ChatCompletion_Serialization(ITestOutputHelper output) : BaseAgents private const string HostName = "Host"; private const string HostInstructions = "Answer questions about the menu."; - [Fact] - public async Task SerializeAndRestoreAgentGroupChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeAndRestoreAgentGroupChat(bool useChatClient) { // Define the agent ChatCompletionAgent agent = @@ -22,7 +24,7 @@ public async Task SerializeAndRestoreAgentGroupChatAsync() { Instructions = HostInstructions, Name = HostName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -51,6 +53,8 @@ public async Task SerializeAndRestoreAgentGroupChatAsync() this.WriteAgentChatMessage(content); } + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(AgentGroupChat chat, string input) { diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs index 46ea8dea2246..d401ba1ec938 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs @@ -1,4 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; @@ -15,13 +18,13 @@ public class ChatCompletion_ServiceSelection(ITestOutputHelper output) : BaseAge private const string ServiceKeyGood = "chat-good"; private const string ServiceKeyBad = "chat-bad"; - [Fact] - public async Task UseServiceSelectionWithChatCompletionAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseServiceSelectionWithChatCompletionAgent(bool useChatClient) { - // Create kernel with two instances of IChatCompletionService - // One service is configured with a valid API key and the other with an - // invalid key that will result in a 401 Unauthorized error. - Kernel kernel = CreateKernelWithTwoServices(); + // Create kernel with two instances of chat services - one good, one bad + Kernel kernel = CreateKernelWithTwoServices(useChatClient); // Define the agent targeting ServiceId = ServiceKeyGood ChatCompletionAgent agentGood = @@ -88,38 +91,76 @@ async Task InvokeAgentAsync(ChatCompletionAgent agent, KernelArguments? argument { Console.WriteLine($"Status: {exception.StatusCode}"); } + catch (ClientResultException cre) + { + Console.WriteLine($"Status: {cre.Status}"); + } } } - private Kernel CreateKernelWithTwoServices() + private Kernel CreateKernelWithTwoServices(bool useChatClient) { IKernelBuilder builder = Kernel.CreateBuilder(); - if (this.UseOpenAIConfig) + if (useChatClient) { - builder.AddOpenAIChatCompletion( - TestConfiguration.OpenAI.ChatModelId, - "bad-key", - serviceId: ServiceKeyBad); - - builder.AddOpenAIChatCompletion( - TestConfiguration.OpenAI.ChatModelId, - TestConfiguration.OpenAI.ApiKey, - serviceId: ServiceKeyGood); + // Add chat clients + if (this.UseOpenAIConfig) + { + builder.Services.AddKeyedChatClient( + ServiceKeyBad, + new OpenAI.OpenAIClient("bad-key").AsChatClient(TestConfiguration.OpenAI.ChatModelId)); + + builder.Services.AddKeyedChatClient( + ServiceKeyGood, + new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey).AsChatClient(TestConfiguration.OpenAI.ChatModelId)); + } + else + { + builder.Services.AddKeyedChatClient( + ServiceKeyBad, + new Azure.AI.OpenAI.AzureOpenAIClient( + new Uri(TestConfiguration.AzureOpenAI.Endpoint), + new Azure.AzureKeyCredential("bad-key")) + .AsChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName)); + + builder.Services.AddKeyedChatClient( + ServiceKeyGood, + new Azure.AI.OpenAI.AzureOpenAIClient( + new Uri(TestConfiguration.AzureOpenAI.Endpoint), + new Azure.AzureKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)) + .AsChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName)); + } } else { - builder.AddAzureOpenAIChatCompletion( - TestConfiguration.AzureOpenAI.ChatDeploymentName, - TestConfiguration.AzureOpenAI.Endpoint, - "bad-key", - serviceId: ServiceKeyBad); - - builder.AddAzureOpenAIChatCompletion( - TestConfiguration.AzureOpenAI.ChatDeploymentName, - TestConfiguration.AzureOpenAI.Endpoint, - TestConfiguration.AzureOpenAI.ApiKey, - serviceId: ServiceKeyGood); + // Add chat completion services + if (this.UseOpenAIConfig) + { + builder.AddOpenAIChatCompletion( + TestConfiguration.OpenAI.ChatModelId, + "bad-key", + serviceId: ServiceKeyBad); + + builder.AddOpenAIChatCompletion( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey, + serviceId: ServiceKeyGood); + } + else + { + builder.AddAzureOpenAIChatCompletion( + TestConfiguration.AzureOpenAI.ChatDeploymentName, + TestConfiguration.AzureOpenAI.Endpoint, + "bad-key", + serviceId: ServiceKeyBad); + + builder.AddAzureOpenAIChatCompletion( + TestConfiguration.AzureOpenAI.ChatDeploymentName, + TestConfiguration.AzureOpenAI.Endpoint, + TestConfiguration.AzureOpenAI.ApiKey, + serviceId: ServiceKeyGood); + } } return builder.Build(); diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_Streaming.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_Streaming.cs index ae9d965ff9a9..00c6fbf167d1 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_Streaming.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_Streaming.cs @@ -14,8 +14,10 @@ public class ChatCompletion_Streaming(ITestOutputHelper output) : BaseAgentsTest private const string ParrotName = "Parrot"; private const string ParrotInstructions = "Repeat the user message in the voice of a pirate and then end with a parrot sound."; - [Fact] - public async Task UseStreamingChatCompletionAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseStreamingChatCompletionAgent(bool useChatClient) { // Define the agent ChatCompletionAgent agent = @@ -23,7 +25,7 @@ public async Task UseStreamingChatCompletionAgentAsync() { Name = ParrotName, Instructions = ParrotInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; ChatHistory chat = []; @@ -35,10 +37,14 @@ public async Task UseStreamingChatCompletionAgentAsync() // Output the entire chat history DisplayChatHistory(chat); + + chatClient?.Dispose(); } - [Fact] - public async Task UseStreamingChatCompletionAgentWithPluginAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseStreamingChatCompletionAgentWithPlugin(bool useChatClient) { const string MenuInstructions = "Answer questions about the menu."; @@ -48,7 +54,7 @@ public async Task UseStreamingChatCompletionAgentWithPluginAsync() { Name = "Host", Instructions = MenuInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; @@ -64,6 +70,8 @@ public async Task UseStreamingChatCompletionAgentWithPluginAsync() // Output the entire chat history DisplayChatHistory(chat); + + chatClient?.Dispose(); } // Local function to invoke agent and display the conversation messages. diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_Templating.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_Templating.cs index 7372b7df19bc..9f560291e2db 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_Templating.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_Templating.cs @@ -20,14 +20,16 @@ private readonly static (string Input, string? Style)[] s_inputs = (Input: "What do you think about having fun?", Style: "old school rap") ]; - [Fact] - public async Task InvokeAgentWithInstructionsTemplateAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvokeAgentWithInstructionsTemplate(bool useChatClient) { // Instruction based template always processed by KernelPromptTemplateFactory ChatCompletionAgent agent = new() { - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), Instructions = """ Write a one verse poem on the requested topic in the style of {{$style}}. @@ -40,10 +42,14 @@ Always state the requested style of the poem. }; await InvokeChatCompletionAgentWithTemplateAsync(agent); + + chatClient?.Dispose(); } - [Fact] - public async Task InvokeAgentWithKernelTemplateAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvokeAgentWithKernelTemplate(bool useChatClient) { // Default factory is KernelPromptTemplateFactory await InvokeChatCompletionAgentWithTemplateAsync( @@ -52,11 +58,14 @@ Write a one verse poem on the requested topic in the style of {{$style}}. Always state the requested style of the poem. """, PromptTemplateConfig.SemanticKernelTemplateFormat, - new KernelPromptTemplateFactory()); + new KernelPromptTemplateFactory(), + useChatClient); } - [Fact] - public async Task InvokeAgentWithHandlebarsTemplateAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvokeAgentWithHandlebarsTemplate(bool useChatClient) { await InvokeChatCompletionAgentWithTemplateAsync( """ @@ -64,11 +73,14 @@ Write a one verse poem on the requested topic in the style of {{style}}. Always state the requested style of the poem. """, HandlebarsPromptTemplateFactory.HandlebarsTemplateFormat, - new HandlebarsPromptTemplateFactory()); + new HandlebarsPromptTemplateFactory(), + useChatClient); } - [Fact] - public async Task InvokeAgentWithLiquidTemplateAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvokeAgentWithLiquidTemplate(bool useChatClient) { await InvokeChatCompletionAgentWithTemplateAsync( """ @@ -76,13 +88,15 @@ Write a one verse poem on the requested topic in the style of {{style}}. Always state the requested style of the poem. """, LiquidPromptTemplateFactory.LiquidTemplateFormat, - new LiquidPromptTemplateFactory()); + new LiquidPromptTemplateFactory(), + useChatClient); } private async Task InvokeChatCompletionAgentWithTemplateAsync( string instructionTemplate, string templateFormat, - IPromptTemplateFactory templateFactory) + IPromptTemplateFactory templateFactory, + bool useChatClient) { // Define the agent PromptTemplateConfig templateConfig = @@ -94,7 +108,7 @@ private async Task InvokeChatCompletionAgentWithTemplateAsync( ChatCompletionAgent agent = new(templateConfig, templateFactory) { - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), Arguments = new KernelArguments() { {"style", "haiku"} @@ -102,6 +116,8 @@ private async Task InvokeChatCompletionAgentWithTemplateAsync( }; await InvokeChatCompletionAgentWithTemplateAsync(agent); + + chatClient?.Dispose(); } private async Task InvokeChatCompletionAgentWithTemplateAsync(ChatCompletionAgent agent) diff --git a/dotnet/samples/Concepts/Agents/ComplexChat_NestedShopper.cs b/dotnet/samples/Concepts/Agents/ComplexChat_NestedShopper.cs index 6f07fb739190..20327fbc5d50 100644 --- a/dotnet/samples/Concepts/Agents/ComplexChat_NestedShopper.cs +++ b/dotnet/samples/Concepts/Agents/ComplexChat_NestedShopper.cs @@ -92,8 +92,10 @@ Select which participant will take the next turn based on the conversation histo {{${{{KernelFunctionTerminationStrategy.DefaultHistoryVariableName}}}}} """; - [Fact] - public async Task NestedChatWithAggregatorAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task NestedChatWithAggregatorAgent(bool useChatClient) { Console.WriteLine($"! {Model}"); @@ -121,7 +123,7 @@ public async Task NestedChatWithAggregatorAgentAsync() new() { TerminationStrategy = - new KernelFunctionTerminationStrategy(outerTerminationFunction, CreateKernelWithChatCompletion()) + new KernelFunctionTerminationStrategy(outerTerminationFunction, CreateKernelWithChatCompletion(useChatClient, out var chatClient)) { ResultParser = (result) => @@ -158,6 +160,8 @@ public async Task NestedChatWithAggregatorAgentAsync() this.WriteAgentChatMessage(message); } + chatClient?.Dispose(); + async Task InvokeChatAsync(string input) { ChatMessageContent message = new(AuthorRole.User, input); diff --git a/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs b/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs index a8e98f2e107e..41f6d0af36ea 100644 --- a/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs +++ b/dotnet/samples/Concepts/Agents/DeclarativeAgents.cs @@ -6,15 +6,32 @@ namespace Agents; +/// +/// Sample showing how declarative agents can be defined through JSON manifest files. +/// Demonstrates how to load and configure an agent from a declarative manifest that specifies: +/// - The agent's identity (name, description, instructions) +/// - The agent's available actions/plugins +/// - Authentication parameters for accessing external services +/// +/// +/// The test uses a SchedulingAssistant example that can: +/// - Read emails for meeting requests +/// - Check calendar availability +/// - Process scheduling-related tasks +/// The agent is configured via "SchedulingAssistant.json" manifest which defines the required +/// plugins and capabilities. +/// public class DeclarativeAgents(ITestOutputHelper output) : BaseAgentsTest(output) { - [InlineData( - "SchedulingAssistant.json", - "Read the body of my last five emails, if any contain a meeting request for today, check that it's already on my calendar, if not, call out which email it is.")] [Theory] - public async Task LoadsAgentFromDeclarativeAgentManifestAsync(string agentFileName, string input) + [InlineData(true)] + [InlineData(false)] + public async Task LoadsAgentFromDeclarativeAgentManifest(bool useChatClient) { - var kernel = this.CreateKernelWithChatCompletion(); + var agentFileName = "SchedulingAssistant.json"; + var input = "Read the body of my last five emails, if any contain a meeting request for today, check that it's already on my calendar, if not, call out which email it is."; + + var kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient); kernel.AutoFunctionInvocationFilters.Add(new ExpectedSchemaFunctionFilter()); var manifestLookupDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "Resources", "DeclarativeAgents"); var manifestFilePath = Path.Combine(manifestLookupDirectory, agentFileName); @@ -45,6 +62,8 @@ public async Task LoadsAgentFromDeclarativeAgentManifestAsync(string agentFileNa var responses = await agent.InvokeAsync(chatHistory, kernelArguments).ToArrayAsync(); Assert.NotEmpty(responses); + + chatClient?.Dispose(); } private sealed class ExpectedSchemaFunctionFilter : IAutoFunctionInvocationFilter diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Agents.cs b/dotnet/samples/Concepts/Agents/MixedChat_Agents.cs index 0895308f0215..887f5c95a7f7 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Agents.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Agents.cs @@ -33,8 +33,10 @@ Only provide a single proposal per response. Consider suggestions when refining an idea. """; - [Fact] - public async Task ChatWithOpenAIAssistantAgentAndChatCompletionAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ChatWithOpenAIAssistantAgentAndChatCompletionAgent(bool useChatClient) { // Define the agents: one of each type ChatCompletionAgent agentReviewer = @@ -42,7 +44,7 @@ public async Task ChatWithOpenAIAssistantAgentAndChatCompletionAgentAsync() { Instructions = ReviewerInstructions, Name = ReviewerName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Define the assistant @@ -87,6 +89,8 @@ await this.AssistantClient.CreateAssistantAsync( } Console.WriteLine($"\n[IS COMPLETED: {chat.IsComplete}]"); + + chatClient?.Dispose(); } private sealed class ApprovalTerminationStrategy : TerminationStrategy diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Files.cs b/dotnet/samples/Concepts/Agents/MixedChat_Files.cs index 56ff0f331f0b..77bf16975c4f 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Files.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Files.cs @@ -16,8 +16,10 @@ public class MixedChat_Files(ITestOutputHelper output) : BaseAssistantTest(outpu { private const string SummaryInstructions = "Summarize the entire conversation for the user in natural language."; - [Fact] - public async Task AnalyzeFileAndGenerateReportAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AnalyzeFileAndGenerateReport(bool useChatClient) { await using Stream stream = EmbeddedResource.ReadStream("30-user-context.txt")!; string fileId = await this.Client.UploadAssistantFileAsync(stream, "30-user-context.txt"); @@ -38,7 +40,7 @@ await this.AssistantClient.CreateAssistantAsync( new() { Instructions = SummaryInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Create a chat for agent interaction. @@ -61,6 +63,8 @@ Create a tab delimited file report of the ordered (descending) frequency distrib await this.Client.DeleteFileAsync(fileId); } + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(Agent agent, string? input = null) { diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Images.cs b/dotnet/samples/Concepts/Agents/MixedChat_Images.cs index 158da60e418a..825218a5f8cd 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Images.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Images.cs @@ -19,8 +19,10 @@ public class MixedChat_Images(ITestOutputHelper output) : BaseAssistantTest(outp private const string SummarizerName = "Summarizer"; private const string SummarizerInstructions = "Summarize the entire conversation for the user in natural language."; - [Fact] - public async Task AnalyzeDataAndGenerateChartAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AnalyzeDataAndGenerateChartAsync(bool useChatClient) { // Define the assistant Assistant assistant = @@ -39,7 +41,7 @@ await this.AssistantClient.CreateAssistantAsync( { Instructions = SummarizerInstructions, Name = SummarizerName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Create a chat for agent interaction. @@ -73,6 +75,8 @@ await InvokeAgentAsync( await this.AssistantClient.DeleteAssistantAsync(analystAgent.Id); } + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(Agent agent, string? input = null) { diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Reset.cs b/dotnet/samples/Concepts/Agents/MixedChat_Reset.cs index 431dcc982a5e..a12dc5087b6c 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Reset.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Reset.cs @@ -18,8 +18,10 @@ The user may either provide information or query on information previously provi If the query does not correspond with information provided, inform the user that their query cannot be answered. """; - [Fact] - public async Task ResetChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ResetChat(bool useChatClient) { // Define the assistant Assistant assistant = @@ -36,7 +38,7 @@ await this.AssistantClient.CreateAssistantAsync( { Name = nameof(ChatCompletionAgent), Instructions = AgentInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Create a chat for agent interaction. @@ -65,6 +67,8 @@ await this.AssistantClient.CreateAssistantAsync( await this.AssistantClient.DeleteAssistantAsync(assistantAgent.Id); } + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(Agent agent, string? input = null) { diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs b/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs index 4979ceedacb1..45f220b1a64a 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Serialization.cs @@ -28,8 +28,10 @@ Never repeat the same number. Only respond with a single number that is the result of your calculation without explanation. """; - [Fact] - public async Task SerializeAndRestoreAgentGroupChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeAndRestoreAgentGroupChat(bool useChatClient) { // Define the agents: one of each type ChatCompletionAgent agentTranslator = @@ -37,7 +39,7 @@ public async Task SerializeAndRestoreAgentGroupChatAsync() { Instructions = TranslatorInstructions, Name = TranslatorName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Define the assistant @@ -74,6 +76,8 @@ await this.AssistantClient.CreateAssistantAsync( this.WriteAgentChatMessage(content); } + chatClient?.Dispose(); + async Task InvokeAgents(AgentGroupChat chat) { await foreach (ChatMessageContent content in chat.InvokeAsync()) diff --git a/dotnet/samples/Concepts/Agents/MixedChat_Streaming.cs b/dotnet/samples/Concepts/Agents/MixedChat_Streaming.cs index fc28c3c683dd..31655862f1ba 100644 --- a/dotnet/samples/Concepts/Agents/MixedChat_Streaming.cs +++ b/dotnet/samples/Concepts/Agents/MixedChat_Streaming.cs @@ -34,8 +34,10 @@ Only provide a single proposal per response. Consider suggestions when refining an idea. """; - [Fact] - public async Task UseStreamingAgentChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseStreamingAgentChat(bool useChatClient) { // Define the agents: one of each type ChatCompletionAgent agentReviewer = @@ -43,7 +45,7 @@ public async Task UseStreamingAgentChatAsync() { Instructions = ReviewerInstructions, Name = ReviewerName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Define the assistant @@ -112,6 +114,8 @@ await this.AssistantClient.CreateAssistantAsync( } Console.WriteLine($"\n[IS COMPLETED: {chat.IsComplete}]"); + + chatClient?.Dispose(); } private sealed class ApprovalTerminationStrategy : TerminationStrategy diff --git a/dotnet/samples/Concepts/Concepts.csproj b/dotnet/samples/Concepts/Concepts.csproj index 0e914e4f5c57..587db2cb8dae 100644 --- a/dotnet/samples/Concepts/Concepts.csproj +++ b/dotnet/samples/Concepts/Concepts.csproj @@ -8,7 +8,7 @@ false true - $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,CA1724,IDE1006 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,CA1724,IDE1006,IDE0009 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 133c8902a450..147ff1c40203 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -35,6 +35,7 @@ all + diff --git a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj index ffc4734e10d6..354a24cdb24e 100644 --- a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj +++ b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj @@ -9,7 +9,7 @@ true - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001 + $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,IDE1006 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 @@ -17,6 +17,7 @@ + diff --git a/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs b/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs index 3807c1ebef74..8e6672fe545a 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs @@ -15,8 +15,10 @@ public class Step01_Agent(ITestOutputHelper output) : BaseAgentsTest(output) private const string ParrotName = "Parrot"; private const string ParrotInstructions = "Repeat the user message in the voice of a pirate and then end with a parrot sound."; - [Fact] - public async Task UseSingleChatCompletionAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseSingleChatCompletionAgent(bool useChatClient) { Kernel kernel = this.CreateKernelWithChatCompletion(); @@ -26,7 +28,7 @@ public async Task UseSingleChatCompletionAgentAsync() { Name = ParrotName, Instructions = ParrotInstructions, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; /// Create the chat history to capture the agent interaction. @@ -37,6 +39,8 @@ public async Task UseSingleChatCompletionAgentAsync() await InvokeAgentAsync("I came, I saw, I conquered."); await InvokeAgentAsync("Practice makes perfect."); + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(string input) { @@ -53,8 +57,10 @@ async Task InvokeAgentAsync(string input) } } - [Fact] - public async Task UseTemplateForChatCompletionAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseTemplateForChatCompletionAgent(bool useChatClient) { // Define the agent string generateStoryYaml = EmbeddedResource.Read("GenerateStory.yaml"); @@ -65,7 +71,7 @@ public async Task UseTemplateForChatCompletionAgentAsync() ChatCompletionAgent agent = new(templateConfig, templateFactory) { - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), Arguments = { { "topic", "Dog" }, @@ -87,6 +93,8 @@ await InvokeAgentAsync( { "length", "3" }, }); + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(KernelArguments? arguments = null) { diff --git a/dotnet/samples/GettingStartedWithAgents/Step02_Plugins.cs b/dotnet/samples/GettingStartedWithAgents/Step02_Plugins.cs index ced4148a7287..9f8699420471 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step02_Plugins.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step02_Plugins.cs @@ -13,14 +13,17 @@ namespace GettingStarted; /// public class Step02_Plugins(ITestOutputHelper output) : BaseAgentsTest(output) { - [Fact] - public async Task UseChatCompletionWithPluginAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseChatCompletionWithPlugin(bool useChatClient) { // Define the agent ChatCompletionAgent agent = CreateAgentWithPlugin( plugin: KernelPluginFactory.CreateFromType(), instructions: "Answer questions about the menu.", - name: "Host"); + name: "Host", + useChatClient: useChatClient); /// Create the chat history to capture the agent interaction. ChatHistory chat = []; @@ -32,12 +35,15 @@ public async Task UseChatCompletionWithPluginAsync() await InvokeAgentAsync(agent, chat, "Thank you"); } - [Fact] - public async Task UseChatCompletionWithPluginEnumParameterAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseChatCompletionWithPluginEnumParameter(bool useChatClient) { // Define the agent ChatCompletionAgent agent = CreateAgentWithPlugin( - KernelPluginFactory.CreateFromType()); + KernelPluginFactory.CreateFromType(), + useChatClient: useChatClient); /// Create the chat history to capture the agent interaction. ChatHistory chat = []; @@ -46,8 +52,10 @@ public async Task UseChatCompletionWithPluginEnumParameterAsync() await InvokeAgentAsync(agent, chat, "Create a beautiful red colored widget for me."); } - [Fact] - public async Task UseChatCompletionWithTemplateExecutionSettingsAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseChatCompletionWithTemplateExecutionSettings(bool useChatClient) { // Read the template resource string autoInvokeYaml = EmbeddedResource.Read("AutoInvokeTools.yaml"); @@ -59,7 +67,7 @@ public async Task UseChatCompletionWithTemplateExecutionSettingsAsync() ChatCompletionAgent agent = new(templateConfig, templateFactory) { - Kernel = this.CreateKernelWithChatCompletion() + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; agent.Kernel.Plugins.AddFromType(); @@ -69,19 +77,22 @@ public async Task UseChatCompletionWithTemplateExecutionSettingsAsync() // Respond to user input, invoking functions where appropriate. await InvokeAgentAsync(agent, chat, "Create a beautiful red colored widget for me."); + + chatClient?.Dispose(); } private ChatCompletionAgent CreateAgentWithPlugin( KernelPlugin plugin, string? instructions = null, - string? name = null) + string? name = null, + bool useChatClient = false) { ChatCompletionAgent agent = new() { Instructions = instructions, Name = name, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out _), Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), }; diff --git a/dotnet/samples/GettingStartedWithAgents/Step03_Chat.cs b/dotnet/samples/GettingStartedWithAgents/Step03_Chat.cs index 1ada85d512f3..5f4c84b7ac1d 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step03_Chat.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step03_Chat.cs @@ -33,8 +33,10 @@ Only provide a single proposal per response. Consider suggestions when refining an idea. """; - [Fact] - public async Task UseAgentGroupChatWithTwoAgentsAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseAgentGroupChatWithTwoAgents(bool useChatClient) { // Define the agents ChatCompletionAgent agentReviewer = @@ -42,7 +44,7 @@ public async Task UseAgentGroupChatWithTwoAgentsAsync() { Instructions = ReviewerInstructions, Name = ReviewerName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient1), }; ChatCompletionAgent agentWriter = @@ -50,7 +52,7 @@ public async Task UseAgentGroupChatWithTwoAgentsAsync() { Instructions = CopyWriterInstructions, Name = CopyWriterName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient2), }; // Create a chat for agent interaction. @@ -84,6 +86,9 @@ public async Task UseAgentGroupChatWithTwoAgentsAsync() } Console.WriteLine($"\n[IS COMPLETED: {chat.IsComplete}]"); + + chatClient1?.Dispose(); + chatClient2?.Dispose(); } private sealed class ApprovalTerminationStrategy : TerminationStrategy diff --git a/dotnet/samples/GettingStartedWithAgents/Step04_KernelFunctionStrategies.cs b/dotnet/samples/GettingStartedWithAgents/Step04_KernelFunctionStrategies.cs index 963b670f1f82..d6c581a4366a 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step04_KernelFunctionStrategies.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step04_KernelFunctionStrategies.cs @@ -34,8 +34,10 @@ Never delimit the response with quotation marks. Consider suggestions when refining an idea. """; - [Fact] - public async Task UseKernelFunctionStrategiesWithAgentGroupChatAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseKernelFunctionStrategiesWithAgentGroupChat(bool useChatClient) { // Define the agents ChatCompletionAgent agentReviewer = @@ -43,7 +45,7 @@ public async Task UseKernelFunctionStrategiesWithAgentGroupChatAsync() { Instructions = ReviewerInstructions, Name = ReviewerName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient1), }; ChatCompletionAgent agentWriter = @@ -51,7 +53,7 @@ public async Task UseKernelFunctionStrategiesWithAgentGroupChatAsync() { Instructions = CopyWriterInstructions, Name = CopyWriterName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient2), }; KernelFunction terminationFunction = @@ -139,5 +141,8 @@ No participant should take more than one turn in a row. } Console.WriteLine($"\n[IS COMPLETED: {chat.IsComplete}]"); + + chatClient1?.Dispose(); + chatClient2?.Dispose(); } } diff --git a/dotnet/samples/GettingStartedWithAgents/Step05_JsonResult.cs b/dotnet/samples/GettingStartedWithAgents/Step05_JsonResult.cs index 8806c7d3b62d..9fad5413bccc 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step05_JsonResult.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step05_JsonResult.cs @@ -27,8 +27,10 @@ Think step-by-step and rate the user input on creativity and expressiveness from } """; - [Fact] - public async Task UseKernelFunctionStrategiesWithJsonResultAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseKernelFunctionStrategiesWithJsonResult(bool useChatClient) { // Define the agents ChatCompletionAgent agent = @@ -36,7 +38,7 @@ public async Task UseKernelFunctionStrategiesWithJsonResultAsync() { Instructions = TutorInstructions, Name = TutorName, - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), }; // Create a chat for agent interaction. @@ -57,6 +59,8 @@ public async Task UseKernelFunctionStrategiesWithJsonResultAsync() await InvokeAgentAsync("The sunset is setting over the mountains."); await InvokeAgentAsync("The sunset is setting over the mountains and filled the sky with a deep red flame, setting the clouds ablaze."); + chatClient?.Dispose(); + // Local function to invoke agent and display the conversation messages. async Task InvokeAgentAsync(string input) { diff --git a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs index 276f2f6fb198..8935e4d66d48 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs @@ -1,5 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel; +using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; @@ -26,25 +29,66 @@ Think step-by-step and rate the user input on creativity and expressiveness from } """; - [Fact] - public async Task UseDependencyInjectionToCreateAgentAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UseDependencyInjectionToCreateAgentAsync(bool useChatClient) { ServiceCollection serviceContainer = new(); serviceContainer.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Information)); - if (this.UseOpenAIConfig) + if (useChatClient) { - serviceContainer.AddOpenAIChatCompletion( - TestConfiguration.OpenAI.ChatModelId, - TestConfiguration.OpenAI.ApiKey); + IChatClient chatClient; + if (this.UseOpenAIConfig) + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey), + TestConfiguration.OpenAI.ChatModelId); + } + else if (!string.IsNullOrEmpty(this.ApiKey)) + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + openAIClient: new AzureOpenAIClient( + endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), + credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)), + modelId: TestConfiguration.AzureOpenAI.ChatModelId); + } + else + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + openAIClient: new AzureOpenAIClient( + endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), + credential: new AzureCliCredential()), + modelId: TestConfiguration.AzureOpenAI.ChatModelId); + } + + var functionCallingChatClient = chatClient!.AsKernelFunctionInvokingChatClient(); + serviceContainer.AddTransient((sp) => functionCallingChatClient); } else { - serviceContainer.AddAzureOpenAIChatCompletion( - TestConfiguration.AzureOpenAI.ChatDeploymentName, - TestConfiguration.AzureOpenAI.Endpoint, - new AzureCliCredential()); + if (this.UseOpenAIConfig) + { + serviceContainer.AddOpenAIChatCompletion( + TestConfiguration.OpenAI.ChatModelId, + TestConfiguration.OpenAI.ApiKey); + } + else if (!string.IsNullOrEmpty(this.ApiKey)) + { + serviceContainer.AddAzureOpenAIChatCompletion( + TestConfiguration.AzureOpenAI.ChatDeploymentName, + TestConfiguration.AzureOpenAI.Endpoint, + TestConfiguration.AzureOpenAI.ApiKey); + } + else + { + serviceContainer.AddAzureOpenAIChatCompletion( + TestConfiguration.AzureOpenAI.ChatDeploymentName, + TestConfiguration.AzureOpenAI.Endpoint, + new AzureCliCredential()); + } } // Transient Kernel as each agent may customize its Kernel instance with plug-ins. diff --git a/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs b/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs index 832ce0b1db02..658f5a4e091c 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs @@ -31,10 +31,12 @@ public class Step07_Telemetry(ITestOutputHelper output) : BaseAssistantTest(outp /// Logging is enabled through the and properties. /// This example uses to output logs to the test console, but any compatible logging provider can be used. /// - [Fact] - public async Task LoggingAsync() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LoggingAsync(bool useChatClient) { - await RunExampleAsync(loggerFactory: this.LoggerFactory); + await RunExampleAsync(loggerFactory: this.LoggerFactory, useChatClient: useChatClient); // Output: // [AddChatMessages] Adding Messages: 1. @@ -51,18 +53,22 @@ public async Task LoggingAsync() /// For output this example uses Console as well as Application Insights. /// [Theory] - [InlineData(true, false)] - [InlineData(false, false)] - [InlineData(true, true)] - [InlineData(false, true)] - public async Task TracingAsync(bool useApplicationInsights, bool useStreaming) + [InlineData(true, false, false)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + [InlineData(false, false, true)] + [InlineData(true, true, true)] + [InlineData(false, true, true)] + public async Task TracingAsync(bool useApplicationInsights, bool useStreaming, bool useChatClient) { using var tracerProvider = GetTracerProvider(useApplicationInsights); using var activity = s_activitySource.StartActivity("MainActivity"); Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}"); - await RunExampleAsync(useStreaming: useStreaming); + await RunExampleAsync(useStreaming: useStreaming, useChatClient: useChatClient); // Output: // Operation/Trace ID: 132d831ef39c13226cdaa79873f375b8 @@ -82,7 +88,8 @@ public async Task TracingAsync(bool useApplicationInsights, bool useStreaming) private async Task RunExampleAsync( bool useStreaming = false, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + bool useChatClient = false) { // Define the agents ChatCompletionAgent agentReviewer = @@ -97,7 +104,7 @@ private async Task RunExampleAsync( If not, provide insight on how to refine suggested copy without examples. """, Description = "An art director who has opinions about copywriting born of a love for David Ogilvy", - Kernel = this.CreateKernelWithChatCompletion(), + Kernel = this.CreateKernelWithChatCompletion(useChatClient, out var chatClient), LoggerFactory = GetLoggerFactoryOrDefault(loggerFactory), }; @@ -190,6 +197,8 @@ Consider suggestions when refining an idea. } Console.WriteLine($"\n[IS COMPLETED: {chat.IsComplete}]"); + + chatClient?.Dispose(); } private TracerProvider? GetTracerProvider(bool useApplicationInsights) diff --git a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj index 911139b69872..bd9433aec82d 100644 --- a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj +++ b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj @@ -18,6 +18,7 @@ + diff --git a/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj b/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj index aa82613fa278..29e91554d092 100644 --- a/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj +++ b/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj @@ -22,6 +22,7 @@ all + diff --git a/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj b/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj index 7a33e7c2fa3b..e07f4f979be1 100644 --- a/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj +++ b/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj @@ -21,6 +21,7 @@ all + diff --git a/dotnet/samples/LearnResources/LearnResources.csproj b/dotnet/samples/LearnResources/LearnResources.csproj index f347bb620e21..faba2b777740 100644 --- a/dotnet/samples/LearnResources/LearnResources.csproj +++ b/dotnet/samples/LearnResources/LearnResources.csproj @@ -34,6 +34,7 @@ all + diff --git a/dotnet/src/Agents/OpenAI/Agents.OpenAI.csproj b/dotnet/src/Agents/OpenAI/Agents.OpenAI.csproj index 315389afd386..1c9a7dec96e7 100644 --- a/dotnet/src/Agents/OpenAI/Agents.OpenAI.csproj +++ b/dotnet/src/Agents/OpenAI/Agents.OpenAI.csproj @@ -27,7 +27,7 @@ - + diff --git a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs index 1ce8039b250d..5acf78415aa0 100644 --- a/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs +++ b/dotnet/src/Agents/UnitTests/Core/ChatCompletionAgentTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; @@ -146,6 +148,43 @@ public async Task VerifyChatCompletionAgentInvocationAsync() Times.Once); } + /// + /// Verify the invocation and response of using . + /// + [Fact] + public async Task VerifyChatClientAgentInvocationAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "what?")])); + + ChatCompletionAgent agent = + new() + { + Instructions = "test instructions", + Kernel = CreateKernel(mockService.Object), + Arguments = [], + }; + + // Act + ChatMessageContent[] result = await agent.InvokeAsync([]).ToArrayAsync(); + + // Assert + Assert.Single(result); + + mockService.Verify( + x => + x.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + /// /// Verify the streaming invocation and response of . /// @@ -191,6 +230,49 @@ public async Task VerifyChatCompletionAgentStreamingAsync() Times.Once); } + /// + /// Verify the streaming invocation and response of using . + /// + [Fact] + public async Task VerifyChatClientAgentStreamingAsync() + { + // Arrange + ChatResponseUpdate[] returnUpdates = + [ + new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh"), + new ChatResponseUpdate(role: null, content: "at?"), + ]; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Returns(returnUpdates.ToAsyncEnumerable()); + + ChatCompletionAgent agent = + new() + { + Instructions = "test instructions", + Kernel = CreateKernel(mockService.Object), + Arguments = [], + }; + + // Act + StreamingChatMessageContent[] result = await agent.InvokeStreamingAsync([]).ToArrayAsync(); + + // Assert + Assert.Equal(2, result.Length); + + mockService.Verify( + x => + x.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + /// /// Verify the invocation and response of . /// @@ -217,6 +299,32 @@ public void VerifyChatCompletionServiceSelection() Assert.Throws(() => ChatCompletionAgent.GetChatCompletionService(kernel, new KernelArguments(new PromptExecutionSettings() { ServiceId = "anything" }))); } + /// + /// Verify the invocation and response of using . + /// + [Fact] + public void VerifyChatClientSelection() + { + // Arrange + Mock mockClient = new(); + Kernel kernel = CreateKernel(mockClient.Object); + + // Act + (IChatCompletionService client, PromptExecutionSettings? settings) = ChatCompletionAgent.GetChatCompletionService(kernel, null); + // Assert + Assert.Equal("ChatClientChatCompletionService", client.GetType().Name); + Assert.Null(settings); + + // Act + (client, settings) = ChatCompletionAgent.GetChatCompletionService(kernel, []); + // Assert + Assert.Equal("ChatClientChatCompletionService", client.GetType().Name); + Assert.Null(settings); + + // Act and Assert + Assert.Throws(() => ChatCompletionAgent.GetChatCompletionService(kernel, new KernelArguments(new PromptExecutionSettings() { ServiceId = "anything" }))); + } + /// /// Verify the invocation and response of . /// @@ -243,4 +351,11 @@ private static Kernel CreateKernel(IChatCompletionService chatCompletionService) builder.Services.AddSingleton(chatCompletionService); return builder.Build(); } + + private static Kernel CreateKernel(IChatClient chatClient) + { + var builder = Kernel.CreateBuilder(); + builder.Services.AddSingleton(chatClient); + return builder.Build(); + } } diff --git a/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs index 0c4da36cc03a..4c48ea2458e3 100644 --- a/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.Amazon.UnitTests/Services/BedrockTextEmbeddingGenerationServiceTests.cs @@ -1,5 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Amazon.BedrockRuntime; using Amazon.Runtime; using Microsoft.Extensions.DependencyInjection; diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 6b375e94cc62..5e1410cf0e48 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -832,7 +832,7 @@ private static List CreateRequestMessages(ChatMessageContent messag // HTTP 400 (invalid_request_error:) [] should be non-empty - 'messages.3.tool_calls' if (toolCalls.Count == 0) { - return [new AssistantChatMessage(message.Content) { ParticipantName = message.AuthorName }]; + return [new AssistantChatMessage(message.Content ?? string.Empty) { ParticipantName = message.AuthorName }]; } var assistantMessage = new AssistantChatMessage(SanitizeFunctionNames(toolCalls)) { ParticipantName = message.AuthorName }; diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs index 78816c97e2e2..3a2b5766ed35 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs @@ -1,9 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel; using System.Reflection; using System.Text; using System.Text.Json; +using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -46,10 +50,21 @@ public abstract class BaseTest : TextWriter protected bool UseBingSearch => TestConfiguration.Bing.ApiKey is not null; protected Kernel CreateKernelWithChatCompletion() + => this.CreateKernelWithChatCompletion(useChatClient: false, out _); + + protected Kernel CreateKernelWithChatCompletion(bool useChatClient, out IChatClient? chatClient) { var builder = Kernel.CreateBuilder(); - AddChatCompletionToKernel(builder); + if (useChatClient) + { + chatClient = AddChatClientToKernel(builder); + } + else + { + chatClient = null; + AddChatCompletionToKernel(builder); + } return builder.Build(); } @@ -78,6 +93,39 @@ protected void AddChatCompletionToKernel(IKernelBuilder builder) } } + protected IChatClient AddChatClientToKernel(IKernelBuilder builder) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + IChatClient chatClient; + if (this.UseOpenAIConfig) + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey), + TestConfiguration.OpenAI.ChatModelId); + } + else if (!string.IsNullOrEmpty(this.ApiKey)) + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + openAIClient: new AzureOpenAIClient( + endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), + credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)), + modelId: TestConfiguration.AzureOpenAI.ChatModelId); + } + else + { + chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( + openAIClient: new AzureOpenAIClient( + endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), + credential: new AzureCliCredential()), + modelId: TestConfiguration.AzureOpenAI.ChatModelId); + } + + var functionCallingChatClient = chatClient!.AsKernelFunctionInvokingChatClient(); + builder.Services.AddTransient((sp) => functionCallingChatClient); + return functionCallingChatClient; +#pragma warning restore CA2000 // Dispose objects before losing scope + } + protected BaseTest(ITestOutputHelper output, bool redirectSystemConsoleOutput = false) { this.Output = output; diff --git a/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyDictionary.cs b/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyDictionary.cs new file mode 100644 index 000000000000..a013d3556df5 --- /dev/null +++ b/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyDictionary.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; + +#pragma warning disable IDE0009 // use this directive +#pragma warning disable CA1716 + +// Original source from +// https://raw.githubusercontent.com/dotnet/extensions/main/src/Shared/EmptyCollections/EmptyReadOnlyList.cs + +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +internal sealed class EmptyReadOnlyDictionary : IReadOnlyDictionary, IDictionary + where TKey : notnull +{ + public static readonly EmptyReadOnlyDictionary Instance = new(); + + public int Count => 0; + public TValue this[TKey key] => throw new KeyNotFoundException(); + public bool ContainsKey(TKey key) => false; + public IEnumerable Keys => EmptyReadOnlyList.Instance; + public IEnumerable Values => EmptyReadOnlyList.Instance; + + public IEnumerator> GetEnumerator() => EmptyReadOnlyList>.Instance.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + ICollection IDictionary.Keys => Array.Empty(); + ICollection IDictionary.Values => Array.Empty(); + bool ICollection>.IsReadOnly => true; + TValue IDictionary.this[TKey key] + { + get => throw new KeyNotFoundException(); + set => throw new NotSupportedException(); + } + + public bool TryGetValue(TKey key, out TValue value) + { +#pragma warning disable CS8601 // The recommended implementation: https://docs.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2.trygetvalue + value = default; +#pragma warning restore + + return false; + } + + void ICollection>.Clear() + { + // nop + } + + void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) + { + // nop + } + + void IDictionary.Add(TKey key, TValue value) => throw new NotSupportedException(); + bool IDictionary.Remove(TKey key) => false; + void ICollection>.Add(KeyValuePair item) => throw new NotSupportedException(); + bool ICollection>.Contains(KeyValuePair item) => false; + bool ICollection>.Remove(KeyValuePair item) => false; +} diff --git a/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyList.cs b/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyList.cs new file mode 100644 index 000000000000..b2c730958691 --- /dev/null +++ b/dotnet/src/InternalUtilities/src/EmptyCollections/EmptyReadonlyList.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; + +#pragma warning disable IDE0009 // use this directive +#pragma warning disable CA1716 + +// Original source from +// https://raw.githubusercontent.com/dotnet/extensions/main/src/Shared/EmptyCollections/EmptyReadOnlyList.cs + +[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Static field, lifetime matches the process")] +internal sealed class EmptyReadOnlyList : IReadOnlyList, ICollection +{ + public static readonly EmptyReadOnlyList Instance = new(); + private readonly Enumerator _enumerator = new(); + + public IEnumerator GetEnumerator() => _enumerator; + IEnumerator IEnumerable.GetEnumerator() => _enumerator; + public int Count => 0; + public T this[int index] => throw new ArgumentOutOfRangeException(nameof(index)); + + void ICollection.CopyTo(T[] array, int arrayIndex) + { + // nop + } + + bool ICollection.Contains(T item) => false; + bool ICollection.IsReadOnly => true; + void ICollection.Add(T item) => throw new NotSupportedException(); + bool ICollection.Remove(T item) => false; + + void ICollection.Clear() + { + // nop + } + + internal sealed class Enumerator : IEnumerator + { + public void Dispose() + { + // nop + } + + public void Reset() + { + // nop + } + + public bool MoveNext() => false; + public T Current => throw new InvalidOperationException(); + object IEnumerator.Current => throw new InvalidOperationException(); + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs new file mode 100644 index 000000000000..a0d6b1865a8f --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs @@ -0,0 +1,631 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +#pragma warning disable IDE0009 // Use explicit 'this.' qualifier +#pragma warning disable IDE1006 // Missing static prefix s_ suffix + +namespace Microsoft.SemanticKernel.ChatCompletion; + +// Slight modified source from +// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs + +/// Provides factory methods for creating commonly used implementations of . +[ExcludeFromCodeCoverage] +internal static partial class AIFunctionFactory +{ + /// Holds the default options instance used when creating function. + private static readonly AIFunctionFactoryOptions _defaultOptions = new(); + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// Metadata to use to override defaults inferred from . + /// The created for invoking . + /// + /// + /// Return values are serialized to using 's + /// . Arguments that are not already of the expected type are + /// marshaled to the expected type via JSON and using 's + /// . If the argument is a , + /// , or , it is deserialized directly. If the argument is anything else unknown, + /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// + public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? options) + { + Verify.NotNull(method); + + return ReflectionAIFunction.Build(method.Method, method.Target, options ?? _defaultOptions); + } + + /// Creates an instance for a method, specified via a delegate. + /// The method to be represented via the created . + /// The name to use for the . + /// The description to use for the . + /// The used to marshal function parameters and any return value. + /// The created for invoking . + /// + /// + /// Return values are serialized to using . + /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using + /// . If the argument is a , , + /// or , it is deserialized directly. If the argument is anything else unknown, it is + /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// + public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) + { + Verify.NotNull(method); + + AIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null + ? _defaultOptions + : new() + { + Name = name, + Description = description, + SerializerOptions = serializerOptions, + }; + + return ReflectionAIFunction.Build(method.Method, method.Target, createOptions); + } + + /// + /// Creates an instance for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// Metadata to use to override defaults inferred from . + /// The created for invoking . + /// + /// + /// Return values are serialized to using 's + /// . Arguments that are not already of the expected type are + /// marshaled to the expected type via JSON and using 's + /// . If the argument is a , + /// , or , it is deserialized directly. If the argument is anything else unknown, + /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// + public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryOptions? options) + { + Verify.NotNull(method); + + return ReflectionAIFunction.Build(method, target, options ?? _defaultOptions); + } + + /// + /// Creates an instance for a method, specified via an instance + /// and an optional target object if the method is an instance method. + /// + /// The method to be represented via the created . + /// + /// The target object for the if it represents an instance method. + /// This should be if and only if is a static method. + /// + /// The name to use for the . + /// The description to use for the . + /// The used to marshal function parameters and return value. + /// The created for invoking . + /// + /// + /// Return values are serialized to using . + /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using + /// . If the argument is a , , + /// or , it is deserialized directly. If the argument is anything else unknown, it is + /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. + /// + /// + public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) + { + Verify.NotNull(method); + + AIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null + ? _defaultOptions + : new() + { + Name = name, + Description = description, + SerializerOptions = serializerOptions, + }; + + return ReflectionAIFunction.Build(method, target, createOptions); + } + + private sealed class ReflectionAIFunction : AIFunction + { + public static ReflectionAIFunction Build(MethodInfo method, object? target, AIFunctionFactoryOptions options) + { + Verify.NotNull(method); + + if (method.ContainsGenericParameters) + { + throw new ArgumentException("Open generic methods are not supported", nameof(method)); + } + + if (!method.IsStatic && target is null) + { + throw new ArgumentNullException(nameof(target), "Target must not be null for an instance method."); + } + + ReflectionAIFunctionDescriptor functionDescriptor = ReflectionAIFunctionDescriptor.GetOrCreate(method, options); + + if (target is null && options.AdditionalProperties is null) + { + // We can use a cached value for static methods not specifying additional properties. + return functionDescriptor.CachedDefaultInstance ??= new(functionDescriptor, target, options); + } + + return new(functionDescriptor, target, options); + } + + private ReflectionAIFunction(ReflectionAIFunctionDescriptor functionDescriptor, object? target, AIFunctionFactoryOptions options) + { + FunctionDescriptor = functionDescriptor; + Target = target; + AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance; + } + + public ReflectionAIFunctionDescriptor FunctionDescriptor { get; } + public object? Target { get; } + public override IReadOnlyDictionary AdditionalProperties { get; } + public override string Name => FunctionDescriptor.Name; + public override string Description => FunctionDescriptor.Description; + public override MethodInfo UnderlyingMethod => FunctionDescriptor.Method; + public override JsonElement JsonSchema => FunctionDescriptor.JsonSchema; + public override JsonSerializerOptions JsonSerializerOptions => FunctionDescriptor.JsonSerializerOptions; + protected override Task InvokeCoreAsync( + IEnumerable>? arguments, + CancellationToken cancellationToken) + { + var paramMarshallers = FunctionDescriptor.ParameterMarshallers; + object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : []; + + IReadOnlyDictionary argDict = + arguments is null || args.Length == 0 ? EmptyReadOnlyDictionary.Instance : + arguments as IReadOnlyDictionary ?? + arguments. +#if NET8_0_OR_GREATER + ToDictionary(); +#else + ToDictionary(kvp => kvp.Key, kvp => kvp.Value); +#endif + for (int i = 0; i < args.Length; i++) + { + args[i] = paramMarshallers[i](argDict, cancellationToken); + } + + return FunctionDescriptor.ReturnParameterMarshaller(ReflectionInvoke(FunctionDescriptor.Method, Target, args), cancellationToken); + } + } + + /// + /// A descriptor for a .NET method-backed AIFunction that precomputes its marshalling delegates and JSON schema. + /// + private sealed class ReflectionAIFunctionDescriptor + { + private const int InnerCacheSoftLimit = 512; + private static readonly ConditionalWeakTable> _descriptorCache = new(); + + /// A boxed . + private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken); + + /// + /// Gets or creates a descriptors using the specified method and options. + /// + public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options) + { + JsonSerializerOptions serializerOptions = options.SerializerOptions ?? AIJsonUtilities.DefaultOptions; + AIJsonSchemaCreateOptions schemaOptions = options.JsonSchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; + serializerOptions.MakeReadOnly(); + ConcurrentDictionary innerCache = _descriptorCache.GetOrCreateValue(serializerOptions); + + DescriptorKey key = new(method, options.Name, options.Description, schemaOptions); + if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor)) + { + return descriptor; + } + + descriptor = new(key, serializerOptions); + return innerCache.Count < InnerCacheSoftLimit + ? innerCache.GetOrAdd(key, descriptor) + : descriptor; + } + + private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions serializerOptions) + { + // Get marshaling delegates for parameters. + ParameterInfo[] parameters = key.Method.GetParameters(); + ParameterMarshallers = new Func, CancellationToken, object?>[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + { + ParameterMarshallers[i] = GetParameterMarshaller(serializerOptions, parameters[i]); + } + + // Get a marshaling delegate for the return value. + ReturnParameterMarshaller = GetReturnParameterMarshaller(key.Method, serializerOptions); + + Method = key.Method; + Name = key.Name ?? GetFunctionName(key.Method); + Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty; + JsonSerializerOptions = serializerOptions; + JsonSchema = AIJsonUtilities.CreateFunctionJsonSchema( + key.Method, + Name, + Description, + serializerOptions, + key.SchemaOptions); + } + + public string Name { get; } + public string Description { get; } + public MethodInfo Method { get; } + public JsonSerializerOptions JsonSerializerOptions { get; } + public JsonElement JsonSchema { get; } + public Func, CancellationToken, object?>[] ParameterMarshallers { get; } + public Func> ReturnParameterMarshaller { get; } + public ReflectionAIFunction? CachedDefaultInstance { get; set; } + + private static string GetFunctionName(MethodInfo method) + { + // Get the function name to use. + string name = SanitizeMemberName(method.Name); + + const string AsyncSuffix = "Async"; + if (IsAsyncMethod(method) && + name.EndsWith(AsyncSuffix, StringComparison.Ordinal) && + name.Length > AsyncSuffix.Length) + { + name = name.Substring(0, name.Length - AsyncSuffix.Length); + } + + return name; + + static bool IsAsyncMethod(MethodInfo method) + { + Type t = method.ReturnType; + + if (t == typeof(Task) || t == typeof(ValueTask)) + { + return true; + } + + if (t.IsGenericType) + { + t = t.GetGenericTypeDefinition(); + if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>)) + { + return true; + } + } + + return false; + } + } + + /// + /// Gets a delegate for handling the marshaling of a parameter. + /// + private static Func, CancellationToken, object?> GetParameterMarshaller( + JsonSerializerOptions serializerOptions, + ParameterInfo parameter) + { + if (string.IsNullOrWhiteSpace(parameter.Name)) + { + throw new ArgumentException("Parameter is missing a name.", nameof(parameter)); + } + + // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. + Type parameterType = parameter.ParameterType; + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType); + + // For CancellationToken parameters, we always bind to the token passed directly to InvokeAsync. + if (parameterType == typeof(CancellationToken)) + { + return static (_, cancellationToken) => + cancellationToken == default ? _boxedDefaultCancellationToken : // optimize common case of a default CT to avoid boxing + cancellationToken; + } + + // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. + return (arguments, _) => + { + // If the parameter has an argument specified in the dictionary, return that argument. + if (arguments.TryGetValue(parameter.Name, out object? value)) + { + return value switch + { + null => null, // Return as-is if null -- if the parameter is a struct this will be handled by MethodInfo.Invoke + _ when parameterType.IsInstanceOfType(value) => value, // Do nothing if value is assignable to parameter type + JsonElement element => JsonSerializer.Deserialize(element, typeInfo), + JsonDocument doc => JsonSerializer.Deserialize(doc, typeInfo), + JsonNode node => JsonSerializer.Deserialize(node, typeInfo), + _ => MarshallViaJsonRoundtrip(value), + }; + + object? MarshallViaJsonRoundtrip(object value) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); + return JsonSerializer.Deserialize(json, typeInfo); + } + catch + { + // Eat any exceptions and fall back to the original value to force a cast exception later on. + return value; + } +#pragma warning restore CA1031 + } + } + + // There was no argument for the parameter in the dictionary. + // Does it have a default value? + if (parameter.HasDefaultValue) + { + return parameter.DefaultValue; + } + + // Leave it empty. + return null; + }; + } + + /// + /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. + /// + private static Func> GetReturnParameterMarshaller(MethodInfo method, JsonSerializerOptions serializerOptions) + { + Type returnType = method.ReturnType; + JsonTypeInfo returnTypeInfo; + + // Void + if (returnType == typeof(void)) + { + return static (_, _) => Task.FromResult(null); + } + + // Task + if (returnType == typeof(Task)) + { + return async static (result, _) => + { + await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); + return null; + }; + } + + // ValueTask + if (returnType == typeof(ValueTask)) + { + return async static (result, _) => + { + await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false); + return null; + }; + } + + if (returnType.IsGenericType) + { + // Task + if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); + returnTypeInfo = serializerOptions.GetTypeInfo(taskResultGetter.ReturnType); + return async (taskObj, cancellationToken) => + { + await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(false); + object? result = ReflectionInvoke(taskResultGetter, taskObj, null); + return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + }; + } + + // ValueTask + if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask); + MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); + returnTypeInfo = serializerOptions.GetTypeInfo(asTaskResultGetter.ReturnType); + return async (taskObj, cancellationToken) => + { + var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; + await task.ConfigureAwait(false); + object? result = ReflectionInvoke(asTaskResultGetter, task, null); + return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + }; + } + } + + // For everything else, just serialize the result as-is. + returnTypeInfo = serializerOptions.GetTypeInfo(returnType); + return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); + + static async Task SerializeResultAsync(object? result, JsonTypeInfo returnTypeInfo, CancellationToken cancellationToken) + { + if (returnTypeInfo.Kind is JsonTypeInfoKind.None) + { + // Special-case trivial contracts to avoid the more expensive general-purpose serialization path. + return JsonSerializer.SerializeToElement(result, returnTypeInfo); + } + + // Serialize asynchronously to support potential IAsyncEnumerable responses. + using PooledMemoryStream stream = new(); +#if NET9_0_OR_GREATER + await JsonSerializer.SerializeAsync(stream, result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + Utf8JsonReader reader = new(stream.GetBuffer()); + return JsonElement.ParseValue(ref reader); +#else + await JsonSerializer.SerializeAsync(stream, result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + stream.Position = 0; + var serializerOptions = _defaultOptions.SerializerOptions ?? AIJsonUtilities.DefaultOptions; + return await JsonSerializer.DeserializeAsync(stream, serializerOptions.GetTypeInfo(typeof(JsonElement)), cancellationToken).ConfigureAwait(false); +#endif + } + + // Throws an exception if a result is found to be null unexpectedly + static object ThrowIfNullResult(object? result) => result ?? throw new InvalidOperationException("Function returned null unexpectedly."); + } + + private static readonly MethodInfo _taskGetResult = typeof(Task<>).GetProperty(nameof(Task.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!; + private static readonly MethodInfo _valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask.AsTask), BindingFlags.Instance | BindingFlags.Public)!; + + private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedType, MethodInfo genericMethodDefinition) + { + Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); +#if NET + return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); +#else +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; +#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); +#endif + } + + private record struct DescriptorKey(MethodInfo Method, string? Name, string? Description, AIJsonSchemaCreateOptions SchemaOptions); + } + + /// + /// Removes characters from a .NET member name that shouldn't be used in an AI function name. + /// + /// The .NET member name that should be sanitized. + /// + /// Replaces non-alphanumeric characters in the identifier with the underscore character. + /// Primarily intended to remove characters produced by compiler-generated method name mangling. + /// + internal static string SanitizeMemberName(string memberName) + { + Verify.NotNull(memberName); + return InvalidNameCharsRegex().Replace(memberName, "_"); + } + + /// Regex that flags any character other than ASCII digits or letters or the underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif + + /// Invokes the MethodInfo with the specified target object and arguments. + private static object? ReflectionInvoke(MethodInfo method, object? target, object?[]? arguments) + { +#if NET + return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); +#else + try + { + return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); + } + catch (TargetInvocationException e) when (e.InnerException is not null) + { + // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions + // is ignored, the original exception will be wrapped in a TargetInvocationException. + // Unwrap it and throw that original exception, maintaining its stack information. + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); + throw; + } +#endif + } + + /// + /// Implements a simple write-only memory stream that uses pooled buffers. + /// + private sealed class PooledMemoryStream : Stream + { + private const int DefaultBufferSize = 4096; + private byte[] _buffer; + private int _position; + + public PooledMemoryStream(int initialCapacity = DefaultBufferSize) + { + _buffer = ArrayPool.Shared.Rent(initialCapacity); + _position = 0; + } + + public ReadOnlySpan GetBuffer() => _buffer.AsSpan(0, _position); + public override bool CanWrite => true; + public override bool CanRead => false; + public override bool CanSeek => false; + public override long Length => _position; + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + EnsureNotDisposed(); + EnsureCapacity(_position + count); + + Buffer.BlockCopy(buffer, offset, _buffer, _position, count); + _position += count; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (_buffer is not null) + { + ArrayPool.Shared.Return(_buffer); + _buffer = null!; + } + + base.Dispose(disposing); + } + + private void EnsureCapacity(int requiredCapacity) + { + if (requiredCapacity <= _buffer.Length) + { + return; + } + + int newCapacity = Math.Max(requiredCapacity, _buffer.Length * 2); + byte[] newBuffer = ArrayPool.Shared.Rent(newCapacity); + Buffer.BlockCopy(_buffer, 0, newBuffer, 0, _position); + + ArrayPool.Shared.Return(_buffer); + _buffer = newBuffer; + } + + private void EnsureNotDisposed() + { + if (_buffer is null) + { + Throw(); + static void Throw() => throw new ObjectDisposedException(nameof(PooledMemoryStream)); + } + } + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs index 949f516f0592..8a5abc42a6e0 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.Services; -namespace Microsoft.SemanticKernel.AI.ChatCompletion; +namespace Microsoft.SemanticKernel.ChatCompletion; /// /// Allow to be used as an in a diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs index 281399f58438..c36ca453bd04 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs @@ -66,4 +66,19 @@ public static IChatCompletionService AsChatCompletionService(this IChatClient cl return client.GetService()?.ModelId; } + + /// + /// Creates a new that supports for function invocation with a . + /// + /// Target chat client service. + /// Function invoking chat client. + [Experimental("SKEXP0001")] + public static IChatClient AsKernelFunctionInvokingChatClient(this IChatClient client) + { + Verify.NotNull(client); + + return client is KernelFunctionInvokingChatClient kernelFunctionInvocationClient + ? kernelFunctionInvocationClient + : new KernelFunctionInvokingChatClient(client); + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs index 5d37a3a496d5..3d190a48c5e4 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs @@ -1,9 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.Extensions.AI; -namespace Microsoft.Extensions.AI; +namespace Microsoft.SemanticKernel.ChatCompletion; internal static class ChatMessageExtensions { diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs new file mode 100644 index 000000000000..d8fab37e57bd --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel.ChatCompletion; + +/// +/// Extensions methods for . +/// +internal static class ChatOptionsExtensions +{ + /// Converts a to a . + internal static PromptExecutionSettings? ToPromptExecutionSettings(this ChatOptions? options) + { + if (options is null) + { + return null; + } + + PromptExecutionSettings settings = new() + { + ExtensionData = new Dictionary(StringComparer.OrdinalIgnoreCase), + ModelId = options.ModelId, + }; + + // Transfer over all strongly-typed members of ChatOptions. We do not know the exact name the derived PromptExecutionSettings + // will pick for these options, so we just use the most common choice for each. (We could make this more exact by having an + // IPromptExecutionSettingsFactory interface with a method like `PromptExecutionSettings Create(ChatOptions options)`; that + // interface could then optionally be implemented by an IChatCompletionService, and this implementation could just ask the + // chat completion service to produce the PromptExecutionSettings it wants. But, this is already a problem + // with PromptExecutionSettings, regardless of ChatOptions... someone creating a PES without knowing what backend is being + // used has to guess at the names to use.) + + if (options.Temperature is not null) + { + settings.ExtensionData["temperature"] = options.Temperature.Value; + } + + if (options.MaxOutputTokens is not null) + { + settings.ExtensionData["max_tokens"] = options.MaxOutputTokens.Value; + } + + if (options.FrequencyPenalty is not null) + { + settings.ExtensionData["frequency_penalty"] = options.FrequencyPenalty.Value; + } + + if (options.PresencePenalty is not null) + { + settings.ExtensionData["presence_penalty"] = options.PresencePenalty.Value; + } + + if (options.StopSequences is not null) + { + settings.ExtensionData["stop_sequences"] = options.StopSequences; + } + + if (options.TopP is not null) + { + settings.ExtensionData["top_p"] = options.TopP.Value; + } + + if (options.TopK is not null) + { + settings.ExtensionData["top_k"] = options.TopK.Value; + } + + if (options.Seed is not null) + { + settings.ExtensionData["seed"] = options.Seed.Value; + } + + if (options.ResponseFormat is not null) + { + if (options.ResponseFormat is ChatResponseFormatText) + { + settings.ExtensionData["response_format"] = "text"; + } + else if (options.ResponseFormat is ChatResponseFormatJson json) + { + settings.ExtensionData["response_format"] = json.Schema is JsonElement schema ? + JsonSerializer.Deserialize(schema, AbstractionsJsonContext.Default.JsonElement) : + "json_object"; + } + } + + // Transfer over loosely-typed members of ChatOptions. + + if (options.AdditionalProperties is not null) + { + foreach (var kvp in options.AdditionalProperties) + { + if (kvp.Value is not null) + { + settings.ExtensionData[kvp.Key] = kvp.Value; + } + } + } + + // Transfer over tools. For IChatClient, we do not want automatic invocation, as that's a concern left up to + // components like FunctionInvocationChatClient. As such, based on the tool mode, we map to the appropriate + // FunctionChoiceBehavior, but always with autoInvoke: false. + + if (options.Tools is { Count: > 0 }) + { + var functions = options.Tools.OfType().Select(f => new AIFunctionKernelFunction(f)); + settings.FunctionChoiceBehavior = + options.ToolMode is null or AutoChatToolMode ? FunctionChoiceBehavior.Auto(functions, autoInvoke: false) : + options.ToolMode is RequiredChatToolMode { RequiredFunctionName: null } ? FunctionChoiceBehavior.Required(functions, autoInvoke: false) : + options.ToolMode is RequiredChatToolMode { RequiredFunctionName: string functionName } ? FunctionChoiceBehavior.Required(functions.Where(f => f.Name == functionName), autoInvoke: false) : + null; + } + + return settings; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs new file mode 100644 index 000000000000..f9f43ee630ae --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.SemanticKernel.ChatCompletion; + +// Slight modified source from +// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryOptions.cs + +/// +/// Represents options that can be provided when creating an from a method. +/// +[ExcludeFromCodeCoverage] +internal sealed class AIFunctionFactoryOptions +{ + /// + /// Initializes a new instance of the class. + /// + public AIFunctionFactoryOptions() + { + } + + /// Gets or sets the used to marshal .NET values being passed to the underlying delegate. + /// + /// If no value has been specified, the instance will be used. + /// + public JsonSerializerOptions? SerializerOptions { get; set; } + + /// + /// Gets or sets the governing the generation of JSON schemas for the function. + /// + /// + /// If no value has been specified, the instance will be used. + /// + public AIJsonSchemaCreateOptions? JsonSchemaCreateOptions { get; set; } + + /// Gets or sets the name to use for the function. + /// + /// The name to use for the function. The default value is a name derived from the method represented by the passed or . + /// + public string? Name { get; set; } + + /// Gets or sets the description to use for the function. + /// + /// The description for the function. The default value is a description derived from the passed or , if possible + /// (for example, via a on the method). + /// + public string? Description { get; set; } + + /// + /// Gets or sets additional values to store on the resulting property. + /// + /// + /// This property can be used to provide arbitrary information about the function. + /// + public IReadOnlyDictionary? AdditionalProperties { get; set; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs new file mode 100644 index 000000000000..da5af46620fd --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; + +#pragma warning disable IDE0009 // Use explicit 'this.' qualifier +#pragma warning disable CA2213 // Disposable fields should be disposed +#pragma warning disable IDE0044 // Add readonly modifier + +namespace Microsoft.SemanticKernel.ChatCompletion; + +// Slight modified source from +// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs + +/// Provides context for an in-flight function invocation. +[ExcludeFromCodeCoverage] +internal sealed class KernelFunctionInvocationContext +{ + /// + /// A nop function used to allow to be non-nullable. Default instances of + /// start with this as the target function. + /// + private static readonly AIFunction s_nopFunction = AIFunctionFactory.Create(() => { }, nameof(KernelFunctionInvocationContext)); + + /// The chat contents associated with the operation that initiated this function call request. + private IList _messages = Array.Empty(); + + /// The AI function to be invoked. + private AIFunction _function = s_nopFunction; + + /// The function call content information associated with this invocation. + private Microsoft.Extensions.AI.FunctionCallContent _callContent = new(string.Empty, s_nopFunction.Name, EmptyReadOnlyDictionary.Instance); + + /// Initializes a new instance of the class. + internal KernelFunctionInvocationContext() + { + } + + /// Gets or sets the function call content information associated with this invocation. + public Microsoft.Extensions.AI.FunctionCallContent CallContent + { + get => _callContent; + set + { + Verify.NotNull(value); + _callContent = value; + } + } + + /// Gets or sets the chat contents associated with the operation that initiated this function call request. + public IList Messages + { + get => _messages; + set + { + Verify.NotNull(value); + _messages = value; + } + } + + /// Gets or sets the chat options associated with the operation that initiated this function call request. + public ChatOptions? Options { get; set; } + + /// Gets or sets the AI function to be invoked. + public AIFunction Function + { + get => _function; + set + { + Verify.NotNull(value); + _function = value; + } + } + + /// Gets or sets the number of this iteration with the underlying client. + /// + /// The initial request to the client that passes along the chat contents provided to the + /// is iteration 1. If the client responds with a function call request, the next request to the client is iteration 2, and so on. + /// + public int Iteration { get; set; } + + /// Gets or sets the index of the function call within the iteration. + /// + /// The response from the underlying client may include multiple function call requests. + /// This index indicates the position of the function call within the iteration. + /// + public int FunctionCallIndex { get; set; } + + /// Gets or sets the total number of function call requests within the iteration. + /// + /// The response from the underlying client might include multiple function call requests. + /// This count indicates how many there were. + /// + public int FunctionCount { get; set; } + + /// Gets or sets a value indicating whether to terminate the request. + /// + /// In response to a function call request, the function might be invoked, its result added to the chat contents, + /// and a new request issued to the wrapped client. If this property is set to , that subsequent request + /// will not be issued and instead the loop immediately terminated rather than continuing until there are no + /// more function call requests in responses. + /// + public bool Terminate { get; set; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs new file mode 100644 index 000000000000..1a59b8f5ccbd --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -0,0 +1,855 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +#pragma warning disable CA2213 // Disposable fields should be disposed +#pragma warning disable IDE0009 // Use explicit 'this.' qualifier +#pragma warning disable IDE1006 // Missing prefix: 's_' + +namespace Microsoft.SemanticKernel.ChatCompletion; + +// Slight modified source from +// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs + +/// +/// A delegating chat client that invokes functions defined on . +/// Include this in a chat pipeline to resolve function calls automatically. +/// +/// +/// +/// When this client receives a in a chat response, it responds +/// by calling the corresponding defined in , +/// producing a . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// instances employed as part of the supplied are also safe. +/// The property can be used to control whether multiple function invocation +/// requests as part of the same request are invocable concurrently, but even with that set to +/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those +/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific +/// ASP.NET web request should only be used as part of a single at a time, and only with +/// set to , in case the inner client decided to issue multiple +/// invocation requests to that same function. +/// +/// +[ExcludeFromCodeCoverage] +internal sealed partial class KernelFunctionInvokingChatClient : DelegatingChatClient +{ + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); + + /// The logger to use for logging information about function invocation. + private readonly ILogger _logger; + + /// The to use for telemetry. + /// This component does not own the instance and should not dispose it. + private readonly ActivitySource? _activitySource; + + /// Maximum number of roundtrips allowed to the inner client. + private int? _maximumIterationsPerRequest; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying , or the next instance in a chain of clients. + /// An to use for logging information about function invocation. + public KernelFunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = null) + : base(innerClient) + { + _logger = logger ?? NullLogger.Instance; + _activitySource = innerClient.GetService(); + } + + /// + /// Gets or sets the for the current function invocation. + /// + /// + /// This value flows across async calls. + /// + internal static KernelFunctionInvocationContext? CurrentContext + { + get => _currentContext.Value; + set => _currentContext.Value = value; + } + + /// + /// Gets or sets a value indicating whether to handle exceptions that occur during function calls. + /// + /// + /// if the + /// underlying will be instructed to give a response without invoking + /// any further functions if a function call fails with an exception. + /// if the underlying is allowed + /// to continue attempting function calls until is reached. + /// The default value is . + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to whether errors are retried during an in-flight request. + /// + public bool RetryOnError { get; set; } + + /// + /// Gets or sets a value indicating whether detailed exception information should be included + /// in the chat history when calling the underlying . + /// + /// + /// if the full exception message is added to the chat history + /// when calling the underlying . + /// if a generic error message is included in the chat history. + /// The default value is . + /// + /// + /// + /// Setting the value to prevents the underlying language model from disclosing + /// raw exception details to the end user, since it doesn't receive that information. Even in this + /// case, the raw object is available to application code by inspecting + /// the property. + /// + /// + /// Setting the value to can help the underlying bypass problems on + /// its own, for example by retrying the function call with different arguments. However it might + /// result in disclosing the raw exception information to external users, which can be a security + /// concern depending on the application scenario. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to whether detailed errors are provided during an in-flight request. + /// + /// + public bool IncludeDetailedErrors { get; set; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + /// + /// An individual response from the inner client might contain multiple function call requests. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls can execute in parallel. + /// + public bool AllowConcurrentInvocation { get; set; } + + /// + /// Gets or sets the maximum number of iterations per request. + /// + /// + /// The maximum number of iterations per request. + /// The default value is . + /// + /// + /// + /// Each request to this might end up making + /// multiple requests to the inner client. Each time the inner client responds with + /// a function call request, this client might perform that invocation and send the results + /// back to the inner client in a new request. This property limits the number of times + /// such a roundtrip is performed. If null, there is no limit applied. If set, the value + /// must be at least one, as it includes the initial request. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int? MaximumIterationsPerRequest + { + get => _maximumIterationsPerRequest; + set + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _maximumIterationsPerRequest = value; + } + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // A single request into this GetResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response + UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response + List? functionCallContents = null; // function call contents that need responding to in the current turn + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + + for (int iteration = 0; ; iteration++) + { + functionCallContents?.Clear(); + + // Make the call to the inner client. + response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + if (response is null) + { + throw new InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); + } + + // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. + bool requiresFunctionInvocation = + options?.Tools is { Count: > 0 } && + (!MaximumIterationsPerRequest.HasValue || iteration < MaximumIterationsPerRequest.GetValueOrDefault()) && + CopyFunctionCalls(response.Messages, ref functionCallContents); + + // In a common case where we make a request and there's no function calling work required, + // fast path out by just returning the original response. + if (iteration == 0 && !requiresFunctionInvocation) + { + return response; + } + + // Track aggregatable details from the response, including all of the response messages and usage details. + (responseMessages ??= []).AddRange(response.Messages); + if (response.Usage is not null) + { + if (totalUsage is not null) + { + totalUsage.Add(response.Usage); + } + else + { + totalUsage = response.Usage; + } + } + + // If there are no tools to call, or for any other reason we should stop, we're done. + // Break out of the loop and allow the handling at the end to configure the response + // with aggregated data from previous requests. + if (!requiresFunctionInvocation) + { + break; + } + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Add the responses from the function calls into the augmented history and also into the tracked + // list of response messages. + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, cancellationToken).ConfigureAwait(false); + responseMessages.AddRange(modeAndMessages.MessagesAdded); + + if (UpdateOptionsForMode(modeAndMessages.Mode, ref options!, response.ChatThreadId)) + { + // Terminate + break; + } + } + + Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); + response.Messages = responseMessages!; + response.Usage = totalUsage; + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(messages); + + // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. + // Create an activity to group them together for better observability. + using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); + + // Copy the original messages in order to avoid enumerating the original messages multiple times. + // The IEnumerable can represent an arbitrary amount of work. + List originalMessages = [.. messages]; + messages = originalMessages; + + List? augmentedHistory = null; // the actual history of messages sent on turns other than the first + List? functionCallContents = null; // function call contents that need responding to in the current turn + List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history + bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + List updates = []; // updates from the current response + + for (int iteration = 0; ; iteration++) + { + updates.Clear(); + functionCallContents?.Clear(); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + if (update is null) + { + throw new InvalidOperationException($"The inner {nameof(IChatClient)} streamed a null {nameof(ChatResponseUpdate)}."); + } + + updates.Add(update); + + _ = CopyFunctionCalls(update.Contents, ref functionCallContents); + + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + // If there are no tools to call, or for any other reason we should stop, return the response. + if (functionCallContents is not { Count: > 0 } || + options?.Tools is not { Count: > 0 } || + (MaximumIterationsPerRequest is { } maxIterations && iteration >= maxIterations)) + { + break; + } + + // Reconstitute a response from the response updates. + var response = updates.ToChatResponse(); + (responseMessages ??= []).AddRange(response.Messages); + + // Prepare the history for the next iteration. + FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + + // Process all of the functions, adding their results into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); + responseMessages.AddRange(modeAndMessages.MessagesAdded); + + // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages + // includes all activities, including generated function results. + string toolResponseId = Guid.NewGuid().ToString("N"); + foreach (var message in modeAndMessages.MessagesAdded) + { + var toolResultUpdate = new ChatResponseUpdate + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + ChatThreadId = response.ChatThreadId, + CreatedAt = DateTimeOffset.UtcNow, + Contents = message.Contents, + RawRepresentation = message.RawRepresentation, + ResponseId = toolResponseId, + Role = message.Role, + }; + + yield return toolResultUpdate; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + if (UpdateOptionsForMode(modeAndMessages.Mode, ref options, response.ChatThreadId)) + { + // Terminate + yield break; + } + } + } + + /// Prepares the various chat message lists after a response from the inner client and before invoking functions. + /// The original messages provided by the caller. + /// The messages reference passed to the inner client. + /// The augmented history containing all the messages to be sent. + /// The most recent response being handled. + /// A list of all response messages received up until this point. + /// Whether the previous iteration's response had a thread id. + private static void FixupHistories( + IEnumerable originalMessages, + ref IEnumerable messages, + [NotNull] ref List? augmentedHistory, + ChatResponse response, + List allTurnsResponseMessages, + ref bool lastIterationHadThreadId) + { + // We're now going to need to augment the history with function result contents. + // That means we need a separate list to store the augmented history. + if (response.ChatThreadId is not null) + { + // The response indicates the inner client is tracking the history, so we don't want to send + // anything we've already sent or received. + if (augmentedHistory is not null) + { + augmentedHistory.Clear(); + } + else + { + augmentedHistory = []; + } + + lastIterationHadThreadId = true; + } + else if (lastIterationHadThreadId) + { + // In the very rare case where the inner client returned a response with a thread ID but then + // returned a subsequent response without one, we want to reconstitute the full history. To do that, + // we can populate the history with the original chat messages and then all of the response + // messages up until this point, which includes the most recent ones. + augmentedHistory ??= []; + augmentedHistory.Clear(); + augmentedHistory.AddRange(originalMessages); + augmentedHistory.AddRange(allTurnsResponseMessages); + + lastIterationHadThreadId = false; + } + else + { + // If augmentedHistory is already non-null, then we've already populated it with everything up + // until this point (except for the most recent response). If it's null, we need to seed it with + // the chat history provided by the caller. + augmentedHistory ??= originalMessages.ToList(); + + // Now add the most recent response messages. + augmentedHistory.AddMessages(response); + + lastIterationHadThreadId = false; + } + + // Use the augmented history as the new set of messages to send. + messages = augmentedHistory; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList messages, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = messages.Count; + for (int i = 0; i < count; i++) + { + any |= CopyFunctionCalls(messages[i].Contents, ref functionCalls); + } + + return any; + } + + /// Copies any from to . + private static bool CopyFunctionCalls( + IList content, [NotNullWhen(true)] ref List? functionCalls) + { + bool any = false; + int count = content.Count; + for (int i = 0; i < count; i++) + { + if (content[i] is Microsoft.Extensions.AI.FunctionCallContent functionCall) + { + (functionCalls ??= []).Add(functionCall); + any = true; + } + } + + return any; + } + + /// Updates for the response. + /// true if the function calling loop should terminate; otherwise, false. + private static bool UpdateOptionsForMode(ContinueMode mode, ref ChatOptions options, string? chatThreadId) + { + switch (mode) + { + case ContinueMode.Continue when options.ToolMode is RequiredChatToolMode: + // We have to reset the tool mode to be non-required after the first iteration, + // as otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = null; + options.ChatThreadId = chatThreadId; + + break; + + case ContinueMode.AllowOneMoreRoundtrip: + // The LLM gets one further chance to answer, but cannot use tools. + options = options.Clone(); + options.Tools = null; + options.ToolMode = null; + options.ChatThreadId = chatThreadId; + + break; + + case ContinueMode.Terminate: + // Bail immediately. + return true; + + default: + // As with the other modes, ensure we've propagated the chat thread ID to the options. + // We only need to clone the options if we're actually mutating it. + if (options.ChatThreadId != chatThreadId) + { + options = options.Clone(); + options.ChatThreadId = chatThreadId; + } + + break; + } + + return false; + } + + /// + /// Processes the function calls in the list. + /// + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing the functions to be invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task<(ContinueMode Mode, IList MessagesAdded)> ProcessFunctionCallsAsync( + List messages, ChatOptions options, List functionCallContents, int iteration, CancellationToken cancellationToken) + { + // We must add 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. + + Debug.Assert(functionCallContents.Count > 0, "Expecteded at least one function call."); + + // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. + if (functionCallContents.Count == 1) + { + FunctionInvocationResult result = await ProcessFunctionCallAsync( + messages, options, functionCallContents, iteration, 0, cancellationToken).ConfigureAwait(false); + + IList added = CreateResponseMessages([result]); + ThrowIfNoFunctionResultsAdded(added); + + messages.AddRange(added); + return (result.ContinueMode, added); + } + else + { + FunctionInvocationResult[] results; + + if (AllowConcurrentInvocation) + { + // Schedule the invocation of every function. + results = await Task.WhenAll( + from i in Enumerable.Range(0, functionCallContents.Count) + select Task.Run(() => ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, cancellationToken))).ConfigureAwait(false); + } + else + { + // Invoke each function serially. + results = new FunctionInvocationResult[functionCallContents.Count]; + for (int i = 0; i < results.Length; i++) + { + results[i] = await ProcessFunctionCallAsync( + messages, options, functionCallContents, + iteration, i, cancellationToken).ConfigureAwait(false); + } + } + + ContinueMode continueMode = ContinueMode.Continue; + + IList added = CreateResponseMessages(results); + ThrowIfNoFunctionResultsAdded(added); + + messages.AddRange(added); + foreach (FunctionInvocationResult fir in results) + { + if (fir.ContinueMode > continueMode) + { + continueMode = fir.ContinueMode; + } + } + + return (continueMode, added); + } + } + + /// + /// Throws an exception if doesn't create any messages. + /// + private void ThrowIfNoFunctionResultsAdded(IList? messages) + { + if (messages is null || messages.Count == 0) + { + throw new InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); + } + } + + /// Processes the function call described in []. + /// The current chat contents, inclusive of the function call contents being processed. + /// The options used for the response being processed. + /// The function call contents representing all the functions being invoked. + /// The iteration number of how many roundtrips have been made to the inner client. + /// The 0-based index of the function being called out of . + /// The to monitor for cancellation requests. + /// A value indicating how the caller should proceed. + private async Task ProcessFunctionCallAsync( + List messages, ChatOptions options, List callContents, + int iteration, int functionCallIndex, CancellationToken cancellationToken) + { + var callContent = callContents[functionCallIndex]; + + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. + AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); + if (function is null) + { + return new(ContinueMode.Continue, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); + } + + KernelFunctionInvocationContext context = new() + { + Messages = messages, + Options = options, + CallContent = callContent, + Function = function, + Iteration = iteration, + FunctionCallIndex = functionCallIndex, + FunctionCount = callContents.Count, + }; + + object? result; + try + { + result = await InvokeFunctionAsync(context, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (!cancellationToken.IsCancellationRequested) + { + return new( + RetryOnError ? ContinueMode.Continue : ContinueMode.AllowOneMoreRoundtrip, // We won't allow further function calls, hence the LLM will just get one more chance to give a final answer. + FunctionInvocationStatus.Exception, + callContent, + result: null, + exception: e); + } + + return new( + context.Terminate ? ContinueMode.Terminate : ContinueMode.Continue, + FunctionInvocationStatus.RanToCompletion, + callContent, + result, + exception: null); + } + + /// Represents the return value of , dictating how the loop should behave. + /// These values are ordered from least severe to most severe, and code explicitly depends on the ordering. + internal enum ContinueMode + { + /// Send back the responses and continue processing. + Continue = 0, + + /// Send back the response but without any tools. + AllowOneMoreRoundtrip = 1, + + /// Immediately exit the function calling loop. + Terminate = 2, + } + + /// Creates one or more response messages for function invocation results. + /// Information about the function call invocations and results. + /// A list of all chat messages created from . + internal IList CreateResponseMessages( + ReadOnlySpan results) + { + var contents = new List(results.Length); + for (int i = 0; i < results.Length; i++) + { + contents.Add(CreateFunctionResultContent(results[i])); + } + + return [new(ChatRole.Tool, contents)]; + + Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) + { + Verify.NotNull(result); + + object? functionResult; + if (result.Status == FunctionInvocationStatus.RanToCompletion) + { + functionResult = result.Result ?? "Success: Function completed."; + } + else + { + string message = result.Status switch + { + FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", + FunctionInvocationStatus.Exception => "Error: Function failed.", + _ => "Error: Unknown error.", + }; + + if (IncludeDetailedErrors && result.Exception is not null) + { + message = $"{message} Exception: {result.Exception.Message}"; + } + + functionResult = message; + } + + return new Microsoft.Extensions.AI.FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; + } + } + + /// Invokes the function asynchronously. + /// + /// The function invocation context detailing the function to be invoked and its arguments along with additional request information. + /// + /// The to monitor for cancellation requests. The default is . + /// The result of the function invocation, or if the function invocation returned . + /// is . + internal async Task InvokeFunctionAsync(KernelFunctionInvocationContext context, CancellationToken cancellationToken) + { + Verify.NotNull(context); + + using Activity? activity = _activitySource?.StartActivity(context.Function.Name); + + long startingTimestamp = 0; + if (_logger.IsEnabled(LogLevel.Debug)) + { + startingTimestamp = Stopwatch.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.Function.JsonSerializerOptions)); + } + else + { + LogInvoking(context.Function.Name); + } + } + + object? result = null; + try + { + CurrentContext = context; + result = await context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + if (activity is not null) + { + _ = activity.SetTag("error.type", e.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, e.Message); + } + + if (e is OperationCanceledException) + { + LogInvocationCanceled(context.Function.Name); + } + else + { + LogInvocationFailed(context.Function.Name, e); + } + + throw; + } + finally + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + TimeSpan elapsed = GetElapsedTime(startingTimestamp); + + if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + { + LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.Function.JsonSerializerOptions)); + } + else + { + LogInvocationCompleted(context.Function.Name, elapsed); + } + } + } + + return result; + } + + /// Serializes as JSON for logging purposes. + private static string LoggingAsJson(T value, JsonSerializerOptions? options) + { + if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true || + AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo)) + { +#pragma warning disable CA1031 // Do not catch general exception types + try + { + return JsonSerializer.Serialize(value, typeInfo); + } + catch + { + } +#pragma warning restore CA1031 // Do not catch general exception types + } + + // If we're unable to get a type info for the value, or if we fail to serialize, + // return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions. + return "{}"; + } + + private static TimeSpan GetElapsedTime(long startingTimestamp) => +#if NET + Stopwatch.GetElapsedTime(startingTimestamp); +#else + new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); +#endif + + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] + private partial void LogInvoking(string methodName); + + [LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)] + private partial void LogInvokingSensitive(string methodName, string arguments); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)] + private partial void LogInvocationCompleted(string methodName, TimeSpan duration); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)] + private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); + + /// Provides information about the invocation of a function call. + public sealed class FunctionInvocationResult + { + internal FunctionInvocationResult(ContinueMode continueMode, FunctionInvocationStatus status, Microsoft.Extensions.AI.FunctionCallContent callContent, object? result, Exception? exception) + { + ContinueMode = continueMode; + Status = status; + CallContent = callContent; + Result = result; + Exception = exception; + } + + /// Gets status about how the function invocation completed. + public FunctionInvocationStatus Status { get; } + + /// Gets the function call content information associated with this invocation. + public Microsoft.Extensions.AI.FunctionCallContent CallContent { get; } + + /// Gets the result of the function call. + public object? Result { get; } + + /// Gets any exception the function call threw. + public Exception? Exception { get; } + + /// Gets an indication for how the caller should continue the processing loop. + internal ContinueMode ContinueMode { get; } + } + + /// Provides error codes for when errors occur as part of the function calling loop. + public enum FunctionInvocationStatus + { + /// The operation completed successfully. + RanToCompletion, + + /// The requested function could not be found. + NotFound, + + /// The function call failed with an exception. + Exception, + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs index b7ed711be65e..82ed2e9725cf 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatClientChatCompletionService.cs @@ -94,7 +94,19 @@ public async IAsyncEnumerable GetStreamingChatMessa yield return update.ToStreamingChatMessageContent(); } - // Add function call content/results to chat history, as other IChatCompletionService streaming implementations do. - chatHistory.Add(new ChatMessage(role ?? ChatRole.Assistant, fcContents).ToChatMessageContent()); + // Message tools and function calls should be individual messages in the history. + foreach (var fcc in fcContents) + { + if (fcc is Microsoft.Extensions.AI.FunctionCallContent functionCallContent) + { + chatHistory.Add(new ChatMessage(ChatRole.Assistant, [functionCallContent]).ToChatMessageContent()); + continue; + } + + if (fcc is Microsoft.Extensions.AI.FunctionResultContent functionResultContent) + { + chatHistory.Add(new ChatMessage(ChatRole.Tool, [functionResultContent]).ToChatMessageContent()); + } + } } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs index 4fe963a49363..45b912a7d884 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatCompletionServiceChatClient.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -44,7 +43,7 @@ internal ChatCompletionServiceChatClient(IChatCompletionService chatCompletionSe var response = await this._chatCompletionService.GetChatMessageContentAsync( chatHistory, - ToPromptExecutionSettings(options), + options.ToPromptExecutionSettings(), kernel: null, cancellationToken).ConfigureAwait(false); @@ -73,7 +72,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync(IEnu await foreach (var update in this._chatCompletionService.GetStreamingChatMessageContentsAsync( new ChatHistory(messages.Select(m => m.ToChatMessageContent())), - ToPromptExecutionSettings(options), + options.ToPromptExecutionSettings(), kernel: null, cancellationToken).ConfigureAwait(false)) { @@ -99,110 +98,4 @@ public void Dispose() serviceType.IsInstanceOfType(this.Metadata) ? this.Metadata : null; } - - /// Converts a to a . - private static PromptExecutionSettings? ToPromptExecutionSettings(ChatOptions? options) - { - if (options is null) - { - return null; - } - - PromptExecutionSettings settings = new() - { - ExtensionData = new Dictionary(StringComparer.OrdinalIgnoreCase), - ModelId = options.ModelId, - }; - - // Transfer over all strongly-typed members of ChatOptions. We do not know the exact name the derived PromptExecutionSettings - // will pick for these options, so we just use the most common choice for each. (We could make this more exact by having an - // IPromptExecutionSettingsFactory interface with a method like `PromptExecutionSettings Create(ChatOptions options)`; that - // interface could then optionally be implemented by an IChatCompletionService, and this implementation could just ask the - // chat completion service to produce the PromptExecutionSettings it wants. But, this is already a problem - // with PromptExecutionSettings, regardless of ChatOptions... someone creating a PES without knowing what backend is being - // used has to guess at the names to use.) - - if (options.Temperature is not null) - { - settings.ExtensionData["temperature"] = options.Temperature.Value; - } - - if (options.MaxOutputTokens is not null) - { - settings.ExtensionData["max_tokens"] = options.MaxOutputTokens.Value; - } - - if (options.FrequencyPenalty is not null) - { - settings.ExtensionData["frequency_penalty"] = options.FrequencyPenalty.Value; - } - - if (options.PresencePenalty is not null) - { - settings.ExtensionData["presence_penalty"] = options.PresencePenalty.Value; - } - - if (options.StopSequences is not null) - { - settings.ExtensionData["stop_sequences"] = options.StopSequences; - } - - if (options.TopP is not null) - { - settings.ExtensionData["top_p"] = options.TopP.Value; - } - - if (options.TopK is not null) - { - settings.ExtensionData["top_k"] = options.TopK.Value; - } - - if (options.Seed is not null) - { - settings.ExtensionData["seed"] = options.Seed.Value; - } - - if (options.ResponseFormat is not null) - { - if (options.ResponseFormat is ChatResponseFormatText) - { - settings.ExtensionData["response_format"] = "text"; - } - else if (options.ResponseFormat is ChatResponseFormatJson json) - { - settings.ExtensionData["response_format"] = json.Schema is JsonElement schema ? - JsonSerializer.Deserialize(schema, AbstractionsJsonContext.Default.JsonElement) : - "json_object"; - } - } - - // Transfer over loosely-typed members of ChatOptions. - - if (options.AdditionalProperties is not null) - { - foreach (var kvp in options.AdditionalProperties) - { - if (kvp.Value is not null) - { - settings.ExtensionData[kvp.Key] = kvp.Value; - } - } - } - - // Transfer over tools. For IChatClient, we do not want automatic invocation, as that's a concern left up to - // components like FunctionInvocationChatClient. As such, based on the tool mode, we map to the appropriate - // FunctionChoiceBehavior, but always with autoInvoke: false. - - if (options.Tools is { Count: > 0 }) - { - var functions = options.Tools.OfType().Select(f => new AIFunctionKernelFunction(f)); - settings.FunctionChoiceBehavior = - options.ToolMode is null or AutoChatToolMode ? FunctionChoiceBehavior.Auto(functions, autoInvoke: false) : - options.ToolMode is RequiredChatToolMode { RequiredFunctionName: null } ? FunctionChoiceBehavior.Required(functions, autoInvoke: false) : - options.ToolMode is RequiredChatToolMode { RequiredFunctionName: string functionName } ? FunctionChoiceBehavior.Required(functions.Where(f => f.Name == functionName), autoInvoke: false) : - null; - } - - return settings; - } } diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs index cc41596adf08..78e2f8445a78 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/StreamingChatMessageContentExtensions.cs @@ -35,7 +35,7 @@ internal static ChatResponseUpdate ToChatResponseUpdate(this StreamingChatMessag aiContent = new Microsoft.Extensions.AI.FunctionCallContent( fcc.CallId ?? string.Empty, fcc.Name ?? string.Empty, - fcc.Arguments is not null ? JsonSerializer.Deserialize>(fcc.Arguments, AbstractionsJsonContext.Default.IDictionaryStringObject!) : null); + !string.IsNullOrWhiteSpace(fcc.Arguments) ? JsonSerializer.Deserialize>(fcc.Arguments, AbstractionsJsonContext.Default.IDictionaryStringObject!) : null); break; } diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs index 4bc58f6c7156..4454c2c0b493 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/FunctionResult.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel; diff --git a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs index 8970251e614a..0fc3984f8d80 100644 --- a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs +++ b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs @@ -14,7 +14,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.SemanticKernel.AI.ChatCompletion; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Services; using Microsoft.SemanticKernel.TextGeneration; From 2a82d902cfbb37e532f743ba6d2cacffe01fdc91 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Apr 2025 13:55:52 +0100 Subject: [PATCH 05/22] Fix conflict, test passing --- .../Concepts/Agents/ChatCompletion_FunctionTermination.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs index ef054f73ecc9..f65541945084 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_FunctionTermination.cs @@ -55,8 +55,6 @@ async Task InvokeAgentAsync(string input) } } - [Fact] - public async Task UseAutoFunctionInvocationFilterWithStreamingAgentInvocationAsync() [Theory] [InlineData(true)] [InlineData(false)] From 8bcb33427145f10367a0caa453aaaec9492c0d4a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Apr 2025 14:04:05 +0100 Subject: [PATCH 06/22] Fix merge conflicts --- .../InternalUtilities/samples/InternalUtilities/BaseTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs index 3a2b5766ed35..2fefb6ee9d16 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs @@ -109,7 +109,7 @@ protected IChatClient AddChatClientToKernel(IKernelBuilder builder) openAIClient: new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)), - modelId: TestConfiguration.AzureOpenAI.ChatModelId); + modelId: TestConfiguration.AzureOpenAI.ChatDeploymentName); } else { @@ -117,7 +117,7 @@ protected IChatClient AddChatClientToKernel(IKernelBuilder builder) openAIClient: new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), credential: new AzureCliCredential()), - modelId: TestConfiguration.AzureOpenAI.ChatModelId); + modelId: TestConfiguration.AzureOpenAI.ChatDeploymentName); } var functionCallingChatClient = chatClient!.AsKernelFunctionInvokingChatClient(); From 65f72adbf2fbc92055a56f998a6b938d28d6ddd2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Apr 2025 14:20:05 +0100 Subject: [PATCH 07/22] Fix format --- .../Functions/KernelFunctionFromPromptTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs index 7d2789a3591c..8d4650a54125 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs @@ -1253,7 +1253,7 @@ public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingContentAsync() { GetStreamingResponseResult = [ new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can ") { RawRepresentation = rawRepresentation }, - new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } ] }; From f685ac40e9e6e58c4b4ba3deacc315adb0b08afd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 1 Apr 2025 14:48:19 +0100 Subject: [PATCH 08/22] Fix format --- .../Functions/KernelFunctionFromPromptTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs index 8d4650a54125..7bb77ce26105 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionFromPromptTests.cs @@ -1220,7 +1220,7 @@ public async Task ItConvertsFromMEAIChatMessageUpdateToSKStreamingChatMessageCon { GetStreamingResponseResult = [ new MEAI.ChatResponseUpdate(MEAI.ChatRole.Assistant, "Hi! How can ") { RawRepresentation = rawRepresentation }, - new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } + new MEAI.ChatResponseUpdate(role: null, content: "I assist you today?") { RawRepresentation = rawRepresentation } ] }; From 8f92fac75817d4e019398044fed639233d7359e3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:56:19 +0100 Subject: [PATCH 09/22] .Net: AutoFunctionInvocation IChatClient Support (#11536) ### Motivation and Context Auto Invocation filters now are supported when using IChatClients with FunctionChoiceBehavior enabled. - Resolves #10730 - Added dedicated AddChatClient to OpenAI Connector - Added KernelFunctionInvokingChatClient - Updated dependency from ME.AI.Abstractions to ME.AI --- dotnet/Directory.Packages.props | 3 +- .../Agents/ChatCompletion_ServiceSelection.cs | 10 +- ...tClient_AutoFunctionInvocationFiltering.cs | 167 ++++ .../Kernel/CustomAIServiceSelector.cs | 8 +- .../Step06_DependencyInjection.cs | 22 +- .../Connectors.OpenAI.UnitTests.csproj | 9 + ...FunctionInvocationFilterChatClientTests.cs | 793 ++++++++++++++++++ .../Core/AutoFunctionInvocationFilterTests.cs | 4 +- ...IKernelBuilderExtensionsChatClientTests.cs | 92 ++ ...viceCollectionExtensionsChatClientTests.cs | 114 +++ ..._multiple_function_calls_test_response.txt | 9 + ...multiple_function_calls_test_response.json | 40 + ..._multiple_function_calls_test_response.txt | 5 + .../Connectors.OpenAI.csproj | 1 + ...penAIKernelBuilderExtensions.ChatClient.cs | 105 +++ .../OpenAIKernelBuilderExtensions.cs | 8 +- ...IServiceCollectionExtensions.ChatClient.cs | 154 ++++ .../OpenAI/OpenAIAudioToTextTests.cs | 2 +- .../OpenAI/OpenAIChatCompletionTests.cs | 53 +- .../OpenAI/OpenAITextToAudioTests.cs | 2 +- .../FunctionCalling/FunctionCallsProcessor.cs | 6 +- .../samples/InternalUtilities/BaseTest.cs | 22 +- .../AI/ChatClient/AIFunctionFactory.cs | 631 -------------- .../AI/ChatClient/ChatClientAIService.cs | 2 +- .../AI/ChatClient/ChatClientExtensions.cs | 39 +- .../AI/ChatClient/ChatMessageExtensions.cs | 13 +- .../AI/ChatClient/ChatOptionsExtensions.cs | 24 + .../AI/ChatClient/FunctionFactoryOptions.cs | 63 -- .../KernelFunctionInvocationContext.cs | 106 --- .../KernelFunctionInvokingChatClient.cs | 522 ++++++++---- .../AI/ChatCompletion/ChatHistory.cs | 84 +- .../ChatCompletion/ChatHistoryExtensions.cs | 61 ++ .../AI/PromptExecutionSettingsExtensions.cs | 9 +- .../AutoFunctionInvocationContext.cs | 295 ++++++- .../Functions/KernelFunction.cs | 1 - .../SemanticKernel.Abstractions.csproj | 2 +- .../Functions/KernelFunctionFromPrompt.cs | 6 +- .../AIFunctionKernelFunctionTests.cs | 4 +- .../ClientResultExceptionExtensionsTests.cs | 1 - .../CustomAIChatClientSelectorTests.cs | 2 +- .../OrderedAIServiceSelectorTests.cs | 2 +- 41 files changed, 2385 insertions(+), 1111 deletions(-) create mode 100644 dotnet/samples/Concepts/Filtering/ChatClient_AutoFunctionInvocationFiltering.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIKernelBuilderExtensionsChatClientTests.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIServiceCollectionExtensionsChatClientTests.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/chat_completion_streaming_chatclient_multiple_function_calls_test_response.txt create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_multiple_function_calls_test_response.json create mode 100644 dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt create mode 100644 dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.ChatClient.cs create mode 100644 dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIServiceCollectionExtensions.ChatClient.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs delete mode 100644 dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 7878b0d5b359..94e6c8de85ef 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -93,7 +93,6 @@ - @@ -109,7 +108,7 @@ - + diff --git a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs index a0aa892a7802..be69fe412d5e 100644 --- a/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs +++ b/dotnet/samples/Concepts/Agents/ChatCompletion_ServiceSelection.cs @@ -109,11 +109,11 @@ private Kernel CreateKernelWithTwoServices(bool useChatClient) { builder.Services.AddKeyedChatClient( ServiceKeyBad, - new OpenAI.OpenAIClient("bad-key").AsChatClient(TestConfiguration.OpenAI.ChatModelId)); + new OpenAI.OpenAIClient("bad-key").GetChatClient(TestConfiguration.OpenAI.ChatModelId).AsIChatClient()); builder.Services.AddKeyedChatClient( ServiceKeyGood, - new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey).AsChatClient(TestConfiguration.OpenAI.ChatModelId)); + new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey).GetChatClient(TestConfiguration.OpenAI.ChatModelId).AsIChatClient()); } else { @@ -122,14 +122,16 @@ private Kernel CreateKernelWithTwoServices(bool useChatClient) new Azure.AI.OpenAI.AzureOpenAIClient( new Uri(TestConfiguration.AzureOpenAI.Endpoint), new Azure.AzureKeyCredential("bad-key")) - .AsChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName)); + .GetChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName) + .AsIChatClient()); builder.Services.AddKeyedChatClient( ServiceKeyGood, new Azure.AI.OpenAI.AzureOpenAIClient( new Uri(TestConfiguration.AzureOpenAI.Endpoint), new Azure.AzureKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)) - .AsChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName)); + .GetChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName) + .AsIChatClient()); } } else diff --git a/dotnet/samples/Concepts/Filtering/ChatClient_AutoFunctionInvocationFiltering.cs b/dotnet/samples/Concepts/Filtering/ChatClient_AutoFunctionInvocationFiltering.cs new file mode 100644 index 000000000000..1e053618a385 --- /dev/null +++ b/dotnet/samples/Concepts/Filtering/ChatClient_AutoFunctionInvocationFiltering.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace Filtering; + +public class ChatClient_AutoFunctionInvocationFiltering(ITestOutputHelper output) : BaseTest(output) +{ + /// + /// Shows how to use . + /// + [Fact] + public async Task UsingAutoFunctionInvocationFilter() + { + var builder = Kernel.CreateBuilder(); + + builder.AddOpenAIChatClient("gpt-4", TestConfiguration.OpenAI.ApiKey); + + // This filter outputs information about auto function invocation and returns overridden result. + builder.Services.AddSingleton(new AutoFunctionInvocationFilter(this.Output)); + + var kernel = builder.Build(); + + var function = KernelFunctionFactory.CreateFromMethod(() => "Result from function", "MyFunction"); + + kernel.ImportPluginFromFunctions("MyPlugin", [function]); + + var executionSettings = new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Required([function], autoInvoke: true) + }; + + var result = await kernel.InvokePromptAsync("Invoke provided function and return result", new(executionSettings)); + + Console.WriteLine(result); + + // Output: + // Request sequence number: 0 + // Function sequence number: 0 + // Total number of functions: 1 + // Result from auto function invocation filter. + } + + /// + /// Shows how to get list of function calls by using . + /// + [Fact] + public async Task GetFunctionCallsWithFilterAsync() + { + var builder = Kernel.CreateBuilder(); + + builder.AddOpenAIChatCompletion("gpt-3.5-turbo-1106", TestConfiguration.OpenAI.ApiKey); + + builder.Services.AddSingleton(new FunctionCallsFilter(this.Output)); + + var kernel = builder.Build(); + + kernel.ImportPluginFromFunctions("HelperFunctions", + [ + kernel.CreateFunctionFromMethod(() => DateTime.UtcNow.ToString("R"), "GetCurrentUtcTime", "Retrieves the current time in UTC."), + kernel.CreateFunctionFromMethod((string cityName) => + cityName switch + { + "Boston" => "61 and rainy", + "London" => "55 and cloudy", + "Miami" => "80 and sunny", + "Paris" => "60 and rainy", + "Tokyo" => "50 and sunny", + "Sydney" => "75 and sunny", + "Tel Aviv" => "80 and sunny", + _ => "31 and snowing", + }, "GetWeatherForCity", "Gets the current weather for the specified city"), + ]); + + var executionSettings = new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + await foreach (var chunk in kernel.InvokePromptStreamingAsync("Check current UTC time and return current weather in Boston city.", new(executionSettings))) + { + Console.WriteLine(chunk.ToString()); + } + + // Output: + // Request #0. Function call: HelperFunctions.GetCurrentUtcTime. + // Request #0. Function call: HelperFunctions.GetWeatherForCity. + // The current UTC time is {time of execution}, and the current weather in Boston is 61°F and rainy. + } + + /// Shows available syntax for auto function invocation filter. + private sealed class AutoFunctionInvocationFilter(ITestOutputHelper output) : IAutoFunctionInvocationFilter + { + public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + // Example: get function information + var functionName = context.Function.Name; + + // Example: get chat history + var chatHistory = context.ChatHistory; + + // Example: get information about all functions which will be invoked + var functionCalls = FunctionCallContent.GetFunctionCalls(context.ChatHistory.Last()); + + // In function calling functionality there are two loops. + // Outer loop is "request" loop - it performs multiple requests to LLM until user ask will be satisfied. + // Inner loop is "function" loop - it handles LLM response with multiple function calls. + + // Workflow example: + // 1. Request to LLM #1 -> Response with 3 functions to call. + // 1.1. Function #1 called. + // 1.2. Function #2 called. + // 1.3. Function #3 called. + // 2. Request to LLM #2 -> Response with 2 functions to call. + // 2.1. Function #1 called. + // 2.2. Function #2 called. + + // context.RequestSequenceIndex - it's a sequence number of outer/request loop operation. + // context.FunctionSequenceIndex - it's a sequence number of inner/function loop operation. + // context.FunctionCount - number of functions which will be called per request (based on example above: 3 for first request, 2 for second request). + + // Example: get request sequence index + output.WriteLine($"Request sequence index: {context.RequestSequenceIndex}"); + + // Example: get function sequence index + output.WriteLine($"Function sequence index: {context.FunctionSequenceIndex}"); + + // Example: get total number of functions which will be called + output.WriteLine($"Total number of functions: {context.FunctionCount}"); + + // Calling next filter in pipeline or function itself. + // By skipping this call, next filters and function won't be invoked, and function call loop will proceed to the next function. + await next(context); + + // Example: get function result + var result = context.Result; + + // Example: override function result value + context.Result = new FunctionResult(context.Result, "Result from auto function invocation filter"); + + // Example: Terminate function invocation + context.Terminate = true; + } + } + + /// Shows how to get list of all function calls per request. + private sealed class FunctionCallsFilter(ITestOutputHelper output) : IAutoFunctionInvocationFilter + { + public async Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + var chatHistory = context.ChatHistory; + var functionCalls = FunctionCallContent.GetFunctionCalls(chatHistory.Last()).ToArray(); + + if (functionCalls is { Length: > 0 }) + { + foreach (var functionCall in functionCalls) + { + output.WriteLine($"Request #{context.RequestSequenceIndex}. Function call: {functionCall.PluginName}.{functionCall.FunctionName}."); + } + } + + await next(context); + } + } +} diff --git a/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs b/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs index d4631323c24d..02ddbdb3ec35 100644 --- a/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs +++ b/dotnet/samples/Concepts/Kernel/CustomAIServiceSelector.cs @@ -10,7 +10,7 @@ namespace KernelExamples; /// -/// This sample shows how to use a custom AI service selector to select a specific model by matching it's id. +/// This sample shows how to use a custom AI service selector to select a specific model by matching the model id. /// public class CustomAIServiceSelector(ITestOutputHelper output) : BaseTest(output) { @@ -39,7 +39,8 @@ public async Task UsingCustomSelectToSelectServiceByMatchingModelId() builder.Services .AddSingleton(customSelector) .AddKeyedChatClient("OpenAIChatClient", new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey) - .AsChatClient("gpt-4o")); // Add a IChatClient to the kernel + .GetChatClient("gpt-4o") + .AsIChatClient()); // Add a IChatClient to the kernel Kernel kernel = builder.Build(); @@ -60,7 +61,6 @@ private sealed class GptAIServiceSelector(string modelNameStartsWith, ITestOutpu private readonly ITestOutputHelper _output = output; private readonly string _modelNameStartsWith = modelNameStartsWith; - /// private bool TrySelect( Kernel kernel, KernelFunction function, KernelArguments arguments, [NotNullWhen(true)] out T? service, out PromptExecutionSettings? serviceSettings) where T : class @@ -78,7 +78,7 @@ private bool TrySelect( else if (serviceToCheck is IChatClient chatClient) { var metadata = chatClient.GetService(); - serviceModelId = metadata?.ModelId; + serviceModelId = metadata?.DefaultModelId; endpoint = metadata?.ProviderUri?.ToString(); } diff --git a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs index 8935e4d66d48..39106b957841 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs @@ -43,25 +43,25 @@ public async Task UseDependencyInjectionToCreateAgentAsync(bool useChatClient) IChatClient chatClient; if (this.UseOpenAIConfig) { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey), - TestConfiguration.OpenAI.ChatModelId); + chatClient = new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient(); } else if (!string.IsNullOrEmpty(this.ApiKey)) { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - openAIClient: new AzureOpenAIClient( + chatClient = new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), - credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)), - modelId: TestConfiguration.AzureOpenAI.ChatModelId); + credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient(); } else { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - openAIClient: new AzureOpenAIClient( + chatClient = new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), - credential: new AzureCliCredential()), - modelId: TestConfiguration.AzureOpenAI.ChatModelId); + credential: new AzureCliCredential()) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient(); } var functionCallingChatClient = chatClient!.AsKernelFunctionInvokingChatClient(); diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Connectors.OpenAI.UnitTests.csproj b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Connectors.OpenAI.UnitTests.csproj index 57f983dd5c9b..04d35b9e6561 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Connectors.OpenAI.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Connectors.OpenAI.UnitTests.csproj @@ -96,6 +96,15 @@ Always + + Always + + + Always + + + Always + diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs new file mode 100644 index 000000000000..dd8d94c99824 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterChatClientTests.cs @@ -0,0 +1,793 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Core; + +public sealed class AutoFunctionInvocationFilterChatClientTests : IDisposable +{ + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public AutoFunctionInvocationFilterChatClientTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Fact] + public async Task FiltersAreExecutedCorrectlyAsync() + { + // Arrange + int filterInvocations = 0; + int functionInvocations = 0; + int[] expectedRequestSequenceNumbers = [0, 0, 1, 1]; + int[] expectedFunctionSequenceNumbers = [0, 1, 0, 1]; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + Kernel? contextKernel = null; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + contextKernel = context.Kernel; + + if (context.ChatHistory.Last() is OpenAIChatMessageContent content) + { + Assert.Equal(2, content.ToolCalls.Count); + } + + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + filterInvocations++; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + Assert.Equal(4, filterInvocations); + Assert.Equal(4, functionInvocations); + Assert.Equal(expectedRequestSequenceNumbers, requestSequenceNumbers); + Assert.Equal(expectedFunctionSequenceNumbers, functionSequenceNumbers); + Assert.Same(kernel, contextKernel); + Assert.Equal("Test chat response", result.ToString()); + } + + [Fact] + public async Task FunctionSequenceIndexIsCorrectForConcurrentCallsAsync() + { + // Arrange + List functionSequenceNumbers = []; + List expectedFunctionSequenceNumbers = [0, 1, 0, 1]; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() + { + AllowParallelCalls = true, + AllowConcurrentInvocation = true + }) + })); + + // Assert + Assert.Equal(expectedFunctionSequenceNumbers, functionSequenceNumbers); + } + + [Fact] + public async Task FiltersAreExecutedCorrectlyOnStreamingAsync() + { + // Arrange + int filterInvocations = 0; + int functionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + if (context.ChatHistory.Last() is OpenAIChatMessageContent content) + { + Assert.Equal(2, content.ToolCalls.Count); + } + + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + filterInvocations++; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new OpenAIPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { } + + // Assert + Assert.Equal(4, filterInvocations); + Assert.Equal(4, functionInvocations); + Assert.Equal([0, 0, 1, 1], requestSequenceNumbers); + Assert.Equal([0, 1, 0, 1], functionSequenceNumbers); + } + + [Fact] + public async Task DifferentWaysOfAddingFiltersWorkCorrectlyAsync() + { + // Arrange + var executionOrder = new List(); + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var filter1 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter1-Invoking"); + await next(context); + }); + + var filter2 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter2-Invoking"); + await next(context); + }); + + var builder = Kernel.CreateBuilder(); + + builder.Plugins.Add(plugin); + + builder.Services.AddOpenAIChatClient("model-id", "test-api-key", "organization-id", httpClient: this._httpClient); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + + // Case #1 - Add filter to services + builder.Services.AddSingleton(filter1); + + var kernel = builder.Build(); + + // Case #2 - Add filter to kernel + kernel.AutoFunctionInvocationFilters.Add(filter2); + + var result = await kernel.InvokePromptAsync("Test prompt", new(new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + Assert.Equal("Filter1-Invoking", executionOrder[0]); + Assert.Equal("Filter2-Invoking", executionOrder[1]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MultipleFiltersAreExecutedInOrderAsync(bool isStreaming) + { + // Arrange + var executionOrder = new List(); + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var filter1 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter1-Invoking"); + await next(context); + executionOrder.Add("Filter1-Invoked"); + }); + + var filter2 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter2-Invoking"); + await next(context); + executionOrder.Add("Filter2-Invoked"); + }); + + var filter3 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter3-Invoking"); + await next(context); + executionOrder.Add("Filter3-Invoked"); + }); + + var builder = Kernel.CreateBuilder(); + + builder.Plugins.Add(plugin); + + builder.Services.AddOpenAIChatClient("model-id", "test-api-key", "organization-id", httpClient: this._httpClient); + + builder.Services.AddSingleton(filter1); + builder.Services.AddSingleton(filter2); + builder.Services.AddSingleton(filter3); + + var kernel = builder.Build(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + if (isStreaming) + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(settings))) + { } + } + else + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + await kernel.InvokePromptAsync("Test prompt", new(settings)); + } + + // Assert + Assert.Equal("Filter1-Invoking", executionOrder[0]); + Assert.Equal("Filter2-Invoking", executionOrder[1]); + Assert.Equal("Filter3-Invoking", executionOrder[2]); + Assert.Equal("Filter3-Invoked", executionOrder[3]); + Assert.Equal("Filter2-Invoked", executionOrder[4]); + Assert.Equal("Filter1-Invoked", executionOrder[5]); + } + + [Fact] + public async Task FilterCanOverrideArgumentsAsync() + { + // Arrange + const string NewValue = "NewValue"; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + context.Arguments!["parameter"] = NewValue; + await next(context); + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + var chatResponse = Assert.IsType(result.GetValue()); + Assert.NotNull(chatResponse); + + var lastFunctionResult = GetLastFunctionResultFromChatResponse(chatResponse); + Assert.NotNull(lastFunctionResult); + Assert.Equal("NewValue", lastFunctionResult.ToString()); + } + + [Fact] + public async Task FilterCanHandleExceptionAsync() + { + // Arrange + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { throw new KernelException("Exception from Function1"); }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => "Result from Function2", "Function2"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + try + { + await next(context); + } + catch (KernelException exception) + { + Assert.Equal("Exception from Function1", exception.Message); + context.Result = new FunctionResult(context.Result, "Result from filter"); + } + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + var chatClient = kernel.GetRequiredService(); + + var executionSettings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + var options = executionSettings.ToChatOptions(kernel); + List messageList = [new(ChatRole.System, "System message")]; + + // Act + var resultMessages = await chatClient.GetResponseAsync(messageList, options, CancellationToken.None); + + // Assert + var firstToolMessage = resultMessages.Messages.First(m => m.Role == ChatRole.Tool); + Assert.NotNull(firstToolMessage); + var firstFunctionResult = firstToolMessage.Contents[^2] as Microsoft.Extensions.AI.FunctionResultContent; + var secondFunctionResult = firstToolMessage.Contents[^1] as Microsoft.Extensions.AI.FunctionResultContent; + + Assert.NotNull(firstFunctionResult); + Assert.NotNull(secondFunctionResult); + Assert.Equal("Result from filter", firstFunctionResult.Result!.ToString()); + Assert.Equal("Result from Function2", secondFunctionResult.Result!.ToString()); + } + + [Fact] + public async Task FilterCanHandleExceptionOnStreamingAsync() + { + // Arrange + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { throw new KernelException("Exception from Function1"); }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => "Result from Function2", "Function2"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + try + { + await next(context); + } + catch (KernelException) + { + context.Result = new FunctionResult(context.Result, "Result from filter"); + } + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var chatClient = kernel.GetRequiredService(); + + var executionSettings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + var options = executionSettings.ToChatOptions(kernel); + List messageList = []; + + // Act + List streamingContent = []; + await foreach (var update in chatClient.GetStreamingResponseAsync(messageList, options, CancellationToken.None)) + { + streamingContent.Add(update); + } + var chatResponse = streamingContent.ToChatResponse(); + + // Assert + var firstToolMessage = chatResponse.Messages.First(m => m.Role == ChatRole.Tool); + Assert.NotNull(firstToolMessage); + var firstFunctionResult = firstToolMessage.Contents[^2] as Microsoft.Extensions.AI.FunctionResultContent; + var secondFunctionResult = firstToolMessage.Contents[^1] as Microsoft.Extensions.AI.FunctionResultContent; + + Assert.NotNull(firstFunctionResult); + Assert.NotNull(secondFunctionResult); + Assert.Equal("Result from filter", firstFunctionResult.Result!.ToString()); + Assert.Equal("Result from Function2", secondFunctionResult.Result!.ToString()); + } + + [Fact] + public async Task FiltersCanSkipFunctionExecutionAsync() + { + // Arrange + int filterInvocations = 0; + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Filter delegate is invoked only for second function, the first one should be skipped. + if (context.Function.Name == "MyPlugin_Function2") + { + await next(context); + } + + filterInvocations++; + }); + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(File.ReadAllText("TestData/filters_chatclient_multiple_function_calls_test_response.json")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(File.ReadAllText("TestData/chat_completion_test_response.json")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + Assert.Equal(2, filterInvocations); + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(1, secondFunctionInvocations); + } + + [Fact] + public async Task PreFilterCanTerminateOperationAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Terminating before first function, so all functions won't be invoked. + context.Terminate = true; + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + await kernel.InvokePromptAsync("Test prompt", new(new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + } + + [Fact] + public async Task PreFilterCanTerminateOperationOnStreamingAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Terminating before first function, so all functions won't be invoked. + context.Terminate = true; + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { } + + // Assert + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + } + + [Fact] + public async Task PostFilterCanTerminateOperationAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + // Terminating after first function, so second function won't be invoked. + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var functionResult = await kernel.InvokePromptAsync("Test prompt", new(new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + })); + + // Assert + Assert.Equal(1, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + Assert.Equal([0], requestSequenceNumbers); + Assert.Equal([0], functionSequenceNumbers); + + // Results of function invoked before termination should be returned + var chatResponse = functionResult.GetValue(); + Assert.NotNull(chatResponse); + + var result = GetLastFunctionResultFromChatResponse(chatResponse); + Assert.NotNull(result); + Assert.Equal("function1-value", result.ToString()); + } + + [Fact] + public async Task PostFilterCanTerminateOperationOnStreamingAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + // Terminating after first function, so second function won't be invoked. + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + List streamingContent = []; + + // Act + await foreach (var update in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { + streamingContent.Add(update); + } + + // Assert + Assert.Equal(1, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + Assert.Equal([0], requestSequenceNumbers); + Assert.Equal([0], functionSequenceNumbers); + + // Results of function invoked before termination should be returned + Assert.Equal(4, streamingContent.Count); + + var chatResponse = streamingContent.ToChatResponse(); + Assert.NotNull(chatResponse); + + var result = GetLastFunctionResultFromChatResponse(chatResponse); + Assert.NotNull(result); + Assert.Equal("function1-value", result.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FilterContextHasValidStreamingFlagAsync(bool isStreaming) + { + // Arrange + bool? actualStreamingFlag = null; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var filter = new AutoFunctionInvocationFilter(async (context, next) => + { + actualStreamingFlag = context.IsStreaming; + await next(context); + }); + + var builder = Kernel.CreateBuilder(); + + builder.Plugins.Add(plugin); + + builder.Services.AddOpenAIChatClient("model-id", "test-api-key", "organization-id", httpClient: this._httpClient); + + builder.Services.AddSingleton(filter); + + var kernel = builder.Build(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + if (isStreaming) + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + await kernel.InvokePromptStreamingAsync("Test prompt", new(settings)).ToListAsync(); + } + else + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + await kernel.InvokePromptAsync("Test prompt", new(settings)); + } + + // Assert + Assert.Equal(isStreaming, actualStreamingFlag); + } + + [Fact] + public async Task PromptExecutionSettingsArePropagatedFromInvokePromptToFilterContextAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [KernelFunctionFactory.CreateFromMethod(() => { }, "Function1")]); + + AutoFunctionInvocationContext? actualContext = null; + + var kernel = this.GetKernelWithFilter(plugin, (context, next) => + { + actualContext = context; + return Task.CompletedTask; + }); + + var expectedExecutionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(expectedExecutionSettings)); + + // Assert + Assert.NotNull(actualContext); + Assert.Same(expectedExecutionSettings, actualContext!.ExecutionSettings); + } + + [Fact] + public async Task PromptExecutionSettingsArePropagatedFromInvokePromptStreamingToFilterContextAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [KernelFunctionFactory.CreateFromMethod(() => { }, "Function1")]); + + AutoFunctionInvocationContext? actualContext = null; + + var kernel = this.GetKernelWithFilter(plugin, (context, next) => + { + actualContext = context; + return Task.CompletedTask; + }); + + var expectedExecutionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(expectedExecutionSettings))) + { } + + // Assert + Assert.NotNull(actualContext); + Assert.Same(expectedExecutionSettings, actualContext!.ExecutionSettings); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + #region private + + private static object? GetLastFunctionResultFromChatResponse(ChatResponse chatResponse) + { + Assert.NotEmpty(chatResponse.Messages); + var chatMessage = chatResponse.Messages[^1]; + + Assert.NotEmpty(chatMessage.Contents); + Assert.Contains(chatMessage.Contents, c => c is Microsoft.Extensions.AI.FunctionResultContent); + + var resultContent = (Microsoft.Extensions.AI.FunctionResultContent)chatMessage.Contents.Last(c => c is Microsoft.Extensions.AI.FunctionResultContent); + return resultContent.Result; + } + +#pragma warning disable CA2000 // Dispose objects before losing scope + private static List GetFunctionCallingResponses() + { + return [ + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/filters_chatclient_multiple_function_calls_test_response.json")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/filters_chatclient_multiple_function_calls_test_response.json")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/chat_completion_test_response.json")) } + ]; + } + + private static List GetFunctionCallingStreamingResponses() + { + return [ + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(File.OpenRead("TestData/chat_completion_streaming_test_response.txt")) } + ]; + } +#pragma warning restore CA2000 + + private Kernel GetKernelWithFilter( + KernelPlugin plugin, + Func, Task>? onAutoFunctionInvocation) + { + var builder = Kernel.CreateBuilder(); + var filter = new AutoFunctionInvocationFilter(onAutoFunctionInvocation); + + builder.Plugins.Add(plugin); + builder.Services.AddSingleton(filter); + + builder.AddOpenAIChatClient("model-id", "test-api-key", "organization-id", httpClient: this._httpClient); + + return builder.Build(); + } + + private sealed class AutoFunctionInvocationFilter( + Func, Task>? onAutoFunctionInvocation) : IAutoFunctionInvocationFilter + { + private readonly Func, Task>? _onAutoFunctionInvocation = onAutoFunctionInvocation; + + public Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) => + this._onAutoFunctionInvocation?.Invoke(context, next) ?? Task.CompletedTask; + } + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs index 19992be01667..b308206b12d5 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/AutoFunctionInvocationFilterTests.cs @@ -312,7 +312,7 @@ public async Task FilterCanOverrideArgumentsAsync() // Act var result = await kernel.InvokePromptAsync("Test prompt", new(new OpenAIPromptExecutionSettings { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() })); // Assert @@ -596,7 +596,7 @@ public async Task PostFilterCanTerminateOperationOnStreamingAsync() Assert.Equal([0], requestSequenceNumbers); Assert.Equal([0], functionSequenceNumbers); - // Results of function invoked before termination should be returned + // Results of function invoked before termination should be returned Assert.Equal(3, streamingContent.Count); var lastMessageContent = streamingContent[^1] as StreamingChatMessageContent; diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIKernelBuilderExtensionsChatClientTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIKernelBuilderExtensionsChatClientTests.cs new file mode 100644 index 000000000000..437d347aa194 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIKernelBuilderExtensionsChatClientTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class OpenAIKernelBuilderExtensionsChatClientTests +{ + [Fact] + public void AddOpenAIChatClientNullArgsThrow() + { + // Arrange + IKernelBuilder builder = null!; + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + + // Act & Assert + var exception = Assert.Throws(() => builder.AddOpenAIChatClient(modelId, apiKey, orgId, serviceId)); + Assert.Equal("builder", exception.ParamName); + + exception = Assert.Throws(() => builder.AddOpenAIChatClient(modelId, new OpenAIClient(apiKey), serviceId)); + Assert.Equal("builder", exception.ParamName); + + using var httpClient = new HttpClient(); + exception = Assert.Throws(() => builder.AddOpenAIChatClient(modelId, new Uri("http://localhost"), apiKey, orgId, serviceId, httpClient)); + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void AddOpenAIChatClientDefaultValidParametersRegistersService() + { + // Arrange + var builder = Kernel.CreateBuilder(); + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + + // Act + builder.AddOpenAIChatClient(modelId, apiKey, orgId, serviceId); + + // Assert + var kernel = builder.Build(); + Assert.NotNull(kernel.GetRequiredService()); + Assert.NotNull(kernel.GetRequiredService(serviceId)); + } + + [Fact] + public void AddOpenAIChatClientOpenAIClientValidParametersRegistersService() + { + // Arrange + var builder = Kernel.CreateBuilder(); + string modelId = "gpt-3.5-turbo"; + var openAIClient = new OpenAIClient("test_api_key"); + string serviceId = "test_service_id"; + + // Act + builder.AddOpenAIChatClient(modelId, openAIClient, serviceId); + + // Assert + var kernel = builder.Build(); + Assert.NotNull(kernel.GetRequiredService()); + Assert.NotNull(kernel.GetRequiredService(serviceId)); + } + + [Fact] + public void AddOpenAIChatClientCustomEndpointValidParametersRegistersService() + { + // Arrange + var builder = Kernel.CreateBuilder(); + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + using var httpClient = new HttpClient(); + + // Act + builder.AddOpenAIChatClient(modelId, new Uri("http://localhost"), apiKey, orgId, serviceId, httpClient); + + // Assert + var kernel = builder.Build(); + Assert.NotNull(kernel.GetRequiredService()); + Assert.NotNull(kernel.GetRequiredService(serviceId)); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIServiceCollectionExtensionsChatClientTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIServiceCollectionExtensionsChatClientTests.cs new file mode 100644 index 000000000000..7a3888b95f30 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Extensions/OpenAIServiceCollectionExtensionsChatClientTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Extensions; + +public class OpenAIServiceCollectionExtensionsChatClientTests +{ + [Fact] + public void AddOpenAIChatClientNullArgsThrow() + { + // Arrange + ServiceCollection services = null!; + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + + // Act & Assert + var exception = Assert.Throws(() => services.AddOpenAIChatClient(modelId, apiKey, orgId, serviceId)); + Assert.Equal("services", exception.ParamName); + + exception = Assert.Throws(() => services.AddOpenAIChatClient(modelId, new OpenAIClient(apiKey), serviceId)); + Assert.Equal("services", exception.ParamName); + + using var httpClient = new HttpClient(); + exception = Assert.Throws(() => services.AddOpenAIChatClient(modelId, new Uri("http://localhost"), apiKey, orgId, serviceId, httpClient)); + Assert.Equal("services", exception.ParamName); + } + + [Fact] + public void AddOpenAIChatClientDefaultValidParametersRegistersService() + { + // Arrange + var services = new ServiceCollection(); + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + + // Act + services.AddOpenAIChatClient(modelId, apiKey, orgId, serviceId); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var chatClient = serviceProvider.GetKeyedService(serviceId); + Assert.NotNull(chatClient); + } + + [Fact] + public void AddOpenAIChatClientOpenAIClientValidParametersRegistersService() + { + // Arrange + var services = new ServiceCollection(); + string modelId = "gpt-3.5-turbo"; + var openAIClient = new OpenAIClient("test_api_key"); + string serviceId = "test_service_id"; + + // Act + services.AddOpenAIChatClient(modelId, openAIClient, serviceId); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var chatClient = serviceProvider.GetKeyedService(serviceId); + Assert.NotNull(chatClient); + } + + [Fact] + public void AddOpenAIChatClientCustomEndpointValidParametersRegistersService() + { + // Arrange + var services = new ServiceCollection(); + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + using var httpClient = new HttpClient(); + // Act + services.AddOpenAIChatClient(modelId, new Uri("http://localhost"), apiKey, orgId, serviceId, httpClient); + // Assert + var serviceProvider = services.BuildServiceProvider(); + var chatClient = serviceProvider.GetKeyedService(serviceId); + Assert.NotNull(chatClient); + } + + [Fact] + public void AddOpenAIChatClientWorksWithKernel() + { + var services = new ServiceCollection(); + string modelId = "gpt-3.5-turbo"; + string apiKey = "test_api_key"; + string orgId = "test_org_id"; + string serviceId = "test_service_id"; + + // Act + services.AddOpenAIChatClient(modelId, apiKey, orgId, serviceId); + services.AddKernel(); + + var serviceProvider = services.BuildServiceProvider(); + var kernel = serviceProvider.GetRequiredService(); + + var serviceFromCollection = serviceProvider.GetKeyedService(serviceId); + var serviceFromKernel = kernel.GetRequiredService(serviceId); + + Assert.NotNull(serviceFromKernel); + Assert.Same(serviceFromCollection, serviceFromKernel); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/chat_completion_streaming_chatclient_multiple_function_calls_test_response.txt b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/chat_completion_streaming_chatclient_multiple_function_calls_test_response.txt new file mode 100644 index 000000000000..17ce94647fd5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/chat_completion_streaming_chatclient_multiple_function_calls_test_response.txt @@ -0,0 +1,9 @@ +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":0,"id":"1","type":"function","function":{"name":"MyPlugin_GetCurrentWeather","arguments":"{\n\"location\": \"Boston, MA\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":1,"id":"2","type":"function","function":{"name":"MyPlugin_FunctionWithException","arguments":"{\n\"argument\": \"value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":2,"id":"3","type":"function","function":{"name":"MyPlugin_NonExistentFunction","arguments":"{\n\"argument\": \"value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":3,"id":"4","type":"function","function":{"name":"MyPlugin_InvalidArguments","arguments":"invalid_arguments_format"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_multiple_function_calls_test_response.json b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_multiple_function_calls_test_response.json new file mode 100644 index 000000000000..2c499b14089f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_multiple_function_calls_test_response.json @@ -0,0 +1,40 @@ +{ + "id": "response-id", + "object": "chat.completion", + "created": 1699896916, + "model": "gpt-3.5-turbo-0613", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": { + "name": "MyPlugin_Function1", + "arguments": "{\n\"parameter\": \"function1-value\"\n}" + } + }, + { + "id": "2", + "type": "function", + "function": { + "name": "MyPlugin_Function2", + "arguments": "{\n\"parameter\": \"function2-value\"\n}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 82, + "completion_tokens": 17, + "total_tokens": 99 + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt new file mode 100644 index 000000000000..c113e3fa97ca --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/TestData/filters_chatclient_streaming_multiple_function_calls_test_response.txt @@ -0,0 +1,5 @@ +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":0,"id":"1","type":"function","function":{"name":"MyPlugin_Function1","arguments":"{\n\"parameter\": \"function1-value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":1,"id":"2","type":"function","function":{"name":"MyPlugin_Function2","arguments":"{\n\"parameter\": \"function2-value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Connectors.OpenAI.csproj b/dotnet/src/Connectors/Connectors.OpenAI/Connectors.OpenAI.csproj index 64a0e72bde6d..c17e878a7a42 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Connectors.OpenAI.csproj +++ b/dotnet/src/Connectors/Connectors.OpenAI/Connectors.OpenAI.csproj @@ -37,6 +37,7 @@ + diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.ChatClient.cs b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.ChatClient.cs new file mode 100644 index 000000000000..9d1832b340ff --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.ChatClient.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace Microsoft.SemanticKernel; + +/// Extension methods for . +[Experimental("SKEXP0010")] +public static class OpenAIChatClientKernelBuilderExtensions +{ + #region Chat Completion + + /// + /// Adds an OpenAI to the . + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The HttpClient to use with this service. + /// The same instance as . + public static IKernelBuilder AddOpenAIChatClient( + this IKernelBuilder builder, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + + builder.Services.AddOpenAIChatClient( + modelId, + apiKey, + orgId, + serviceId, + httpClient); + + return builder; + } + + /// + /// Adds an OpenAI to the . + /// + /// The instance to augment. + /// OpenAI model id + /// to use for the service. If null, one must be available in the service provider when this service is resolved. + /// A local identifier for the given AI service + /// The same instance as . + public static IKernelBuilder AddOpenAIChatClient( + this IKernelBuilder builder, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(builder); + + builder.Services.AddOpenAIChatClient( + modelId, + openAIClient, + serviceId); + + return builder; + } + + /// + /// Adds a custom endpoint OpenAI to the . + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// Custom OpenAI Compatible Message API endpoint + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The HttpClient to use with this service. + /// The same instance as . + public static IKernelBuilder AddOpenAIChatClient( + this IKernelBuilder builder, + string modelId, + Uri endpoint, + string? apiKey, + string? orgId = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(builder); + + builder.Services.AddOpenAIChatClient( + modelId, + endpoint, + apiKey, + orgId, + serviceId, + httpClient); + + return builder; + } + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.cs b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.cs index a07a81fdb5b3..01307b9adc2a 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIKernelBuilderExtensions.cs @@ -20,7 +20,7 @@ namespace Microsoft.SemanticKernel; /// -/// Sponsor extensions class for . +/// Extension methods for . /// public static class OpenAIKernelBuilderExtensions { @@ -269,7 +269,7 @@ public static IKernelBuilder AddOpenAIFiles( #region Chat Completion /// - /// Adds the OpenAI chat completion service to the list. + /// Adds an to the . /// /// The instance to augment. /// OpenAI model name, see https://platform.openai.com/docs/models @@ -304,7 +304,7 @@ OpenAIChatCompletionService Factory(IServiceProvider serviceProvider, object? _) } /// - /// Adds the OpenAI chat completion service to the list. + /// Adds an to the . /// /// The instance to augment. /// OpenAI model id @@ -330,7 +330,7 @@ OpenAIChatCompletionService Factory(IServiceProvider serviceProvider, object? _) } /// - /// Adds the Custom Endpoint OpenAI chat completion service to the list. + /// Adds a custom endpoint to the . /// /// The instance to augment. /// OpenAI model name, see https://platform.openai.com/docs/models diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIServiceCollectionExtensions.ChatClient.cs b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIServiceCollectionExtensions.ChatClient.cs new file mode 100644 index 000000000000..2954e958936a --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI/Extensions/OpenAIServiceCollectionExtensions.ChatClient.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Http; +using OpenAI; + +namespace Microsoft.SemanticKernel; + +/// +/// Sponsor extensions class for . +/// +[Experimental("SKEXP0010")] +public static class OpenAIChatClientServiceCollectionExtensions +{ + /// + /// White space constant. + /// + private const string SingleSpace = " "; + + /// + /// Adds the OpenAI chat completion service to the list. + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The HttpClient to use with this service. + /// The same instance as . + public static IServiceCollection AddOpenAIChatClient( + this IServiceCollection services, + string modelId, + string apiKey, + string? orgId = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(services); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + return new OpenAIClient(new ApiKeyCredential(apiKey ?? SingleSpace), options: GetClientOptions(orgId: orgId, httpClient: HttpClientProvider.GetHttpClient(httpClient, serviceProvider))) + .GetChatClient(modelId) + .AsIChatClient() + .AsKernelFunctionInvokingChatClient(loggerFactory); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + /// + /// Adds the OpenAI chat completion service to the list. + /// + /// The instance to augment. + /// OpenAI model id + /// to use for the service. If null, one must be available in the service provider when this service is resolved. + /// A local identifier for the given AI service + /// The same instance as . + public static IServiceCollection AddOpenAIChatClient(this IServiceCollection services, + string modelId, + OpenAIClient? openAIClient = null, + string? serviceId = null) + { + Verify.NotNull(services); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + return (openAIClient ?? serviceProvider.GetRequiredService()) + .GetChatClient(modelId) + .AsIChatClient() + .AsKernelFunctionInvokingChatClient(loggerFactory); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + /// + /// Adds the Custom OpenAI chat completion service to the list. + /// + /// The instance to augment. + /// OpenAI model name, see https://platform.openai.com/docs/models + /// A Custom Message API compatible endpoint. + /// OpenAI API key, see https://platform.openai.com/account/api-keys + /// OpenAI organization id. This is usually optional unless your account belongs to multiple organizations. + /// A local identifier for the given AI service + /// The HttpClient to use with this service. + /// The same instance as . + public static IServiceCollection AddOpenAIChatClient( + this IServiceCollection services, + string modelId, + Uri endpoint, + string? apiKey = null, + string? orgId = null, + string? serviceId = null, + HttpClient? httpClient = null) + { + Verify.NotNull(services); + + IChatClient Factory(IServiceProvider serviceProvider, object? _) + { + var loggerFactory = serviceProvider.GetService(); + + return new OpenAIClient(new ApiKeyCredential(apiKey ?? SingleSpace), GetClientOptions(endpoint, orgId, HttpClientProvider.GetHttpClient(httpClient, serviceProvider))) + .GetChatClient(modelId) + .AsIChatClient() + .AsKernelFunctionInvokingChatClient(loggerFactory); + } + + services.AddKeyedSingleton(serviceId, (Func)Factory); + + return services; + } + + private static OpenAIClientOptions GetClientOptions( + Uri? endpoint = null, + string? orgId = null, + HttpClient? httpClient = null) + { + OpenAIClientOptions options = new(); + + if (endpoint is not null) + { + options.Endpoint = endpoint; + } + + if (orgId is not null) + { + options.OrganizationId = orgId; + } + + if (httpClient is not null) + { + options.Transport = new HttpClientPipelineTransport(httpClient); + } + + return options; + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAudioToTextTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAudioToTextTests.cs index 90375307c533..9e1127fa8b55 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAudioToTextTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAudioToTextTests.cs @@ -22,7 +22,7 @@ public sealed class OpenAIAudioToTextTests() .AddUserSecrets() .Build(); - [RetryFact]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + [RetryFact] //(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] public async Task OpenAIAudioToTextTestAsync() { // Arrange diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs index fe8ff155d9c5..ddfe6b997a25 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIChatCompletionTests.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; -using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; using OpenAI; @@ -48,16 +47,16 @@ public async Task ItCanUseOpenAiChatForTextGenerationAsync() [Fact] public async Task ItCanUseOpenAiChatClientAndContentsAsync() { - var OpenAIConfiguration = this._configuration.GetSection("OpenAI").Get(); - Assert.NotNull(OpenAIConfiguration); - Assert.NotNull(OpenAIConfiguration.ChatModelId); - Assert.NotNull(OpenAIConfiguration.ApiKey); - Assert.NotNull(OpenAIConfiguration.ServiceId); + var openAIConfiguration = this._configuration.GetSection("OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + Assert.NotNull(openAIConfiguration.ChatModelId); + Assert.NotNull(openAIConfiguration.ApiKey); + Assert.NotNull(openAIConfiguration.ServiceId); // Arrange - var openAIClient = new OpenAIClient(OpenAIConfiguration.ApiKey); + var openAIClient = new OpenAIClient(openAIConfiguration.ApiKey); var builder = Kernel.CreateBuilder(); - builder.Services.AddChatClient(openAIClient.AsChatClient(OpenAIConfiguration.ChatModelId)); + builder.Services.AddChatClient(openAIClient.GetChatClient(openAIConfiguration.ChatModelId).AsIChatClient()); var kernel = builder.Build(); var func = kernel.CreateFunctionFromPrompt( @@ -104,16 +103,16 @@ public async Task OpenAIStreamingTestAsync() [Fact] public async Task ItCanUseOpenAiStreamingChatClientAndContentsAsync() { - var OpenAIConfiguration = this._configuration.GetSection("OpenAI").Get(); - Assert.NotNull(OpenAIConfiguration); - Assert.NotNull(OpenAIConfiguration.ChatModelId); - Assert.NotNull(OpenAIConfiguration.ApiKey); - Assert.NotNull(OpenAIConfiguration.ServiceId); + var openAIConfiguration = this._configuration.GetSection("OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + Assert.NotNull(openAIConfiguration.ChatModelId); + Assert.NotNull(openAIConfiguration.ApiKey); + Assert.NotNull(openAIConfiguration.ServiceId); // Arrange - var openAIClient = new OpenAIClient(OpenAIConfiguration.ApiKey); + var openAIClient = new OpenAIClient(openAIConfiguration.ApiKey); var builder = Kernel.CreateBuilder(); - builder.Services.AddChatClient(openAIClient.AsChatClient(OpenAIConfiguration.ChatModelId)); + builder.Services.AddChatClient(openAIClient.GetChatClient(openAIConfiguration.ChatModelId).AsIChatClient()); var kernel = builder.Build(); var plugins = TestHelpers.ImportSamplePlugins(kernel, "ChatPlugin"); @@ -179,7 +178,7 @@ public async Task OpenAIHttpRetryPolicyTestAsync() // Assert Assert.All(statusCodes, s => Assert.Equal(HttpStatusCode.Unauthorized, s)); - Assert.Equal(HttpStatusCode.Unauthorized, ((HttpOperationException)exception).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, exception.StatusCode); } [Fact] @@ -258,11 +257,11 @@ public async Task SemanticKernelVersionHeaderIsSentAsync() var kernel = this.CreateAndInitializeKernel(httpClient); // Act - var result = await kernel.InvokePromptAsync("Where is the most famous fish market in Seattle, Washington, USA?"); + await kernel.InvokePromptAsync("Where is the most famous fish market in Seattle, Washington, USA?"); // Assert Assert.NotNull(httpHeaderHandler.RequestHeaders); - Assert.True(httpHeaderHandler.RequestHeaders.TryGetValues("Semantic-Kernel-Version", out var values)); + Assert.True(httpHeaderHandler.RequestHeaders.TryGetValues("Semantic-Kernel-Version", out var _)); } //[Theory(Skip = "This test is for manual verification.")] @@ -301,18 +300,18 @@ public async Task LogProbsDataIsReturnedWhenRequestedAsync(bool? logprobs, int? private Kernel CreateAndInitializeKernel(HttpClient? httpClient = null) { - var OpenAIConfiguration = this._configuration.GetSection("OpenAI").Get(); - Assert.NotNull(OpenAIConfiguration); - Assert.NotNull(OpenAIConfiguration.ChatModelId); - Assert.NotNull(OpenAIConfiguration.ApiKey); - Assert.NotNull(OpenAIConfiguration.ServiceId); + var openAIConfiguration = this._configuration.GetSection("OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + Assert.NotNull(openAIConfiguration.ChatModelId); + Assert.NotNull(openAIConfiguration.ApiKey); + Assert.NotNull(openAIConfiguration.ServiceId); - var kernelBuilder = base.CreateKernelBuilder(); + var kernelBuilder = this.CreateKernelBuilder(); kernelBuilder.AddOpenAIChatCompletion( - modelId: OpenAIConfiguration.ChatModelId, - apiKey: OpenAIConfiguration.ApiKey, - serviceId: OpenAIConfiguration.ServiceId, + modelId: openAIConfiguration.ChatModelId, + apiKey: openAIConfiguration.ApiKey, + serviceId: openAIConfiguration.ServiceId, httpClient: httpClient); return kernelBuilder.Build(); diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAITextToAudioTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAITextToAudioTests.cs index c2818abe2502..420295fe4349 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAITextToAudioTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAITextToAudioTests.cs @@ -18,7 +18,7 @@ public sealed class OpenAITextToAudioTests .AddUserSecrets() .Build(); - [Fact]//(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] + [Fact] //(Skip = "OpenAI will often throttle requests. This test is for manual verification.")] public async Task OpenAITextToAudioTestAsync() { // Arrange diff --git a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs index 97cb426c307d..88f3da9d6a53 100644 --- a/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs +++ b/dotnet/src/InternalUtilities/connectors/AI/FunctionCalling/FunctionCallsProcessor.cs @@ -211,7 +211,7 @@ public FunctionCallsProcessor(ILogger? logger = null) { bool terminationRequested = false; - // Wait for all of the function invocations to complete, then add the results to the chat, but stop when we hit a + // Wait for all the function invocations to complete, then add the results to the chat, but stop when we hit a // function for which termination was requested. FunctionResultContext[] resultContexts = await Task.WhenAll(functionTasks).ConfigureAwait(false); foreach (FunctionResultContext resultContext in resultContexts) @@ -487,8 +487,8 @@ public static string ProcessFunctionResult(object functionResult) return stringResult; } - // This is an optimization to use ChatMessageContent content directly - // without unnecessary serialization of the whole message content class. + // This is an optimization to use ChatMessageContent content directly + // without unnecessary serialization of the whole message content class. if (functionResult is ChatMessageContent chatMessageContent) { return chatMessageContent.ToString(); diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs index 2fefb6ee9d16..3a8d561f4eaf 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/BaseTest.cs @@ -99,25 +99,25 @@ protected IChatClient AddChatClientToKernel(IKernelBuilder builder) IChatClient chatClient; if (this.UseOpenAIConfig) { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey), - TestConfiguration.OpenAI.ChatModelId); + chatClient = new OpenAI.OpenAIClient(TestConfiguration.OpenAI.ApiKey) + .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + .AsIChatClient(); } else if (!string.IsNullOrEmpty(this.ApiKey)) { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - openAIClient: new AzureOpenAIClient( + chatClient = new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), - credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)), - modelId: TestConfiguration.AzureOpenAI.ChatDeploymentName); + credential: new ApiKeyCredential(TestConfiguration.AzureOpenAI.ApiKey)) + .GetChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName) + .AsIChatClient(); } else { - chatClient = new Microsoft.Extensions.AI.OpenAIChatClient( - openAIClient: new AzureOpenAIClient( + chatClient = new AzureOpenAIClient( endpoint: new Uri(TestConfiguration.AzureOpenAI.Endpoint), - credential: new AzureCliCredential()), - modelId: TestConfiguration.AzureOpenAI.ChatDeploymentName); + credential: new AzureCliCredential()) + .GetChatClient(TestConfiguration.AzureOpenAI.ChatDeploymentName) + .AsIChatClient(); } var functionCallingChatClient = chatClient!.AsKernelFunctionInvokingChatClient(); diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs deleted file mode 100644 index a0d6b1865a8f..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/AIFunctionFactory.cs +++ /dev/null @@ -1,631 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Buffers; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -#pragma warning disable IDE0009 // Use explicit 'this.' qualifier -#pragma warning disable IDE1006 // Missing static prefix s_ suffix - -namespace Microsoft.SemanticKernel.ChatCompletion; - -// Slight modified source from -// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs - -/// Provides factory methods for creating commonly used implementations of . -[ExcludeFromCodeCoverage] -internal static partial class AIFunctionFactory -{ - /// Holds the default options instance used when creating function. - private static readonly AIFunctionFactoryOptions _defaultOptions = new(); - - /// Creates an instance for a method, specified via a delegate. - /// The method to be represented via the created . - /// Metadata to use to override defaults inferred from . - /// The created for invoking . - /// - /// - /// Return values are serialized to using 's - /// . Arguments that are not already of the expected type are - /// marshaled to the expected type via JSON and using 's - /// . If the argument is a , - /// , or , it is deserialized directly. If the argument is anything else unknown, - /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. - /// - /// - public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? options) - { - Verify.NotNull(method); - - return ReflectionAIFunction.Build(method.Method, method.Target, options ?? _defaultOptions); - } - - /// Creates an instance for a method, specified via a delegate. - /// The method to be represented via the created . - /// The name to use for the . - /// The description to use for the . - /// The used to marshal function parameters and any return value. - /// The created for invoking . - /// - /// - /// Return values are serialized to using . - /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using - /// . If the argument is a , , - /// or , it is deserialized directly. If the argument is anything else unknown, it is - /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. - /// - /// - public static AIFunction Create(Delegate method, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) - { - Verify.NotNull(method); - - AIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null - ? _defaultOptions - : new() - { - Name = name, - Description = description, - SerializerOptions = serializerOptions, - }; - - return ReflectionAIFunction.Build(method.Method, method.Target, createOptions); - } - - /// - /// Creates an instance for a method, specified via an instance - /// and an optional target object if the method is an instance method. - /// - /// The method to be represented via the created . - /// - /// The target object for the if it represents an instance method. - /// This should be if and only if is a static method. - /// - /// Metadata to use to override defaults inferred from . - /// The created for invoking . - /// - /// - /// Return values are serialized to using 's - /// . Arguments that are not already of the expected type are - /// marshaled to the expected type via JSON and using 's - /// . If the argument is a , - /// , or , it is deserialized directly. If the argument is anything else unknown, - /// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. - /// - /// - public static AIFunction Create(MethodInfo method, object? target, AIFunctionFactoryOptions? options) - { - Verify.NotNull(method); - - return ReflectionAIFunction.Build(method, target, options ?? _defaultOptions); - } - - /// - /// Creates an instance for a method, specified via an instance - /// and an optional target object if the method is an instance method. - /// - /// The method to be represented via the created . - /// - /// The target object for the if it represents an instance method. - /// This should be if and only if is a static method. - /// - /// The name to use for the . - /// The description to use for the . - /// The used to marshal function parameters and return value. - /// The created for invoking . - /// - /// - /// Return values are serialized to using . - /// Arguments that are not already of the expected type are marshaled to the expected type via JSON and using - /// . If the argument is a , , - /// or , it is deserialized directly. If the argument is anything else unknown, it is - /// round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type. - /// - /// - public static AIFunction Create(MethodInfo method, object? target, string? name = null, string? description = null, JsonSerializerOptions? serializerOptions = null) - { - Verify.NotNull(method); - - AIFunctionFactoryOptions createOptions = serializerOptions is null && name is null && description is null - ? _defaultOptions - : new() - { - Name = name, - Description = description, - SerializerOptions = serializerOptions, - }; - - return ReflectionAIFunction.Build(method, target, createOptions); - } - - private sealed class ReflectionAIFunction : AIFunction - { - public static ReflectionAIFunction Build(MethodInfo method, object? target, AIFunctionFactoryOptions options) - { - Verify.NotNull(method); - - if (method.ContainsGenericParameters) - { - throw new ArgumentException("Open generic methods are not supported", nameof(method)); - } - - if (!method.IsStatic && target is null) - { - throw new ArgumentNullException(nameof(target), "Target must not be null for an instance method."); - } - - ReflectionAIFunctionDescriptor functionDescriptor = ReflectionAIFunctionDescriptor.GetOrCreate(method, options); - - if (target is null && options.AdditionalProperties is null) - { - // We can use a cached value for static methods not specifying additional properties. - return functionDescriptor.CachedDefaultInstance ??= new(functionDescriptor, target, options); - } - - return new(functionDescriptor, target, options); - } - - private ReflectionAIFunction(ReflectionAIFunctionDescriptor functionDescriptor, object? target, AIFunctionFactoryOptions options) - { - FunctionDescriptor = functionDescriptor; - Target = target; - AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary.Instance; - } - - public ReflectionAIFunctionDescriptor FunctionDescriptor { get; } - public object? Target { get; } - public override IReadOnlyDictionary AdditionalProperties { get; } - public override string Name => FunctionDescriptor.Name; - public override string Description => FunctionDescriptor.Description; - public override MethodInfo UnderlyingMethod => FunctionDescriptor.Method; - public override JsonElement JsonSchema => FunctionDescriptor.JsonSchema; - public override JsonSerializerOptions JsonSerializerOptions => FunctionDescriptor.JsonSerializerOptions; - protected override Task InvokeCoreAsync( - IEnumerable>? arguments, - CancellationToken cancellationToken) - { - var paramMarshallers = FunctionDescriptor.ParameterMarshallers; - object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : []; - - IReadOnlyDictionary argDict = - arguments is null || args.Length == 0 ? EmptyReadOnlyDictionary.Instance : - arguments as IReadOnlyDictionary ?? - arguments. -#if NET8_0_OR_GREATER - ToDictionary(); -#else - ToDictionary(kvp => kvp.Key, kvp => kvp.Value); -#endif - for (int i = 0; i < args.Length; i++) - { - args[i] = paramMarshallers[i](argDict, cancellationToken); - } - - return FunctionDescriptor.ReturnParameterMarshaller(ReflectionInvoke(FunctionDescriptor.Method, Target, args), cancellationToken); - } - } - - /// - /// A descriptor for a .NET method-backed AIFunction that precomputes its marshalling delegates and JSON schema. - /// - private sealed class ReflectionAIFunctionDescriptor - { - private const int InnerCacheSoftLimit = 512; - private static readonly ConditionalWeakTable> _descriptorCache = new(); - - /// A boxed . - private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken); - - /// - /// Gets or creates a descriptors using the specified method and options. - /// - public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options) - { - JsonSerializerOptions serializerOptions = options.SerializerOptions ?? AIJsonUtilities.DefaultOptions; - AIJsonSchemaCreateOptions schemaOptions = options.JsonSchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; - serializerOptions.MakeReadOnly(); - ConcurrentDictionary innerCache = _descriptorCache.GetOrCreateValue(serializerOptions); - - DescriptorKey key = new(method, options.Name, options.Description, schemaOptions); - if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor)) - { - return descriptor; - } - - descriptor = new(key, serializerOptions); - return innerCache.Count < InnerCacheSoftLimit - ? innerCache.GetOrAdd(key, descriptor) - : descriptor; - } - - private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions serializerOptions) - { - // Get marshaling delegates for parameters. - ParameterInfo[] parameters = key.Method.GetParameters(); - ParameterMarshallers = new Func, CancellationToken, object?>[parameters.Length]; - for (int i = 0; i < parameters.Length; i++) - { - ParameterMarshallers[i] = GetParameterMarshaller(serializerOptions, parameters[i]); - } - - // Get a marshaling delegate for the return value. - ReturnParameterMarshaller = GetReturnParameterMarshaller(key.Method, serializerOptions); - - Method = key.Method; - Name = key.Name ?? GetFunctionName(key.Method); - Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty; - JsonSerializerOptions = serializerOptions; - JsonSchema = AIJsonUtilities.CreateFunctionJsonSchema( - key.Method, - Name, - Description, - serializerOptions, - key.SchemaOptions); - } - - public string Name { get; } - public string Description { get; } - public MethodInfo Method { get; } - public JsonSerializerOptions JsonSerializerOptions { get; } - public JsonElement JsonSchema { get; } - public Func, CancellationToken, object?>[] ParameterMarshallers { get; } - public Func> ReturnParameterMarshaller { get; } - public ReflectionAIFunction? CachedDefaultInstance { get; set; } - - private static string GetFunctionName(MethodInfo method) - { - // Get the function name to use. - string name = SanitizeMemberName(method.Name); - - const string AsyncSuffix = "Async"; - if (IsAsyncMethod(method) && - name.EndsWith(AsyncSuffix, StringComparison.Ordinal) && - name.Length > AsyncSuffix.Length) - { - name = name.Substring(0, name.Length - AsyncSuffix.Length); - } - - return name; - - static bool IsAsyncMethod(MethodInfo method) - { - Type t = method.ReturnType; - - if (t == typeof(Task) || t == typeof(ValueTask)) - { - return true; - } - - if (t.IsGenericType) - { - t = t.GetGenericTypeDefinition(); - if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>)) - { - return true; - } - } - - return false; - } - } - - /// - /// Gets a delegate for handling the marshaling of a parameter. - /// - private static Func, CancellationToken, object?> GetParameterMarshaller( - JsonSerializerOptions serializerOptions, - ParameterInfo parameter) - { - if (string.IsNullOrWhiteSpace(parameter.Name)) - { - throw new ArgumentException("Parameter is missing a name.", nameof(parameter)); - } - - // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. - Type parameterType = parameter.ParameterType; - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType); - - // For CancellationToken parameters, we always bind to the token passed directly to InvokeAsync. - if (parameterType == typeof(CancellationToken)) - { - return static (_, cancellationToken) => - cancellationToken == default ? _boxedDefaultCancellationToken : // optimize common case of a default CT to avoid boxing - cancellationToken; - } - - // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. - return (arguments, _) => - { - // If the parameter has an argument specified in the dictionary, return that argument. - if (arguments.TryGetValue(parameter.Name, out object? value)) - { - return value switch - { - null => null, // Return as-is if null -- if the parameter is a struct this will be handled by MethodInfo.Invoke - _ when parameterType.IsInstanceOfType(value) => value, // Do nothing if value is assignable to parameter type - JsonElement element => JsonSerializer.Deserialize(element, typeInfo), - JsonDocument doc => JsonSerializer.Deserialize(doc, typeInfo), - JsonNode node => JsonSerializer.Deserialize(node, typeInfo), - _ => MarshallViaJsonRoundtrip(value), - }; - - object? MarshallViaJsonRoundtrip(object value) - { -#pragma warning disable CA1031 // Do not catch general exception types - try - { - string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); - return JsonSerializer.Deserialize(json, typeInfo); - } - catch - { - // Eat any exceptions and fall back to the original value to force a cast exception later on. - return value; - } -#pragma warning restore CA1031 - } - } - - // There was no argument for the parameter in the dictionary. - // Does it have a default value? - if (parameter.HasDefaultValue) - { - return parameter.DefaultValue; - } - - // Leave it empty. - return null; - }; - } - - /// - /// Gets a delegate for handling the result value of a method, converting it into the to return from the invocation. - /// - private static Func> GetReturnParameterMarshaller(MethodInfo method, JsonSerializerOptions serializerOptions) - { - Type returnType = method.ReturnType; - JsonTypeInfo returnTypeInfo; - - // Void - if (returnType == typeof(void)) - { - return static (_, _) => Task.FromResult(null); - } - - // Task - if (returnType == typeof(Task)) - { - return async static (result, _) => - { - await ((Task)ThrowIfNullResult(result)).ConfigureAwait(false); - return null; - }; - } - - // ValueTask - if (returnType == typeof(ValueTask)) - { - return async static (result, _) => - { - await ((ValueTask)ThrowIfNullResult(result)).ConfigureAwait(false); - return null; - }; - } - - if (returnType.IsGenericType) - { - // Task - if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) - { - MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); - returnTypeInfo = serializerOptions.GetTypeInfo(taskResultGetter.ReturnType); - return async (taskObj, cancellationToken) => - { - await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(false); - object? result = ReflectionInvoke(taskResultGetter, taskObj, null); - return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); - }; - } - - // ValueTask - if (returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask); - MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); - returnTypeInfo = serializerOptions.GetTypeInfo(asTaskResultGetter.ReturnType); - return async (taskObj, cancellationToken) => - { - var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; - await task.ConfigureAwait(false); - object? result = ReflectionInvoke(asTaskResultGetter, task, null); - return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); - }; - } - } - - // For everything else, just serialize the result as-is. - returnTypeInfo = serializerOptions.GetTypeInfo(returnType); - return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); - - static async Task SerializeResultAsync(object? result, JsonTypeInfo returnTypeInfo, CancellationToken cancellationToken) - { - if (returnTypeInfo.Kind is JsonTypeInfoKind.None) - { - // Special-case trivial contracts to avoid the more expensive general-purpose serialization path. - return JsonSerializer.SerializeToElement(result, returnTypeInfo); - } - - // Serialize asynchronously to support potential IAsyncEnumerable responses. - using PooledMemoryStream stream = new(); -#if NET9_0_OR_GREATER - await JsonSerializer.SerializeAsync(stream, result, returnTypeInfo, cancellationToken).ConfigureAwait(false); - Utf8JsonReader reader = new(stream.GetBuffer()); - return JsonElement.ParseValue(ref reader); -#else - await JsonSerializer.SerializeAsync(stream, result, returnTypeInfo, cancellationToken).ConfigureAwait(false); - stream.Position = 0; - var serializerOptions = _defaultOptions.SerializerOptions ?? AIJsonUtilities.DefaultOptions; - return await JsonSerializer.DeserializeAsync(stream, serializerOptions.GetTypeInfo(typeof(JsonElement)), cancellationToken).ConfigureAwait(false); -#endif - } - - // Throws an exception if a result is found to be null unexpectedly - static object ThrowIfNullResult(object? result) => result ?? throw new InvalidOperationException("Function returned null unexpectedly."); - } - - private static readonly MethodInfo _taskGetResult = typeof(Task<>).GetProperty(nameof(Task.Result), BindingFlags.Instance | BindingFlags.Public)!.GetMethod!; - private static readonly MethodInfo _valueTaskAsTask = typeof(ValueTask<>).GetMethod(nameof(ValueTask.AsTask), BindingFlags.Instance | BindingFlags.Public)!; - - private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedType, MethodInfo genericMethodDefinition) - { - Debug.Assert(specializedType.IsGenericType && specializedType.GetGenericTypeDefinition() == genericMethodDefinition.DeclaringType, "generic member definition doesn't match type."); -#if NET - return (MethodInfo)specializedType.GetMemberWithSameMetadataDefinitionAs(genericMethodDefinition); -#else -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; -#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); -#endif - } - - private record struct DescriptorKey(MethodInfo Method, string? Name, string? Description, AIJsonSchemaCreateOptions SchemaOptions); - } - - /// - /// Removes characters from a .NET member name that shouldn't be used in an AI function name. - /// - /// The .NET member name that should be sanitized. - /// - /// Replaces non-alphanumeric characters in the identifier with the underscore character. - /// Primarily intended to remove characters produced by compiler-generated method name mangling. - /// - internal static string SanitizeMemberName(string memberName) - { - Verify.NotNull(memberName); - return InvalidNameCharsRegex().Replace(memberName, "_"); - } - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif - - /// Invokes the MethodInfo with the specified target object and arguments. - private static object? ReflectionInvoke(MethodInfo method, object? target, object?[]? arguments) - { -#if NET - return method.Invoke(target, BindingFlags.DoNotWrapExceptions, binder: null, arguments, culture: null); -#else - try - { - return method.Invoke(target, BindingFlags.Default, binder: null, arguments, culture: null); - } - catch (TargetInvocationException e) when (e.InnerException is not null) - { - // If we're targeting .NET Framework, such that BindingFlags.DoNotWrapExceptions - // is ignored, the original exception will be wrapped in a TargetInvocationException. - // Unwrap it and throw that original exception, maintaining its stack information. - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); - throw; - } -#endif - } - - /// - /// Implements a simple write-only memory stream that uses pooled buffers. - /// - private sealed class PooledMemoryStream : Stream - { - private const int DefaultBufferSize = 4096; - private byte[] _buffer; - private int _position; - - public PooledMemoryStream(int initialCapacity = DefaultBufferSize) - { - _buffer = ArrayPool.Shared.Rent(initialCapacity); - _position = 0; - } - - public ReadOnlySpan GetBuffer() => _buffer.AsSpan(0, _position); - public override bool CanWrite => true; - public override bool CanRead => false; - public override bool CanSeek => false; - public override long Length => _position; - public override long Position - { - get => _position; - set => throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - EnsureNotDisposed(); - EnsureCapacity(_position + count); - - Buffer.BlockCopy(buffer, offset, _buffer, _position, count); - _position += count; - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - - protected override void Dispose(bool disposing) - { - if (_buffer is not null) - { - ArrayPool.Shared.Return(_buffer); - _buffer = null!; - } - - base.Dispose(disposing); - } - - private void EnsureCapacity(int requiredCapacity) - { - if (requiredCapacity <= _buffer.Length) - { - return; - } - - int newCapacity = Math.Max(requiredCapacity, _buffer.Length * 2); - byte[] newBuffer = ArrayPool.Shared.Rent(newCapacity); - Buffer.BlockCopy(_buffer, 0, newBuffer, 0, _position); - - ArrayPool.Shared.Return(_buffer); - _buffer = newBuffer; - } - - private void EnsureNotDisposed() - { - if (_buffer is null) - { - Throw(); - static void Throw() => throw new ObjectDisposedException(nameof(PooledMemoryStream)); - } - } - } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs index 8a5abc42a6e0..58ea317804f9 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientAIService.cs @@ -33,7 +33,7 @@ internal ChatClientAIService(IChatClient chatClient) var metadata = this._chatClient.GetService(); Verify.NotNull(metadata); - this._internalAttributes[nameof(metadata.ModelId)] = metadata.ModelId; + this._internalAttributes[AIServiceExtensions.ModelIdKey] = metadata.DefaultModelId; this._internalAttributes[nameof(metadata.ProviderName)] = metadata.ProviderName; this._internalAttributes[nameof(metadata.ProviderUri)] = metadata.ProviderUri; } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs index c36ca453bd04..e035a436a83a 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatClientExtensions.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; namespace Microsoft.SemanticKernel.ChatCompletion; @@ -27,7 +29,7 @@ internal static Task GetResponseAsync( Kernel? kernel = null, CancellationToken cancellationToken = default) { - var chatOptions = executionSettings?.ToChatOptions(kernel); + var chatOptions = GetChatOptionsFromSettings(executionSettings, kernel); // Try to parse the text as a chat history if (ChatPromptParser.TryParse(prompt, out var chatHistoryFromPrompt)) @@ -39,6 +41,25 @@ internal static Task GetResponseAsync( return chatClient.GetResponseAsync(prompt, chatOptions, cancellationToken); } + /// Get ChatClient streaming response for the prompt, settings and kernel. + /// Target chat client service. + /// The standardized prompt input. + /// The AI execution settings (optional). + /// The containing services, plugins, and other state for use throughout the operation. + /// The to monitor for cancellation requests. The default is . + /// Streaming list of different completion streaming string updates generated by the remote model + internal static IAsyncEnumerable GetStreamingResponseAsync( + this IChatClient chatClient, + string prompt, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var chatOptions = GetChatOptionsFromSettings(executionSettings, kernel); + + return chatClient.GetStreamingResponseAsync(prompt, chatOptions, cancellationToken); + } + /// Creates an for the specified . /// The chat client to be represented as a chat completion service. /// An optional that can be used to resolve services to use in the instance. @@ -64,21 +85,31 @@ public static IChatCompletionService AsChatCompletionService(this IChatClient cl { Verify.NotNull(client); - return client.GetService()?.ModelId; + return client.GetService()?.DefaultModelId; } /// /// Creates a new that supports for function invocation with a . /// /// Target chat client service. + /// Optional logger factory to use for logging. /// Function invoking chat client. [Experimental("SKEXP0001")] - public static IChatClient AsKernelFunctionInvokingChatClient(this IChatClient client) + public static IChatClient AsKernelFunctionInvokingChatClient(this IChatClient client, ILoggerFactory? loggerFactory = null) { Verify.NotNull(client); return client is KernelFunctionInvokingChatClient kernelFunctionInvocationClient ? kernelFunctionInvocationClient - : new KernelFunctionInvokingChatClient(client); + : new KernelFunctionInvokingChatClient(client, loggerFactory); + } + + private static ChatOptions GetChatOptionsFromSettings(PromptExecutionSettings? executionSettings, Kernel? kernel) + { + ChatOptions chatOptions = executionSettings?.ToChatOptions(kernel) ?? new ChatOptions().AddKernel(kernel); + + // Passing by reference to be used by AutoFunctionInvocationFilters + chatOptions.AdditionalProperties![ChatOptionsExtensions.PromptExecutionSettingsKey] = executionSettings; + return chatOptions; } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs index 3d190a48c5e4..1501cb71d988 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatMessageExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using Microsoft.Extensions.AI; namespace Microsoft.SemanticKernel.ChatCompletion; @@ -7,7 +8,6 @@ namespace Microsoft.SemanticKernel.ChatCompletion; internal static class ChatMessageExtensions { /// Converts a to a . - /// This conversion should not be necessary once SK eventually adopts the shared content types. internal static ChatMessageContent ToChatMessageContent(this ChatMessage message, Microsoft.Extensions.AI.ChatResponse? response = null) { ChatMessageContent result = new() @@ -46,4 +46,15 @@ internal static ChatMessageContent ToChatMessageContent(this ChatMessage message return result; } + + /// Converts a list of to a . + internal static ChatHistory ToChatHistory(this IEnumerable chatMessages) + { + ChatHistory chatHistory = []; + foreach (var message in chatMessages) + { + chatHistory.Add(message.ToChatMessageContent()); + } + return chatHistory; + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs index d8fab37e57bd..68540a1c32d8 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/ChatOptionsExtensions.cs @@ -13,6 +13,11 @@ namespace Microsoft.SemanticKernel.ChatCompletion; /// internal static class ChatOptionsExtensions { + internal const string KernelKey = "AutoInvokingKernel"; + internal const string IsStreamingKey = "AutoInvokingIsStreaming"; + internal const string ChatMessageContentKey = "AutoInvokingChatCompletionContent"; + internal const string PromptExecutionSettingsKey = "AutoInvokingPromptExecutionSettings"; + /// Converts a to a . internal static PromptExecutionSettings? ToPromptExecutionSettings(this ChatOptions? options) { @@ -118,4 +123,23 @@ internal static class ChatOptionsExtensions return settings; } + + /// + /// To enable usage of AutoFunctionInvocationFilters with ChatClient's the kernel needs to be provided in the ChatOptions + /// + /// Chat options. + /// Kernel to be used for auto function invocation. + internal static ChatOptions AddKernel(this ChatOptions options, Kernel? kernel) + { + Verify.NotNull(options); + + // Only add the kernel if it is provided + if (kernel is not null) + { + options.AdditionalProperties ??= []; + options.AdditionalProperties.TryAdd(KernelKey, kernel); + } + + return options; + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs deleted file mode 100644 index f9f43ee630ae..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/FunctionFactoryOptions.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Text.Json; -using Microsoft.Extensions.AI; - -namespace Microsoft.SemanticKernel.ChatCompletion; - -// Slight modified source from -// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactoryOptions.cs - -/// -/// Represents options that can be provided when creating an from a method. -/// -[ExcludeFromCodeCoverage] -internal sealed class AIFunctionFactoryOptions -{ - /// - /// Initializes a new instance of the class. - /// - public AIFunctionFactoryOptions() - { - } - - /// Gets or sets the used to marshal .NET values being passed to the underlying delegate. - /// - /// If no value has been specified, the instance will be used. - /// - public JsonSerializerOptions? SerializerOptions { get; set; } - - /// - /// Gets or sets the governing the generation of JSON schemas for the function. - /// - /// - /// If no value has been specified, the instance will be used. - /// - public AIJsonSchemaCreateOptions? JsonSchemaCreateOptions { get; set; } - - /// Gets or sets the name to use for the function. - /// - /// The name to use for the function. The default value is a name derived from the method represented by the passed or . - /// - public string? Name { get; set; } - - /// Gets or sets the description to use for the function. - /// - /// The description for the function. The default value is a description derived from the passed or , if possible - /// (for example, via a on the method). - /// - public string? Description { get; set; } - - /// - /// Gets or sets additional values to store on the resulting property. - /// - /// - /// This property can be used to provide arbitrary information about the function. - /// - public IReadOnlyDictionary? AdditionalProperties { get; set; } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs deleted file mode 100644 index da5af46620fd..000000000000 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvocationContext.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.AI; - -#pragma warning disable IDE0009 // Use explicit 'this.' qualifier -#pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable IDE0044 // Add readonly modifier - -namespace Microsoft.SemanticKernel.ChatCompletion; - -// Slight modified source from -// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs - -/// Provides context for an in-flight function invocation. -[ExcludeFromCodeCoverage] -internal sealed class KernelFunctionInvocationContext -{ - /// - /// A nop function used to allow to be non-nullable. Default instances of - /// start with this as the target function. - /// - private static readonly AIFunction s_nopFunction = AIFunctionFactory.Create(() => { }, nameof(KernelFunctionInvocationContext)); - - /// The chat contents associated with the operation that initiated this function call request. - private IList _messages = Array.Empty(); - - /// The AI function to be invoked. - private AIFunction _function = s_nopFunction; - - /// The function call content information associated with this invocation. - private Microsoft.Extensions.AI.FunctionCallContent _callContent = new(string.Empty, s_nopFunction.Name, EmptyReadOnlyDictionary.Instance); - - /// Initializes a new instance of the class. - internal KernelFunctionInvocationContext() - { - } - - /// Gets or sets the function call content information associated with this invocation. - public Microsoft.Extensions.AI.FunctionCallContent CallContent - { - get => _callContent; - set - { - Verify.NotNull(value); - _callContent = value; - } - } - - /// Gets or sets the chat contents associated with the operation that initiated this function call request. - public IList Messages - { - get => _messages; - set - { - Verify.NotNull(value); - _messages = value; - } - } - - /// Gets or sets the chat options associated with the operation that initiated this function call request. - public ChatOptions? Options { get; set; } - - /// Gets or sets the AI function to be invoked. - public AIFunction Function - { - get => _function; - set - { - Verify.NotNull(value); - _function = value; - } - } - - /// Gets or sets the number of this iteration with the underlying client. - /// - /// The initial request to the client that passes along the chat contents provided to the - /// is iteration 1. If the client responds with a function call request, the next request to the client is iteration 2, and so on. - /// - public int Iteration { get; set; } - - /// Gets or sets the index of the function call within the iteration. - /// - /// The response from the underlying client may include multiple function call requests. - /// This index indicates the position of the function call within the iteration. - /// - public int FunctionCallIndex { get; set; } - - /// Gets or sets the total number of function call requests within the iteration. - /// - /// The response from the underlying client might include multiple function call requests. - /// This count indicates how many there were. - /// - public int FunctionCount { get; set; } - - /// Gets or sets a value indicating whether to terminate the request. - /// - /// In response to a function call request, the function might be invoked, its result added to the chat contents, - /// and a new request issued to the wrapped client. If this property is set to , that subsequent request - /// will not be issued and instead the loop immediately terminated rather than continuing until there are no - /// more function call requests in responses. - /// - public bool Terminate { get; set; } -} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs index 1a59b8f5ccbd..ea2dce48fc62 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatClient/KernelFunctionInvokingChatClient.cs @@ -1,26 +1,31 @@ // Copyright (c) Microsoft. All rights reserved. using System; +#pragma warning restore IDE0073 // The file header does not match the required text using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable IDE0009 // This #pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable IDE0009 // Use explicit 'this.' qualifier -#pragma warning disable IDE1006 // Missing prefix: 's_' +#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test +#pragma warning disable SA1202 // 'protected' members should come before 'private' members -namespace Microsoft.SemanticKernel.ChatCompletion; +// Modified source from 2025-04-07 +// https://raw.githubusercontent.com/dotnet/extensions/84d09b794d994435568adcbb85a981143d4f15cb/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs -// Slight modified source from -// https://raw.githubusercontent.com/dotnet/extensions/refs/heads/main/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +namespace Microsoft.Extensions.AI; /// /// A delegating chat client that invokes functions defined on . @@ -28,9 +33,11 @@ namespace Microsoft.SemanticKernel.ChatCompletion; /// /// /// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a . +/// When this client receives a in a chat response, it responds +/// by calling the corresponding defined in , +/// producing a that it sends back to the inner client. This loop +/// is repeated until there are no more function calls to make, or until another stop condition is met, +/// such as hitting . /// /// /// The provided implementation of is thread-safe for concurrent use so long as the @@ -44,11 +51,13 @@ namespace Microsoft.SemanticKernel.ChatCompletion; /// invocation requests to that same function. /// /// -[ExcludeFromCodeCoverage] -internal sealed partial class KernelFunctionInvokingChatClient : DelegatingChatClient +public partial class KernelFunctionInvokingChatClient : DelegatingChatClient { - /// The for the current function invocation. - private static readonly AsyncLocal _currentContext = new(); + /// The for the current function invocation. + private static readonly AsyncLocal _currentContext = new(); + + /// Optional services used for function invocation. + private readonly IServiceProvider? _functionInvocationServices; /// The logger to use for logging information about function invocation. private readonly ILogger _logger; @@ -58,49 +67,37 @@ internal sealed partial class KernelFunctionInvokingChatClient : DelegatingChatC private readonly ActivitySource? _activitySource; /// Maximum number of roundtrips allowed to the inner client. - private int? _maximumIterationsPerRequest; + private int _maximumIterationsPerRequest = 10; + + /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. + private int _maximumConsecutiveErrorsPerRequest = 3; /// /// Initializes a new instance of the class. /// /// The underlying , or the next instance in a chain of clients. - /// An to use for logging information about function invocation. - public KernelFunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = null) + /// An to use for logging information about function invocation. + /// An optional to use for resolving services required by the instances being invoked. + public KernelFunctionInvokingChatClient(IChatClient innerClient, ILoggerFactory? loggerFactory = null, IServiceProvider? functionInvocationServices = null) : base(innerClient) { - _logger = logger ?? NullLogger.Instance; + _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; _activitySource = innerClient.GetService(); + _functionInvocationServices = functionInvocationServices; } /// - /// Gets or sets the for the current function invocation. + /// Gets or sets the for the current function invocation. /// /// /// This value flows across async calls. /// - internal static KernelFunctionInvocationContext? CurrentContext + public static AutoFunctionInvocationContext? CurrentContext { get => _currentContext.Value; - set => _currentContext.Value = value; + protected set => _currentContext.Value = value; } - /// - /// Gets or sets a value indicating whether to handle exceptions that occur during function calls. - /// - /// - /// if the - /// underlying will be instructed to give a response without invoking - /// any further functions if a function call fails with an exception. - /// if the underlying is allowed - /// to continue attempting function calls until is reached. - /// The default value is . - /// - /// - /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to whether errors are retried during an in-flight request. - /// - public bool RetryOnError { get; set; } - /// /// Gets or sets a value indicating whether detailed exception information should be included /// in the chat history when calling the underlying . @@ -116,17 +113,17 @@ internal static KernelFunctionInvocationContext? CurrentContext /// Setting the value to prevents the underlying language model from disclosing /// raw exception details to the end user, since it doesn't receive that information. Even in this /// case, the raw object is available to application code by inspecting - /// the property. + /// the property. /// /// /// Setting the value to can help the underlying bypass problems on - /// its own, for example by retrying the function call with different arguments. However it might + /// its own, for example by retrying the function call with different arguments. However, it might /// result in disclosing the raw exception information to external users, which can be a security /// concern depending on the application scenario. /// /// /// Changing the value of this property while the client is in use might result in inconsistencies - /// as to whether detailed errors are provided during an in-flight request. + /// whether detailed errors are provided during an in-flight request. /// /// public bool IncludeDetailedErrors { get; set; } @@ -151,23 +148,22 @@ internal static KernelFunctionInvocationContext? CurrentContext /// /// /// The maximum number of iterations per request. - /// The default value is . + /// The default value is 10. /// /// /// - /// Each request to this might end up making + /// Each request to this might end up making /// multiple requests to the inner client. Each time the inner client responds with /// a function call request, this client might perform that invocation and send the results /// back to the inner client in a new request. This property limits the number of times - /// such a roundtrip is performed. If null, there is no limit applied. If set, the value - /// must be at least one, as it includes the initial request. + /// such a roundtrip is performed. The value must be at least one, as it includes the initial request. /// /// /// Changing the value of this property while the client is in use might result in inconsistencies /// as to how many iterations are allowed for an in-flight request. /// /// - public int? MaximumIterationsPerRequest + public int MaximumIterationsPerRequest { get => _maximumIterationsPerRequest; set @@ -181,6 +177,47 @@ public int? MaximumIterationsPerRequest } } + /// + /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. + /// + /// + /// The maximum number of consecutive iterations that are allowed to fail with an error. + /// The default value is 3. + /// + /// + /// + /// When function invocations fail with an exception, the + /// continues to make requests to the inner client, optionally supplying exception information (as + /// controlled by ). This allows the to + /// recover from errors by trying other function parameters that may succeed. + /// + /// + /// However, in case function invocations continue to produce exceptions, this property can be used to + /// limit the number of consecutive failing attempts. When the limit is reached, the exception will be + /// rethrown to the caller. + /// + /// + /// If the value is set to zero, all function calling exceptions immediately terminate the function + /// invocation loop and the exception will be rethrown to the caller. + /// + /// + /// Changing the value of this property while the client is in use might result in inconsistencies + /// as to how many iterations are allowed for an in-flight request. + /// + /// + public int MaximumConsecutiveErrorsPerRequest + { + get => _maximumConsecutiveErrorsPerRequest; + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Argument less than minimum value 0"); + } + _maximumConsecutiveErrorsPerRequest = value; + } + } + /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -200,8 +237,9 @@ public override async Task GetResponseAsync( ChatResponse? response = null; // the response from the inner client, which is possibly modified and then eventually returned List? responseMessages = null; // tracked list of messages, across multiple turns, to be used for the final response UsageDetails? totalUsage = null; // tracked usage across all turns, to be used for the final response - List? functionCallContents = null; // function call contents that need responding to in the current turn + List? functionCallContents = null; // function call contents that need responding to in the current turn bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set + int consecutiveErrorCount = 0; for (int iteration = 0; ; iteration++) { @@ -217,7 +255,7 @@ public override async Task GetResponseAsync( // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. bool requiresFunctionInvocation = options?.Tools is { Count: > 0 } && - (!MaximumIterationsPerRequest.HasValue || iteration < MaximumIterationsPerRequest.GetValueOrDefault()) && + iteration < MaximumIterationsPerRequest && CopyFunctionCalls(response.Messages, ref functionCallContents); // In a common case where we make a request and there's no function calling work required, @@ -227,7 +265,7 @@ public override async Task GetResponseAsync( return response; } - // Track aggregatable details from the response, including all of the response messages and usage details. + // Track aggregate details from the response, including all the response messages and usage details. (responseMessages ??= []).AddRange(response.Messages); if (response.Usage is not null) { @@ -252,16 +290,24 @@ public override async Task GetResponseAsync( // Prepare the history for the next iteration. FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); + // Prepare the options for the next auto function invocation iteration. + UpdateOptionsForAutoFunctionInvocation(ref options!, response.Messages.Last().ToChatMessageContent(), isStreaming: false); + // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, cancellationToken).ConfigureAwait(false); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken).ConfigureAwait(false); responseMessages.AddRange(modeAndMessages.MessagesAdded); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - if (UpdateOptionsForMode(modeAndMessages.Mode, ref options!, response.ChatThreadId)) + if (modeAndMessages.ShouldTerminate) { - // Terminate break; } + + // Clear the auto function invocation options. + ClearOptionsForAutoFunctionInvocation(ref options); + + UpdateOptionsForNextIteration(ref options!, response.ChatThreadId); } Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); @@ -279,7 +325,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity(nameof(KernelFunctionInvokingChatClient)); + using Activity? activity = _activitySource?.StartActivity(nameof(FunctionInvokingChatClient)); // Copy the original messages in order to avoid enumerating the original messages multiple times. // The IEnumerable can represent an arbitrary amount of work. @@ -287,10 +333,11 @@ public override async IAsyncEnumerable GetStreamingResponseA messages = originalMessages; List? augmentedHistory = null; // the actual history of messages sent on turns other than the first - List? functionCallContents = null; // function call contents that need responding to in the current turn + List? functionCallContents = null; // function call contents that need responding to in the current turn List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history bool lastIterationHadThreadId = false; // whether the last iteration's response had a ChatThreadId set List updates = []; // updates from the current response + int consecutiveErrorCount = 0; for (int iteration = 0; ; iteration++) { @@ -315,7 +362,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // If there are no tools to call, or for any other reason we should stop, return the response. if (functionCallContents is not { Count: > 0 } || options?.Tools is not { Count: > 0 } || - (MaximumIterationsPerRequest is { } maxIterations && iteration >= maxIterations)) + iteration >= _maximumIterationsPerRequest) { break; } @@ -327,13 +374,26 @@ public override async IAsyncEnumerable GetStreamingResponseA // Prepare the history for the next iteration. FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadThreadId); - // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, cancellationToken).ConfigureAwait(false); + // Prepare the options for the next auto function invocation iteration. + UpdateOptionsForAutoFunctionInvocation(ref options, response.Messages.Last().ToChatMessageContent(), isStreaming: true); + + // Process all the functions, adding their results into the history. + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken).ConfigureAwait(false); responseMessages.AddRange(modeAndMessages.MessagesAdded); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages - // includes all activities, including generated function results. + // Clear the auto function invocation options. + ClearOptionsForAutoFunctionInvocation(ref options); + + // This is a synthetic ID since we're generating the tool messages instead of getting them from + // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to + // use the same message ID for all of them within a given iteration, as this is a single logical + // message with multiple content items. We could also use different message IDs per tool content, + // but there's no benefit to doing so. string toolResponseId = Guid.NewGuid().ToString("N"); + + // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages + // include all activity, including generated function results. foreach (var message in modeAndMessages.MessagesAdded) { var toolResultUpdate = new ChatResponseUpdate @@ -345,6 +405,7 @@ public override async IAsyncEnumerable GetStreamingResponseA Contents = message.Contents, RawRepresentation = message.RawRepresentation, ResponseId = toolResponseId, + MessageId = toolResponseId, // See above for why this can be the same as ResponseId Role = message.Role, }; @@ -352,11 +413,12 @@ public override async IAsyncEnumerable GetStreamingResponseA Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } - if (UpdateOptionsForMode(modeAndMessages.Mode, ref options, response.ChatThreadId)) + if (modeAndMessages.ShouldTerminate) { - // Terminate yield break; } + + UpdateOptionsForNextIteration(ref options, response.ChatThreadId); } } @@ -396,7 +458,7 @@ private static void FixupHistories( { // In the very rare case where the inner client returned a response with a thread ID but then // returned a subsequent response without one, we want to reconstitute the full history. To do that, - // we can populate the history with the original chat messages and then all of the response + // we can populate the history with the original chat messages and then all the response // messages up until this point, which includes the most recent ones. augmentedHistory ??= []; augmentedHistory.Clear(); @@ -424,7 +486,7 @@ private static void FixupHistories( /// Copies any from to . private static bool CopyFunctionCalls( - IList messages, [NotNullWhen(true)] ref List? functionCalls) + IList messages, [NotNullWhen(true)] ref List? functionCalls) { bool any = false; int count = messages.Count; @@ -436,15 +498,15 @@ private static bool CopyFunctionCalls( return any; } - /// Copies any from to . + /// Copies any from to . private static bool CopyFunctionCalls( - IList content, [NotNullWhen(true)] ref List? functionCalls) + IList content, [NotNullWhen(true)] ref List? functionCalls) { bool any = false; int count = content.Count; for (int i = 0; i < count; i++) { - if (content[i] is Microsoft.Extensions.AI.FunctionCallContent functionCall) + if (content[i] is FunctionCallContent functionCall) { (functionCalls ??= []).Add(functionCall); any = true; @@ -454,47 +516,54 @@ private static bool CopyFunctionCalls( return any; } - /// Updates for the response. - /// true if the function calling loop should terminate; otherwise, false. - private static bool UpdateOptionsForMode(ContinueMode mode, ref ChatOptions options, string? chatThreadId) + private static void UpdateOptionsForAutoFunctionInvocation(ref ChatOptions options, ChatMessageContent content, bool isStreaming) { - switch (mode) + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) { - case ContinueMode.Continue when options.ToolMode is RequiredChatToolMode: - // We have to reset the tool mode to be non-required after the first iteration, - // as otherwise we'll be in an infinite loop. - options = options.Clone(); - options.ToolMode = null; - options.ChatThreadId = chatThreadId; - - break; + throw new KernelException($"The reserved key name '{ChatOptionsExtensions.IsStreamingKey}' is already specified in the options. Avoid using this key name."); + } - case ContinueMode.AllowOneMoreRoundtrip: - // The LLM gets one further chance to answer, but cannot use tools. - options = options.Clone(); - options.Tools = null; - options.ToolMode = null; - options.ChatThreadId = chatThreadId; + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) + { + throw new KernelException($"The reserved key name '{ChatOptionsExtensions.ChatMessageContentKey}' is already specified in the options. Avoid using this key name."); + } - break; + options.AdditionalProperties ??= []; - case ContinueMode.Terminate: - // Bail immediately. - return true; + options.AdditionalProperties[ChatOptionsExtensions.IsStreamingKey] = isStreaming; + options.AdditionalProperties[ChatOptionsExtensions.ChatMessageContentKey] = content; + } - default: - // As with the other modes, ensure we've propagated the chat thread ID to the options. - // We only need to clone the options if we're actually mutating it. - if (options.ChatThreadId != chatThreadId) - { - options = options.Clone(); - options.ChatThreadId = chatThreadId; - } + private static void ClearOptionsForAutoFunctionInvocation(ref ChatOptions options) + { + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.IsStreamingKey) ?? false) + { + options.AdditionalProperties.Remove(ChatOptionsExtensions.IsStreamingKey); + } - break; + if (options.AdditionalProperties?.ContainsKey(ChatOptionsExtensions.ChatMessageContentKey) ?? false) + { + options.AdditionalProperties.Remove(ChatOptionsExtensions.ChatMessageContentKey); } + } - return false; + private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? chatThreadId) + { + if (options.ToolMode is RequiredChatToolMode) + { + // We have to reset the tool mode to be non-required after the first iteration, + // as otherwise we'll be in an infinite loop. + options = options.Clone(); + options.ToolMode = null; + options.ChatThreadId = chatThreadId; + } + else if (options.ChatThreadId != chatThreadId) + { + // As with the other modes, ensure we've propagated the chat thread ID to the options. + // We only need to clone the options if we're actually mutating it. + options = options.Clone(); + options.ChatThreadId = chatThreadId; + } } /// @@ -504,71 +573,124 @@ private static bool UpdateOptionsForMode(ContinueMode mode, ref ChatOptions opti /// The options used for the response being processed. /// The function call contents representing the functions to be invoked. /// The iteration number of how many roundtrips have been made to the inner client. + /// The number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. + /// Whether the function calls are being processed in a streaming context. /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. - private async Task<(ContinueMode Mode, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, int iteration, CancellationToken cancellationToken) + /// A value indicating how the caller should proceed. + private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( + List messages, ChatOptions options, List functionCallContents, + int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // We must add 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. - Debug.Assert(functionCallContents.Count > 0, "Expecteded at least one function call."); + Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); + var shouldTerminate = false; + + var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. if (functionCallContents.Count == 1) { FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, iteration, 0, cancellationToken).ConfigureAwait(false); + messages, options, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); IList added = CreateResponseMessages([result]); ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); messages.AddRange(added); - return (result.ContinueMode, added); + return (result.ShouldTerminate, consecutiveErrorCount, added); } else { - FunctionInvocationResult[] results; + List results = []; + var terminationRequested = false; if (AllowConcurrentInvocation) { - // Schedule the invocation of every function. - results = await Task.WhenAll( + // Rather than awaiting each function before invoking the next, invoke all of them + // and then await all of them. We avoid forcibly introducing parallelism via Task.Run, + // but if a function invocation completes asynchronously, its processing can overlap + // with the processing of other the other invocation invocations. + results.AddRange(await Task.WhenAll( from i in Enumerable.Range(0, functionCallContents.Count) - select Task.Run(() => ProcessFunctionCallAsync( + select ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, cancellationToken))).ConfigureAwait(false); + iteration, i, captureExceptions: true, isStreaming, cancellationToken)).ConfigureAwait(false)); + + terminationRequested = results.Any(r => r.ShouldTerminate); } else { // Invoke each function serially. - results = new FunctionInvocationResult[functionCallContents.Count]; - for (int i = 0; i < results.Length; i++) + for (int i = 0; i < functionCallContents.Count; i++) { - results[i] = await ProcessFunctionCallAsync( + var result = await ProcessFunctionCallAsync( messages, options, functionCallContents, - iteration, i, cancellationToken).ConfigureAwait(false); + iteration, i, captureCurrentIterationExceptions, isStreaming, cancellationToken).ConfigureAwait(false); + + results.Add(result); + + if (result.ShouldTerminate) + { + shouldTerminate = true; + terminationRequested = true; + break; + } } } - ContinueMode continueMode = ContinueMode.Continue; - IList added = CreateResponseMessages(results); ThrowIfNoFunctionResultsAdded(added); + UpdateConsecutiveErrorCountOrThrow(added, ref consecutiveErrorCount); messages.AddRange(added); - foreach (FunctionInvocationResult fir in results) + + if (!terminationRequested) { - if (fir.ContinueMode > continueMode) + // If any function requested termination, we'll terminate. + shouldTerminate = false; + foreach (FunctionInvocationResult fir in results) { - continueMode = fir.ContinueMode; + shouldTerminate = shouldTerminate || fir.ShouldTerminate; } } - return (continueMode, added); + return (shouldTerminate, consecutiveErrorCount, added); } } + private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) + { + var allExceptions = added.SelectMany(m => m.Contents.OfType()) + .Select(frc => frc.Exception!) + .Where(e => e is not null); + +#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection + if (allExceptions.Any()) + { + consecutiveErrorCount++; + if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + { + var allExceptionsArray = allExceptions.ToArray(); + if (allExceptionsArray.Length == 1) + { + ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); + } + else + { + throw new AggregateException(allExceptionsArray); + } + } + } + else + { + consecutiveErrorCount = 0; + } +#pragma warning restore CA1851 // Possible multiple enumerations of 'IEnumerable' collection + } + /// /// Throws an exception if doesn't create any messages. /// @@ -576,7 +698,7 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) { if (messages is null || messages.Count == 0) { - throw new InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); + throw new InvalidOperationException($"{this.GetType().Name}.{nameof(this.CreateResponseMessages)} returned null or an empty collection of messages."); } } @@ -586,11 +708,13 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// The function call contents representing all the functions being invoked. /// The iteration number of how many roundtrips have been made to the inner client. /// The 0-based index of the function being called out of . + /// If true, handles function-invocation exceptions by returning a value with . Otherwise, rethrows. + /// Whether the function calls are being processed in a streaming context. /// The to monitor for cancellation requests. - /// A value indicating how the caller should proceed. + /// A value indicating how the caller should proceed. private async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, - int iteration, int functionCallIndex, CancellationToken cancellationToken) + List messages, ChatOptions options, List callContents, + int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; @@ -598,19 +722,28 @@ private async Task ProcessFunctionCallAsync( AIFunction? function = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); if (function is null) { - return new(ContinueMode.Continue, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); + return new(shouldTerminate: false, FunctionInvokingChatClient.FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } - KernelFunctionInvocationContext context = new() + if (callContent.Arguments is not null) { + callContent.Arguments = new KernelArguments(callContent.Arguments); + } + + var context = new AutoFunctionInvocationContext(new() + { + Function = function, + Arguments = new(callContent.Arguments) { Services = _functionInvocationServices }, + Messages = messages, Options = options, + CallContent = callContent, - Function = function, Iteration = iteration, FunctionCallIndex = functionCallIndex, FunctionCount = callContents.Count, - }; + }) + { IsStreaming = isStreaming }; object? result; try @@ -619,56 +752,46 @@ private async Task ProcessFunctionCallAsync( } catch (Exception e) when (!cancellationToken.IsCancellationRequested) { + if (!captureExceptions) + { + throw; + } + return new( - RetryOnError ? ContinueMode.Continue : ContinueMode.AllowOneMoreRoundtrip, // We won't allow further function calls, hence the LLM will just get one more chance to give a final answer. - FunctionInvocationStatus.Exception, + shouldTerminate: false, + FunctionInvokingChatClient.FunctionInvocationStatus.Exception, callContent, result: null, exception: e); } return new( - context.Terminate ? ContinueMode.Terminate : ContinueMode.Continue, - FunctionInvocationStatus.RanToCompletion, + shouldTerminate: context.Terminate, + FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion, callContent, result, exception: null); } - /// Represents the return value of , dictating how the loop should behave. - /// These values are ordered from least severe to most severe, and code explicitly depends on the ordering. - internal enum ContinueMode - { - /// Send back the responses and continue processing. - Continue = 0, - - /// Send back the response but without any tools. - AllowOneMoreRoundtrip = 1, - - /// Immediately exit the function calling loop. - Terminate = 2, - } - /// Creates one or more response messages for function invocation results. /// Information about the function call invocations and results. /// A list of all chat messages created from . - internal IList CreateResponseMessages( - ReadOnlySpan results) + private IList CreateResponseMessages(List results) { - var contents = new List(results.Length); - for (int i = 0; i < results.Length; i++) + var contents = new List(results.Count); + for (int i = 0; i < results.Count; i++) { contents.Add(CreateFunctionResultContent(results[i])); } return [new(ChatRole.Tool, contents)]; - Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) + FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult result) { Verify.NotNull(result); object? functionResult; - if (result.Status == FunctionInvocationStatus.RanToCompletion) + if (result.Status == FunctionInvokingChatClient.FunctionInvocationStatus.RanToCompletion) { functionResult = result.Result ?? "Success: Function completed."; } @@ -676,8 +799,8 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi { string message = result.Status switch { - FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", - FunctionInvocationStatus.Exception => "Error: Function failed.", + FunctionInvokingChatClient.FunctionInvocationStatus.NotFound => $"Error: Requested function \"{result.CallContent.Name}\" not found.", + FunctionInvokingChatClient.FunctionInvocationStatus.Exception => "Error: Function failed.", _ => "Error: Unknown error.", }; @@ -689,7 +812,49 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi functionResult = message; } - return new Microsoft.Extensions.AI.FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; + return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception }; + } + } + + /// + /// Invokes the auto function invocation filters. + /// + /// The auto function invocation context. + /// The function to call after the filters. + /// The auto function invocation context. + private async Task OnAutoFunctionInvocationAsync( + AutoFunctionInvocationContext context, + Func functionCallCallback) + { + await this.InvokeFilterOrFunctionAsync(functionCallCallback, context).ConfigureAwait(false); + + return context; + } + + /// + /// This method will execute auto function invocation filters and function recursively. + /// If there are no registered filters, just function will be executed. + /// If there are registered filters, filter on position will be executed. + /// Second parameter of filter is callback. It can be either filter on + 1 position or function if there are no remaining filters to execute. + /// Function will always be executed as last step after all filters. + /// + private async Task InvokeFilterOrFunctionAsync( + Func functionCallCallback, + AutoFunctionInvocationContext context, + int index = 0) + { + IList autoFunctionInvocationFilters = context.Kernel.AutoFunctionInvocationFilters; + + if (autoFunctionInvocationFilters is { Count: > 0 } && index < autoFunctionInvocationFilters.Count) + { + await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync( + context, + (ctx) => this.InvokeFilterOrFunctionAsync(functionCallCallback, ctx, index + 1) + ).ConfigureAwait(false); + } + else + { + await functionCallCallback(context).ConfigureAwait(false); } } @@ -700,7 +865,7 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi /// The to monitor for cancellation requests. The default is . /// The result of the function invocation, or if the function invocation returned . /// is . - internal async Task InvokeFunctionAsync(KernelFunctionInvocationContext context, CancellationToken cancellationToken) + private async Task InvokeFunctionAsync(AutoFunctionInvocationContext context, CancellationToken cancellationToken) { Verify.NotNull(context); @@ -712,7 +877,7 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi startingTimestamp = Stopwatch.GetTimestamp(); if (_logger.IsEnabled(LogLevel.Trace)) { - LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.Function.JsonSerializerOptions)); + LogInvokingSensitive(context.Function.Name, LoggingAsJson(context.CallContent.Arguments, context.AIFunction.JsonSerializerOptions)); } else { @@ -723,8 +888,24 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi object? result = null; try { - CurrentContext = context; - result = await context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken).ConfigureAwait(false); + CurrentContext = context; // doesn't need to be explicitly reset after, as that's handled automatically at async method exit + context = await this.OnAutoFunctionInvocationAsync( + context, + async (ctx) => + { + // Check if filter requested termination + if (ctx.Terminate) + { + return; + } + + // 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. + result = await context.AIFunction.InvokeAsync(new(context.Arguments), cancellationToken).ConfigureAwait(false); + ctx.Result = new FunctionResult(ctx.Function, result); + }).ConfigureAwait(false); + result = context.Result.GetValue(); } catch (Exception e) { @@ -753,7 +934,7 @@ Microsoft.Extensions.AI.FunctionResultContent CreateFunctionResultContent(Functi if (result is not null && _logger.IsEnabled(LogLevel.Trace)) { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.Function.JsonSerializerOptions)); + LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingAsJson(result, context.AIFunction.JsonSerializerOptions)); } else { @@ -815,9 +996,9 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => /// Provides information about the invocation of a function call. public sealed class FunctionInvocationResult { - internal FunctionInvocationResult(ContinueMode continueMode, FunctionInvocationStatus status, Microsoft.Extensions.AI.FunctionCallContent callContent, object? result, Exception? exception) + internal FunctionInvocationResult(bool shouldTerminate, FunctionInvokingChatClient.FunctionInvocationStatus status, FunctionCallContent callContent, object? result, Exception? exception) { - ContinueMode = continueMode; + ShouldTerminate = shouldTerminate; Status = status; CallContent = callContent; Result = result; @@ -825,10 +1006,10 @@ internal FunctionInvocationResult(ContinueMode continueMode, FunctionInvocationS } /// Gets status about how the function invocation completed. - public FunctionInvocationStatus Status { get; } + public FunctionInvokingChatClient.FunctionInvocationStatus Status { get; } /// Gets the function call content information associated with this invocation. - public Microsoft.Extensions.AI.FunctionCallContent CallContent { get; } + public FunctionCallContent CallContent { get; } /// Gets the result of the function call. public object? Result { get; } @@ -836,20 +1017,7 @@ internal FunctionInvocationResult(ContinueMode continueMode, FunctionInvocationS /// Gets any exception the function call threw. public Exception? Exception { get; } - /// Gets an indication for how the caller should continue the processing loop. - internal ContinueMode ContinueMode { get; } - } - - /// Provides error codes for when errors occur as part of the function calling loop. - public enum FunctionInvocationStatus - { - /// The operation completed successfully. - RanToCompletion, - - /// The requested function could not be found. - NotFound, - - /// The function call failed with an exception. - Exception, + /// Gets a value indicating whether the caller should terminate the processing loop. + internal bool ShouldTerminate { get; } } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs index 22968c47ea38..147cdd5ba332 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs @@ -18,6 +18,14 @@ public class ChatHistory : IList, IReadOnlyListThe messages. private readonly List _messages; + private Action? _overrideAdd; + private Func? _overrideRemove; + private Action? _overrideClear; + private Action? _overrideInsert; + private Action? _overrideRemoveAt; + private Action? _overrideRemoveRange; + private Action>? _overrideAddRange; + /// Initializes an empty history. /// /// Creates a new instance of the class @@ -27,6 +35,38 @@ public ChatHistory() this._messages = []; } + // This allows observation of the chat history changes by-reference reflecting in an + // internal IEnumerable when used from IChatClients + // with AutoFunctionInvocationFilters + internal void SetOverrides( + Action overrideAdd, + Func overrideRemove, + Action onClear, + Action overrideInsert, + Action overrideRemoveAt, + Action overrideRemoveRange, + Action> overrideAddRange) + { + this._overrideAdd = overrideAdd; + this._overrideRemove = overrideRemove; + this._overrideClear = onClear; + this._overrideInsert = overrideInsert; + this._overrideRemoveAt = overrideRemoveAt; + this._overrideRemoveRange = overrideRemoveRange; + this._overrideAddRange = overrideAddRange; + } + + internal void ClearOverrides() + { + this._overrideAdd = null; + this._overrideRemove = null; + this._overrideClear = null; + this._overrideInsert = null; + this._overrideRemoveAt = null; + this._overrideRemoveRange = null; + this._overrideAddRange = null; + } + /// /// Creates a new instance of the with a first message in the provided . /// If not role is provided then the first message will default to role. @@ -37,8 +77,7 @@ public ChatHistory(string message, AuthorRole role) { Verify.NotNullOrWhiteSpace(message); - this._messages = []; - this.Add(new ChatMessageContent(role, message)); + this._messages = [new ChatMessageContent(role, message)]; } /// @@ -60,7 +99,7 @@ public ChatHistory(IEnumerable messages) } /// Gets the number of messages in the history. - public int Count => this._messages.Count; + public virtual int Count => this._messages.Count; /// /// Role of the message author @@ -118,29 +157,32 @@ public void AddDeveloperMessage(string content) => /// Adds a message to the history. /// The message to add. /// is null. - public void Add(ChatMessageContent item) + public virtual void Add(ChatMessageContent item) { Verify.NotNull(item); this._messages.Add(item); + this._overrideAdd?.Invoke(item); } /// Adds the messages to the history. /// The collection whose messages should be added to the history. /// is null. - public void AddRange(IEnumerable items) + public virtual void AddRange(IEnumerable items) { Verify.NotNull(items); this._messages.AddRange(items); + this._overrideAddRange?.Invoke(items); } /// Inserts a message into the history at the specified index. /// The index at which the item should be inserted. /// The message to insert. /// is null. - public void Insert(int index, ChatMessageContent item) + public virtual void Insert(int index, ChatMessageContent item) { Verify.NotNull(item); this._messages.Insert(index, item); + this._overrideInsert?.Invoke(index, item); } /// @@ -151,17 +193,22 @@ public void Insert(int index, ChatMessageContent item) /// is null. /// The number of messages in the history is greater than the available space from to the end of . /// is less than 0. - public void CopyTo(ChatMessageContent[] array, int arrayIndex) => this._messages.CopyTo(array, arrayIndex); + public virtual void CopyTo(ChatMessageContent[] array, int arrayIndex) + => this._messages.CopyTo(array, arrayIndex); /// Removes all messages from the history. - public void Clear() => this._messages.Clear(); + public virtual void Clear() + { + this._messages.Clear(); + this._overrideClear?.Invoke(); + } /// Gets or sets the message at the specified index in the history. /// The index of the message to get or set. /// The message at the specified index. /// is null. /// The was not valid for this history. - public ChatMessageContent this[int index] + public virtual ChatMessageContent this[int index] { get => this._messages[index]; set @@ -175,7 +222,7 @@ public ChatMessageContent this[int index] /// The message to locate. /// true if the message is found in the history; otherwise, false. /// is null. - public bool Contains(ChatMessageContent item) + public virtual bool Contains(ChatMessageContent item) { Verify.NotNull(item); return this._messages.Contains(item); @@ -185,7 +232,7 @@ public bool Contains(ChatMessageContent item) /// The message to locate. /// The index of the first found occurrence of the specified message; -1 if the message could not be found. /// is null. - public int IndexOf(ChatMessageContent item) + public virtual int IndexOf(ChatMessageContent item) { Verify.NotNull(item); return this._messages.IndexOf(item); @@ -194,16 +241,22 @@ public int IndexOf(ChatMessageContent item) /// Removes the message at the specified index from the history. /// The index of the message to remove. /// The was not valid for this history. - public void RemoveAt(int index) => this._messages.RemoveAt(index); + public virtual void RemoveAt(int index) + { + this._messages.RemoveAt(index); + this._overrideRemoveAt?.Invoke(index); + } /// Removes the first occurrence of the specified message from the history. /// The message to remove from the history. /// true if the item was successfully removed; false if it wasn't located in the history. /// is null. - public bool Remove(ChatMessageContent item) + public virtual bool Remove(ChatMessageContent item) { Verify.NotNull(item); - return this._messages.Remove(item); + var result = this._messages.Remove(item); + this._overrideRemove?.Invoke(item); + return result; } /// @@ -214,9 +267,10 @@ public bool Remove(ChatMessageContent item) /// is less than 0. /// is less than 0. /// and do not denote a valid range of messages. - public void RemoveRange(int index, int count) + public virtual void RemoveRange(int index, int count) { this._messages.RemoveRange(index, count); + this._overrideRemoveRange?.Invoke(index, count); } /// diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs index 5c7e56d0ce43..381e073a1446 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistoryExtensions.cs @@ -80,6 +80,67 @@ public static async Task ReduceAsync(this ChatHistory chatHistory, return chatHistory; } + /// Converts a to a list. + /// The chat history to convert. + /// A list of objects. internal static List ToChatMessageList(this ChatHistory chatHistory) => chatHistory.Select(m => m.ToChatMessage()).ToList(); + + internal static void SetChatMessageHandlers(this ChatHistory chatHistory, IList messages) + { + chatHistory.SetOverrides(Add, Remove, Clear, Insert, RemoveAt, RemoveRange, AddRange); + + void Add(ChatMessageContent item) + { + messages.Add(item.ToChatMessage()); + } + + void Clear() + { + messages.Clear(); + } + + bool Remove(ChatMessageContent item) + { + var index = chatHistory.IndexOf(item); + + if (index < 0) + { + return false; + } + + messages.RemoveAt(index); + + return true; + } + + void Insert(int index, ChatMessageContent item) + { + messages.Insert(index, item.ToChatMessage()); + } + + void RemoveAt(int index) + { + messages.RemoveAt(index); + } + + void RemoveRange(int index, int count) + { + if (messages is List messageList) + { + messageList.RemoveRange(index, count); + return; + } + + foreach (var chatMessage in messages.Skip(index).Take(count)) + { + messages.Remove(chatMessage); + } + } + + void AddRange(IEnumerable items) + { + messages.AddRange(items.Select(i => i.ToChatMessage())); + } + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs index 98bb09be6f85..74fe27c2e841 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettingsExtensions.cs @@ -8,13 +8,15 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel; -internal static class PromptExecutionSettingsExtensions +/// Extensions methods for . +public static class PromptExecutionSettingsExtensions { /// Converts a pair of and to a . - internal static ChatOptions? ToChatOptions(this PromptExecutionSettings? settings, Kernel? kernel) + public static ChatOptions? ToChatOptions(this PromptExecutionSettings? settings, Kernel? kernel) { if (settings is null) { @@ -149,7 +151,8 @@ internal static class PromptExecutionSettingsExtensions options.Tools = functions.Select(f => f.AsAIFunction(kernel)).Cast().ToList(); } - return options; + // Enables usage of AutoFunctionInvocationFilters + return options.AddKernel(kernel!); // Be a little lenient on the types of the values used in the extension data, // e.g. allow doubles even when requesting floats. diff --git a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs index 0f18be8df8e0..bc8dd0c3490c 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Filters/AutoFunctionInvocation/AutoFunctionInvocationContext.cs @@ -1,6 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; using System.Threading; +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel.ChatCompletion; namespace Microsoft.SemanticKernel; @@ -10,6 +15,34 @@ namespace Microsoft.SemanticKernel; /// public class AutoFunctionInvocationContext { + private ChatHistory? _chatHistory; + private KernelFunction? _kernelFunction; + private readonly Microsoft.Extensions.AI.FunctionInvocationContext _invocationContext = new(); + + /// + /// Initializes a new instance of the class from an existing . + /// + internal AutoFunctionInvocationContext(Microsoft.Extensions.AI.FunctionInvocationContext invocationContext) + { + Verify.NotNull(invocationContext); + Verify.NotNull(invocationContext.Options); + + // the ChatOptions must be provided with AdditionalProperties. + Verify.NotNull(invocationContext.Options.AdditionalProperties); + + invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.KernelKey, out var kernel); + Verify.NotNull(kernel); + + invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.ChatMessageContentKey, out var chatMessageContent); + Verify.NotNull(chatMessageContent); + + invocationContext.Options.AdditionalProperties.TryGetValue(ChatOptionsExtensions.PromptExecutionSettingsKey, out var executionSettings); + this.ExecutionSettings = executionSettings; + this._invocationContext = invocationContext; + + this.Result = new FunctionResult(this.Function) { Culture = kernel.Culture }; + } + /// /// Initializes a new instance of the class. /// @@ -31,11 +64,21 @@ public AutoFunctionInvocationContext( Verify.NotNull(chatHistory); Verify.NotNull(chatMessageContent); - this.Kernel = kernel; - this.Function = function; + this._invocationContext.Options = new() + { + AdditionalProperties = new() + { + [ChatOptionsExtensions.ChatMessageContentKey] = chatMessageContent, + [ChatOptionsExtensions.KernelKey] = kernel + } + }; + + this._kernelFunction = function; + this._chatHistory = chatHistory; + this._invocationContext.Messages = chatHistory.ToChatMessageList(); + chatHistory.SetChatMessageHandlers(this._invocationContext.Messages); + this._invocationContext.Function = function.AsAIFunction(); this.Result = result; - this.ChatHistory = chatHistory; - this.ChatMessageContent = chatMessageContent; } /// @@ -52,71 +95,263 @@ public AutoFunctionInvocationContext( /// /// Gets the arguments associated with the operation. /// - public KernelArguments? Arguments { get; init; } + public KernelArguments? Arguments + { + get => this._invocationContext.CallContent.Arguments is KernelArguments kernelArguments ? kernelArguments : null; + init => this._invocationContext.CallContent.Arguments = value; + } /// /// Request sequence index of automatic function invocation process. Starts from 0. /// - public int RequestSequenceIndex { get; init; } + public int RequestSequenceIndex + { + get => this._invocationContext.Iteration; + init => this._invocationContext.Iteration = value; + } /// /// Function sequence index. Starts from 0. /// - public int FunctionSequenceIndex { get; init; } + public int FunctionSequenceIndex + { + get => this._invocationContext.FunctionCallIndex; + init => this._invocationContext.FunctionCallIndex = value; + } - /// - /// Number of functions that will be invoked during auto function invocation request. - /// - public int FunctionCount { get; init; } + /// Gets or sets the total number of function call requests within the iteration. + /// + /// The response from the underlying client might include multiple function call requests. + /// This count indicates how many there were. + /// + public int FunctionCount + { + get => this._invocationContext.FunctionCount; + init => this._invocationContext.FunctionCount = value; + } /// /// The ID of the tool call. /// - public string? ToolCallId { get; init; } + public string? ToolCallId + { + get => this._invocationContext.CallContent.CallId; + init + { + this._invocationContext.CallContent = new Microsoft.Extensions.AI.FunctionCallContent( + callId: value ?? string.Empty, + name: this._invocationContext.CallContent.Name, + arguments: this._invocationContext.CallContent.Arguments); + } + } /// /// The chat message content associated with automatic function invocation. /// - public ChatMessageContent ChatMessageContent { get; } + public ChatMessageContent ChatMessageContent + => (this._invocationContext.Options?.AdditionalProperties?[ChatOptionsExtensions.ChatMessageContentKey] as ChatMessageContent)!; /// /// The execution settings associated with the operation. /// - public PromptExecutionSettings? ExecutionSettings { get; init; } + public PromptExecutionSettings? ExecutionSettings + { + get => this._invocationContext.Options?.AdditionalProperties?[ChatOptionsExtensions.PromptExecutionSettingsKey] as PromptExecutionSettings; + init + { + this._invocationContext.Options ??= new(); + this._invocationContext.Options.AdditionalProperties ??= []; + this._invocationContext.Options.AdditionalProperties[ChatOptionsExtensions.PromptExecutionSettingsKey] = value; + } + } /// /// Gets the associated with automatic function invocation. /// - public ChatHistory ChatHistory { get; } + public ChatHistory ChatHistory => this._chatHistory ??= new ChatMessageHistory(this._invocationContext.Messages); /// /// Gets the with which this filter is associated. /// - public KernelFunction Function { get; } + public KernelFunction Function + { + get + { + if (this._kernelFunction is null + // If the schemas are different, + // AIFunction reference potentially was modified and the kernel function should be regenerated. + || !IsSameSchema(this._kernelFunction, this._invocationContext.Function)) + { + this._kernelFunction = this._invocationContext.Function.AsKernelFunction(); + } + + return this._kernelFunction; + } + } /// /// Gets the containing services, plugins, and other state for use throughout the operation. /// - public Kernel Kernel { get; } + public Kernel Kernel + { + get + { + Kernel? kernel = null; + this._invocationContext.Options?.AdditionalProperties?.TryGetValue(ChatOptionsExtensions.KernelKey, out kernel); + + // To avoid exception from properties, when attempting to retrieve a kernel from a non-ready context, it will give a null. + return kernel!; + } + } /// /// Gets or sets the result of the function's invocation. /// public FunctionResult Result { get; set; } + /// Gets or sets a value indicating whether to terminate the request. + /// + /// In response to a function call request, the function might be invoked, its result added to the chat contents, + /// and a new request issued to the wrapped client. If this property is set to , that subsequent request + /// will not be issued and instead the loop immediately terminated rather than continuing until there are no + /// more function call requests in responses. + /// + public bool Terminate + { + get => this._invocationContext.Terminate; + set => this._invocationContext.Terminate = value; + } + + /// Gets or sets the function call content information associated with this invocation. + internal Microsoft.Extensions.AI.FunctionCallContent CallContent + { + get => this._invocationContext.CallContent; + set => this._invocationContext.CallContent = value; + } + + internal AIFunction AIFunction + { + get => this._invocationContext.Function; + set => this._invocationContext.Function = value; + } + + private static bool IsSameSchema(KernelFunction kernelFunction, AIFunction aiFunction) + { + // Compares the schemas, should be similar. + return string.Equals( + kernelFunction.AsAIFunction().JsonSchema.ToString(), + aiFunction.JsonSchema.ToString(), + StringComparison.OrdinalIgnoreCase); + + // TODO: Later can be improved by comparing the underlying methods. + // return kernelFunction.UnderlyingMethod == aiFunction.UnderlyingMethod; + } + /// - /// Gets or sets a value indicating whether the operation associated with the filter should be terminated. - /// - /// By default, this value is , which means all functions will be invoked. - /// If set to , the behavior depends on how functions are invoked: - /// - /// - If functions are invoked sequentially (the default behavior), the remaining functions will not be invoked, - /// and the last request to the LLM will not be performed. - /// - /// - If functions are invoked concurrently (controlled by the option), - /// other functions will still be invoked, and the last request to the LLM will not be performed. - /// - /// In both cases, the automatic function invocation process will be terminated, and the result of the last executed function will be returned to the caller. + /// Mutable IEnumerable of chat message as chat history. /// - public bool Terminate { get; set; } + private class ChatMessageHistory : ChatHistory, IEnumerable + { + private readonly List _messages; + + internal ChatMessageHistory(IEnumerable messages) : base(messages.ToChatHistory()) + { + this._messages = new List(messages); + } + + public override void Add(ChatMessageContent item) + { + base.Add(item); + this._messages.Add(item.ToChatMessage()); + } + + public override void Clear() + { + base.Clear(); + this._messages.Clear(); + } + + public override bool Remove(ChatMessageContent item) + { + var index = base.IndexOf(item); + + if (index < 0) + { + return false; + } + + this._messages.RemoveAt(index); + base.RemoveAt(index); + + return true; + } + + public override void Insert(int index, ChatMessageContent item) + { + base.Insert(index, item); + this._messages.Insert(index, item.ToChatMessage()); + } + + public override void RemoveAt(int index) + { + this._messages.RemoveAt(index); + base.RemoveAt(index); + } + + public override ChatMessageContent this[int index] + { + get => this._messages[index].ToChatMessageContent(); + set + { + this._messages[index] = value.ToChatMessage(); + base[index] = value; + } + } + + public override void RemoveRange(int index, int count) + { + this._messages.RemoveRange(index, count); + base.RemoveRange(index, count); + } + + public override void CopyTo(ChatMessageContent[] array, int arrayIndex) + { + for (int i = 0; i < this._messages.Count; i++) + { + array[arrayIndex + i] = this._messages[i].ToChatMessageContent(); + } + } + + public override bool Contains(ChatMessageContent item) => base.Contains(item); + + public override int IndexOf(ChatMessageContent item) => base.IndexOf(item); + + public override void AddRange(IEnumerable items) + { + base.AddRange(items); + this._messages.AddRange(items.Select(i => i.ToChatMessage())); + } + + public override int Count => this._messages.Count; + + // Explicit implementation of IEnumerable.GetEnumerator() + IEnumerator IEnumerable.GetEnumerator() + { + foreach (var message in this._messages) + { + yield return message.ToChatMessageContent(); // Convert and yield each item + } + } + + // Explicit implementation of non-generic IEnumerable.GetEnumerator() + IEnumerator IEnumerable.GetEnumerator() + => ((IEnumerable)this).GetEnumerator(); + } + + /// Destructor to clear the chat history overrides. + ~AutoFunctionInvocationContext() + { + // The moment this class is destroyed, we need to clear the update message overrides + this._chatHistory?.ClearOverrides(); + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs index c0a76d0e0c2c..0e09c1d525b7 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs @@ -542,7 +542,6 @@ public KernelAIFunction(KernelFunction kernelFunction, Kernel? kernel) this.JsonSchema = BuildFunctionSchema(kernelFunction); } - public override string Name { get; } public override JsonElement JsonSchema { get; } public override string Description => this._kernelFunction.Description; diff --git a/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj b/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj index 47043cbe1df8..4826426418a6 100644 --- a/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj +++ b/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj @@ -29,7 +29,7 @@ - + diff --git a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs index 61b3324ef420..8037f513ee65 100644 --- a/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs +++ b/dotnet/src/SemanticKernel.Core/Functions/KernelFunctionFromPrompt.cs @@ -283,7 +283,7 @@ protected override async IAsyncEnumerable InvokeStreamingCoreAsync GetChatClientResultAsync( }; } - var modelId = chatClient.GetService()?.ModelId; + var modelId = chatClient.GetService()?.DefaultModelId; // Usage details are global and duplicated for each chat message content, use first one to get usage information - this.CaptureUsageDetails(chatClient.GetService()?.ModelId, chatResponse.Usage, this._logger); + this.CaptureUsageDetails(chatClient.GetService()?.DefaultModelId, chatResponse.Usage, this._logger); return new FunctionResult(this, chatResponse) { diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/AIFunctionKernelFunctionTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/AIFunctionKernelFunctionTests.cs index 065784118c3d..20a2460e1751 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/AIFunctionKernelFunctionTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/AIFunctionKernelFunctionTests.cs @@ -14,8 +14,8 @@ public class AIFunctionKernelFunctionTests public void ShouldAssignIsRequiredParameterMetadataPropertyCorrectly() { // Arrange and Act - AIFunction aiFunction = AIFunctionFactory.Create((string p1, int? p2 = null) => p1, - new AIFunctionFactoryOptions { JsonSchemaCreateOptions = new AIJsonSchemaCreateOptions { RequireAllProperties = false } }); + AIFunction aiFunction = Microsoft.Extensions.AI.AIFunctionFactory.Create((string p1, int? p2 = null) => p1, + new Microsoft.Extensions.AI.AIFunctionFactoryOptions { JsonSchemaCreateOptions = new AIJsonSchemaCreateOptions { RequireAllProperties = false } }); AIFunctionKernelFunction sut = new(aiFunction); diff --git a/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs index c9c348d1ac44..c25fa0ccd249 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Extensions/ClientResultExceptionExtensionsTests.cs @@ -64,7 +64,6 @@ public void ItProvideStatusForResponsesWithoutContent() // Assert Assert.NotNull(httpOperationException); Assert.NotNull(httpOperationException.StatusCode); - Assert.Null(httpOperationException.ResponseContent); Assert.Equal(exception, httpOperationException.InnerException); Assert.Equal(exception.Message, httpOperationException.Message); Assert.Equal(pipelineResponse.Status, (int)httpOperationException.StatusCode!); diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs index 322c5f3b935f..7400875cdc9b 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/CustomAIChatClientSelectorTests.cs @@ -70,7 +70,7 @@ private sealed class ChatClientTest : IChatClient public ChatClientTest() { - this._metadata = new ChatClientMetadata(modelId: "Value1"); + this._metadata = new ChatClientMetadata(defaultModelId: "Value1"); } public void Dispose() diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs index b0a57fa8cdf6..52619c22af6e 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/OrderedAIServiceSelectorTests.cs @@ -527,7 +527,7 @@ private sealed class ChatClient : IChatClient public ChatClient(string modelId) { - this.Metadata = new ChatClientMetadata(modelId: modelId); + this.Metadata = new ChatClientMetadata(defaultModelId: modelId); } public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) From d0543e9ca77e33deae284b5b76629ccbd0eba501 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:12:50 +0100 Subject: [PATCH 10/22] .Net: main -> feature-msextensions-ai (Conflict fix) (#11610) ### Motivation and Context Main merge. --------- Signed-off-by: dependabot[bot] Co-authored-by: NEWTON MALLICK <38786893+N-E-W-T-O-N@users.noreply.github.com> Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Co-authored-by: Roger Barreto Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> Co-authored-by: Phil Jirsa Co-authored-by: DavidJFowler Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Tao Chen Co-authored-by: Tommaso Stocchi Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Co-authored-by: Dade Cook Co-authored-by: Dade Cook --- dotnet/Directory.Packages.props | 8 +- dotnet/samples/.editorconfig | 2 + .../Google_GeminiChatCompletion.cs | 68 ++- .../Google_GeminiChatCompletionStreaming.cs | 66 ++- .../Google_GeminiGetModelResult.cs | 7 +- .../Google_GeminiStructuredOutputs.cs | 375 ++++++++++++++ .../ChatCompletion/Google_GeminiVision.cs | 29 +- .../FunctionCalling/Gemini_FunctionCalling.cs | 44 +- ...tMemoryPlugin_GeminiEmbeddingGeneration.cs | 52 +- dotnet/samples/Concepts/README.md | 1 + .../ChatWithAgent.AppHost.csproj | 2 +- .../Extensions/AuthorRoleExtensions.cs | 33 ++ .../ChatMessageContentExtensions.cs | 66 +++ .../MCPClient/Extensions/ContentExtensions.cs | 29 ++ .../Extensions/PromptResultExtensions.cs | 36 +- .../Extensions/SamplingMessageExtensions.cs | 34 ++ .../MCPClient/HumanInTheLoopFilter.cs | 48 ++ .../MCPClient/MCPClient.csproj | 4 +- .../MCPClient/Program.cs | 317 +++++++++++- .../Extensions/McpServerBuilderExtensions.cs | 173 ++++++- .../MCPServer/MCPServer.csproj | 13 +- .../MCPServer/Program.cs | 12 +- .../EmbeddedResource.cs | 2 +- .../EmployeeBirthdaysAndPositions.png | Bin 0 -> 117037 bytes .../ProjectResources/SalesReport2014.png | Bin 0 -> 295386 bytes .../{Resources => ProjectResources}/cat.jpg | Bin .../getCurrentWeatherForCity.json | 0 .../semantic-kernel-info.txt | 0 .../MCPServer/Prompts/PromptRegistry.cs | 66 --- .../MCPServer/Resources/ResourceDefinition.cs | 2 +- .../MCPServer/Resources/ResourceRegistry.cs | 104 ---- .../Resources/ResourceTemplateDefinition.cs | 7 +- .../MCPServer/Tools/MailboxUtils.cs | 129 +++++ .../README.md | 89 +++- .../ModelContextProtocolPlugin/Program.cs | 2 +- .../ProcessFramework.Aspire.AppHost.csproj | 2 +- .../Program.cs | 8 +- .../Program.cs | 32 -- .../{Extensions.cs => CommonExtensions.cs} | 76 ++- .../Program.cs | 42 +- .../Program.cs | 42 +- .../package.json | 2 +- .../ProcessWithCloudEvents.Client/yarn.lock | 8 +- .../GettingStarted/GettingStarted.csproj | 2 +- .../GettingStarted/Step1_Create_Kernel.cs | 2 +- .../GettingStarted/Step2_Add_Plugins.cs | 2 +- .../GettingStarted/Step3_Yaml_Prompt.cs | 2 +- .../Step4_Dependency_Injection.cs | 4 +- .../GettingStarted/Step5_Chat_Prompt.cs | 2 +- .../GettingStarted/Step6_Responsible_AI.cs | 2 +- .../GettingStarted/Step7_Observability.cs | 2 +- .../GettingStarted/Step8_Pipelining.cs | 2 +- .../GettingStarted/Step9_OpenAPI_Plugins.cs | 4 +- .../AzureAIAgent/Step01_AzureAIAgent.cs | 2 +- .../Step02_AzureAIAgent_Plugins.cs | 4 +- .../AzureAIAgent/Step03_AzureAIAgent_Chat.cs | 2 +- .../Step04_AzureAIAgent_CodeInterpreter.cs | 2 +- .../Step05_AzureAIAgent_FileSearch.cs | 2 +- .../Step06_AzureAIAgent_OpenAPI.cs | 2 +- .../Step07_AzureAIAgent_Functions.cs | 2 +- .../Step08_AzureAIAgent_Declarative.cs | 21 +- .../BedrockAgent/Step01_BedrockAgent.cs | 6 +- .../Step02_BedrockAgent_CodeInterpreter.cs | 2 +- .../Step03_BedrockAgent_Functions.cs | 6 +- .../BedrockAgent/Step04_BedrockAgent_Trace.cs | 2 +- .../Step05_BedrockAgent_FileSearch.cs | 2 +- .../Step06_BedrockAgent_AgentChat.cs | 2 +- .../Step07_BedrockAgent_Declarative.cs | 8 +- .../GettingStartedWithAgents.csproj | 2 +- .../OpenAIAssistant/Step01_Assistant.cs | 2 +- .../Step02_Assistant_Plugins.cs | 4 +- .../Step03_Assistant_Vision.cs | 2 +- .../Step04_AssistantTool_CodeInterpreter.cs | 2 +- .../Step05_AssistantTool_FileSearch.cs | 2 +- .../Step06_AssistantTool_Function.cs | 2 +- .../Step07_Assistant_Declarative.cs | 11 +- .../GettingStartedWithAgents/Step01_Agent.cs | 4 +- .../Step06_DependencyInjection.cs | 2 +- .../Step07_Telemetry.cs | 4 +- .../Step08_AgentAsKernelFunction.cs | 6 +- .../Step09_Declarative.cs | 9 +- .../Step10_MultiAgent_Declarative.cs | 4 +- .../GettingStartedWithProcesses.csproj | 2 +- .../GettingStartedWithTextSearch.csproj | 2 +- .../GettingStartedWithVectorStores.csproj | 2 +- .../Definition/AgentDefinition.cs | 2 +- .../Abstractions/Definition/AgentOutput.cs | 2 +- .../Yaml/AgentDefinitionYamlTests.cs | 3 +- .../Agents/UnitTests/Yaml/AgentYamlTests.cs | 3 +- dotnet/src/Agents/Yaml/AgentDefinitionYaml.cs | 8 + .../Core/ClientCoreTests.cs | 4 - .../Core/AzureClientCore.cs | 2 - .../Core/SingleAuthorizationHeaderPolicy.cs | 46 -- .../Core/Gemini/GeminiRequestTests.cs | 81 ++- .../Core/Gemini/Models/GeminiRequest.cs | 53 +- ...drantVectorStoreCollectionCreateMapping.cs | 2 - ...drantVectorStoreCollectionSearchMapping.cs | 29 +- .../QdrantVectorStoreRecordCollection.cs | 20 +- .../QdrantVectorStoreRecordFieldMapping.cs | 10 +- ...VectorStoreCollectionSearchMappingTests.cs | 4 +- .../QdrantVectorStoreRecordCollectionTests.cs | 20 +- .../QdrantVectorStoreRecordMapperTests.cs | 14 +- .../Functions.Prompty.UnitTests.csproj | 5 + .../PromptyTest.cs | 41 ++ .../TestData/model.json | 11 + .../TestData/relativeFileReference.prompty | 10 + .../Extensions/PromptyKernelExtensions.cs | 10 +- .../KernelFunctionPrompty.cs | 5 +- .../Plugins/CreateKernelPluginYamlTests.cs | 7 +- .../PromptYamlKernelExtensions.cs | 7 +- .../Memory/Qdrant/QdrantVectorStoreFixture.cs | 15 +- .../QdrantVectorStoreRecordCollectionTests.cs | 8 +- .../InternalUtilities/TestConfiguration.cs | 2 +- ...te_filter_what_is_the_semantic_kernel.json | 289 +++++++++++ .../brave_what_is_the_semantic_kernel.json | 445 ++++++++++++++++ .../Web/Brave/BraveTextSearchTests.cs | 285 +++++++++++ .../Web/SearchUrlSkillTests.cs | 65 +++ .../Plugins.Web/Brave/BraveConnector.cs | 160 ++++++ .../Plugins.Web/Brave/BraveSearchResponse.cs | 480 ++++++++++++++++++ .../Plugins.Web/Brave/BraveTextSearch.cs | 387 ++++++++++++++ .../Brave/BraveTextSearchOptions.cs | 39 ++ .../Plugins.Web/Brave/BraveWebResult.cs | 144 ++++++ .../Plugins/Plugins.Web/SearchUrlPlugin.cs | 53 ++ .../Plugins.Web/WebKernelBuilderExtensions.cs | 20 + .../WebServiceCollectionExtensions.cs | 26 + ...letion_agent_message_callback_streaming.py | 14 +- python/samples/concepts/mcp/README.md | 25 +- .../concepts/mcp/agent_with_mcp_agent.py | 118 +++++ .../concepts/mcp/agent_with_mcp_plugin.py | 15 +- .../concepts/mcp/agent_with_mcp_sampling.py | 115 +++++ .../mcp/azure_ai_agent_with_local_server.py | 112 ++++ .../mcp/azure_ai_agent_with_mcp_plugin.py | 11 +- .../mcp/local_agent_with_local_server.py | 112 ++++ python/samples/concepts/mcp/mcp_as_plugin.py | 8 +- .../concepts/mcp/servers/menu_agent_server.py | 175 +++++++ .../restaurant_booking_agent_server.py | 149 ++++++ .../token_usage/simple_chat_token_usage.py | 83 +++ .../simple_chat_token_usage_streaming.py | 94 ++++ python/samples/demos/mcp_server/README.md | 80 +++ .../demos/mcp_server/agent_as_server.py | 145 ++++++ .../mcp_server/mcp_server_with_prompts.py | 83 +++ .../mcp_server/mcp_server_with_sampling.py | 116 +++++ .../sk_mcp_server.py | 9 +- python/samples/demos/sk_mcp_server/README.md | 53 -- python/semantic_kernel/__init__.py | 2 +- python/semantic_kernel/agents/agent.py | 37 +- .../agents/azure_ai/agent_thread_actions.py | 7 +- .../chat_completion/chat_completion_agent.py | 18 +- .../open_ai/assistant_thread_actions.py | 7 +- .../open_ai/responses_agent_thread_actions.py | 11 +- .../semantic_kernel/connectors/ai/__init__.py | 3 +- .../connectors/ai/completion_usage.py | 7 + python/semantic_kernel/connectors/mcp.py | 354 +++++++++---- .../functions/kernel_function_decorator.py | 28 +- .../functions/kernel_function_extension.py | 19 +- .../functions/kernel_function_from_method.py | 1 + python/semantic_kernel/kernel.py | 14 +- .../services/kernel_services_extension.py | 15 +- .../test_azureai_agent_integration.py | 5 + .../test_chat_completion_agent_integration.py | 42 ++ ...test_openai_assistant_agent_integration.py | 5 + ...test_openai_responses_agent_integration.py | 5 + .../test_chat_completion_agent.py | 6 + .../test_openai_responses_thread_actions.py | 2 +- .../ai/test_completion_token_usage.py | 43 ++ 165 files changed, 6183 insertions(+), 965 deletions(-) create mode 100644 dotnet/samples/Concepts/ChatCompletion/Google_GeminiStructuredOutputs.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/AuthorRoleExtensions.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ChatMessageContentExtensions.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ContentExtensions.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/SamplingMessageExtensions.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/HumanInTheLoopFilter.cs rename dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/{ => ProjectResources}/EmbeddedResource.cs (98%) create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmployeeBirthdaysAndPositions.png create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/SalesReport2014.png rename dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/{Resources => ProjectResources}/cat.jpg (100%) rename dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/{Prompts => ProjectResources}/getCurrentWeatherForCity.json (100%) rename dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/{Resources => ProjectResources}/semantic-kernel-info.txt (100%) delete mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Prompts/PromptRegistry.cs delete mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceRegistry.cs create mode 100644 dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Tools/MailboxUtils.cs rename dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/{Extensions.cs => CommonExtensions.cs} (60%) delete mode 100644 dotnet/src/Connectors/Connectors.AzureOpenAI/Core/SingleAuthorizationHeaderPolicy.cs create mode 100644 dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json create mode 100644 dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty create mode 100644 dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_site_filter_what_is_the_semantic_kernel.json create mode 100644 dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_what_is_the_semantic_kernel.json create mode 100644 dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveConnector.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveSearchResponse.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearchOptions.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveWebResult.cs create mode 100644 python/samples/concepts/mcp/agent_with_mcp_agent.py create mode 100644 python/samples/concepts/mcp/agent_with_mcp_sampling.py create mode 100644 python/samples/concepts/mcp/azure_ai_agent_with_local_server.py create mode 100644 python/samples/concepts/mcp/local_agent_with_local_server.py create mode 100644 python/samples/concepts/mcp/servers/menu_agent_server.py create mode 100644 python/samples/concepts/mcp/servers/restaurant_booking_agent_server.py create mode 100644 python/samples/concepts/token_usage/simple_chat_token_usage.py create mode 100644 python/samples/concepts/token_usage/simple_chat_token_usage_streaming.py create mode 100644 python/samples/demos/mcp_server/README.md create mode 100644 python/samples/demos/mcp_server/agent_as_server.py create mode 100644 python/samples/demos/mcp_server/mcp_server_with_prompts.py create mode 100644 python/samples/demos/mcp_server/mcp_server_with_sampling.py rename python/samples/demos/{sk_mcp_server => mcp_server}/sk_mcp_server.py (95%) delete mode 100644 python/samples/demos/sk_mcp_server/README.md create mode 100644 python/tests/unit/connectors/ai/test_completion_token_usage.py diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 94e6c8de85ef..a2d8eec3365a 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -12,7 +12,7 @@ - + @@ -35,7 +35,7 @@ - + @@ -123,7 +123,7 @@ - + @@ -156,7 +156,7 @@ - + diff --git a/dotnet/samples/.editorconfig b/dotnet/samples/.editorconfig index 7fb69b748935..373945e1fd71 100644 --- a/dotnet/samples/.editorconfig +++ b/dotnet/samples/.editorconfig @@ -1,5 +1,7 @@ # Setting errors for SDK projects under samples folder [*.cs] +indent_style = space +indent_size = 4 dotnet_diagnostic.CA2007.severity = error # Do not directly await a Task dotnet_diagnostic.VSTHRD111.severity = error # Use .ConfigureAwait(bool) dotnet_diagnostic.IDE1006.severity = error # Naming rule violations diff --git a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletion.cs b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletion.cs index f5963698ce0d..f32e58fb92b5 100644 --- a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletion.cs +++ b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletion.cs @@ -1,14 +1,18 @@ // Copyright (c) Microsoft. All rights reserved. +using Google.Apis.Auth.OAuth2; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; namespace ChatCompletion; +/// +/// These examples demonstrate different ways of using chat completion with Google VertexAI and GoogleAI APIs. +/// public sealed class Google_GeminiChatCompletion(ITestOutputHelper output) : BaseTest(output) { [Fact] - public async Task GoogleAIAsync() + public async Task GoogleAIUsingChatCompletion() { Console.WriteLine("============= Google AI - Gemini Chat Completion ============="); @@ -27,31 +31,27 @@ public async Task GoogleAIAsync() apiKey: geminiApiKey) .Build(); - await RunSampleAsync(kernel); + await this.ProcessChatAsync(kernel); } [Fact] - public async Task VertexAIAsync() + public async Task VertexAIUsingChatCompletion() { Console.WriteLine("============= Vertex AI - Gemini Chat Completion ============="); - string geminiBearerKey = TestConfiguration.VertexAI.BearerKey; - string geminiModelId = TestConfiguration.VertexAI.Gemini.ModelId; - string geminiLocation = TestConfiguration.VertexAI.Location; - string geminiProject = TestConfiguration.VertexAI.ProjectId; - - if (geminiBearerKey is null || geminiModelId is null || geminiLocation is null || geminiProject is null) - { - Console.WriteLine("Gemini vertex ai credentials not found. Skipping example."); - return; - } + string? bearerToken = null; + Assert.NotNull(TestConfiguration.VertexAI.ClientId); + Assert.NotNull(TestConfiguration.VertexAI.ClientSecret); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( - modelId: geminiModelId, - bearerKey: geminiBearerKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerTokenProvider: GetBearerToken, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .Build(); // To generate bearer key, you need installed google sdk or use google web console with command: @@ -72,23 +72,39 @@ public async Task VertexAIAsync() // // This is just example, in production we recommend using Google SDK to generate your BearerKey token. // // This delegate will be called on every request, // // when providing the token consider using caching strategy and refresh token logic when it is expired or close to expiration. - // return GetBearerKey(); + // return GetBearerToken(); // }, // location: TestConfiguration.VertexAI.Location, // projectId: TestConfiguration.VertexAI.ProjectId); - await RunSampleAsync(kernel); - } + async ValueTask GetBearerToken() + { + if (!string.IsNullOrEmpty(bearerToken)) + { + return bearerToken; + } + + var credential = GoogleWebAuthorizationBroker.AuthorizeAsync( + new ClientSecrets + { + ClientId = TestConfiguration.VertexAI.ClientId, + ClientSecret = TestConfiguration.VertexAI.ClientSecret + }, + ["https://www.googleapis.com/auth/cloud-platform"], + "user", + CancellationToken.None); + + var userCredential = await credential.WaitAsync(CancellationToken.None); + bearerToken = userCredential.Token.AccessToken; + + return bearerToken; + } - private async Task RunSampleAsync(Kernel kernel) - { - await SimpleChatAsync(kernel); + await this.ProcessChatAsync(kernel); } - private async Task SimpleChatAsync(Kernel kernel) + private async Task ProcessChatAsync(Kernel kernel) { - Console.WriteLine("======== Simple Chat ========"); - var chatHistory = new ChatHistory("You are an expert in the tool shop."); var chat = kernel.GetRequiredService(); diff --git a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs index 2b6f7b1f7556..a0b3bfb4e1e8 100644 --- a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs +++ b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs @@ -1,15 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. using System.Text; +using Google.Apis.Auth.OAuth2; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; namespace ChatCompletion; +/// +/// These examples demonstrate different ways of using chat completion with Google VertexAI and GoogleAI APIs. +/// public sealed class Google_GeminiChatCompletionStreaming(ITestOutputHelper output) : BaseTest(output) { [Fact] - public async Task GoogleAIAsync() + public async Task GoogleAIUsingStreamingChatCompletion() { Console.WriteLine("============= Google AI - Gemini Chat Completion ============="); @@ -28,31 +32,27 @@ public async Task GoogleAIAsync() apiKey: geminiApiKey) .Build(); - await RunSampleAsync(kernel); + await this.ProcessStreamingChatAsync(kernel); } [Fact] - public async Task VertexAIAsync() + public async Task VertexAIUsingStreamingChatCompletion() { Console.WriteLine("============= Vertex AI - Gemini Chat Completion ============="); - string geminiBearerKey = TestConfiguration.VertexAI.BearerKey; - string geminiModelId = TestConfiguration.VertexAI.Gemini.ModelId; - string geminiLocation = TestConfiguration.VertexAI.Location; - string geminiProject = TestConfiguration.VertexAI.ProjectId; - - if (geminiBearerKey is null || geminiModelId is null || geminiLocation is null || geminiProject is null) - { - Console.WriteLine("Gemini vertex ai credentials not found. Skipping example."); - return; - } + string? bearerToken = null; + Assert.NotNull(TestConfiguration.VertexAI.ClientId); + Assert.NotNull(TestConfiguration.VertexAI.ClientSecret); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( - modelId: geminiModelId, - bearerKey: geminiBearerKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerTokenProvider: GetBearerToken, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .Build(); // To generate bearer key, you need installed google sdk or use google web console with command: @@ -78,18 +78,34 @@ public async Task VertexAIAsync() // location: TestConfiguration.VertexAI.Location, // projectId: TestConfiguration.VertexAI.ProjectId); - await RunSampleAsync(kernel); - } + async ValueTask GetBearerToken() + { + if (!string.IsNullOrEmpty(bearerToken)) + { + return bearerToken; + } - private async Task RunSampleAsync(Kernel kernel) - { - await StreamingChatAsync(kernel); + var credential = GoogleWebAuthorizationBroker.AuthorizeAsync( + new ClientSecrets + { + ClientId = TestConfiguration.VertexAI.ClientId, + ClientSecret = TestConfiguration.VertexAI.ClientSecret + }, + ["https://www.googleapis.com/auth/cloud-platform"], + "user", + CancellationToken.None); + + var userCredential = await credential.WaitAsync(CancellationToken.None); + bearerToken = userCredential.Token.AccessToken; + + return bearerToken; + } + + await this.ProcessStreamingChatAsync(kernel); } - private async Task StreamingChatAsync(Kernel kernel) + private async Task ProcessStreamingChatAsync(Kernel kernel) { - Console.WriteLine("======== Streaming Chat ========"); - var chatHistory = new ChatHistory("You are an expert in the tool shop."); var chat = kernel.GetRequiredService(); diff --git a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs index fd687768fb4e..8eeca97e10a2 100644 --- a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs +++ b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs @@ -11,10 +11,15 @@ namespace ChatCompletion; public sealed class Google_GeminiGetModelResult(ITestOutputHelper output) : BaseTest(output) { [Fact] - public async Task GetTokenUsageMetadataAsync() + public async Task GetTokenUsageMetadata() { Console.WriteLine("======== Inline Function Definition + Invocation ========"); + Assert.NotNull(TestConfiguration.VertexAI.BearerKey); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); + // Create kernel Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( diff --git a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiStructuredOutputs.cs b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiStructuredOutputs.cs new file mode 100644 index 000000000000..20ae0617a06c --- /dev/null +++ b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiStructuredOutputs.cs @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Google.Apis.Auth.OAuth2; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.Google; +using OpenAI.Chat; +using Directory = System.IO.Directory; +using File = System.IO.File; + +namespace ChatCompletion; + +/// +/// Structured Outputs is a feature in Vertex API that ensures the model will always generate responses based on provided JSON Schema. +/// This gives more control over model responses, allows to avoid model hallucinations and write simpler prompts without a need to be specific about response format. +/// More information here: . +/// +public class Google_GeminiStructuredOutputs(ITestOutputHelper output) : BaseTest(output) +{ + /// + /// This method shows how to enable Structured Outputs feature with object by providing + /// JSON schema of desired response format. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StructuredOutputsWithTypeInExecutionSettings(bool useGoogleAI) + { + var kernel = this.InitializeKernel(useGoogleAI); + + GeminiPromptExecutionSettings executionSettings = new() + { + ResponseMimeType = "application/json", + // Send a request and pass prompt execution settings with desired response schema. + ResponseSchema = typeof(User) + }; + + var result = await kernel.InvokePromptAsync("Extract the data from the following text: My name is Praveen", new(executionSettings)); + + var user = JsonSerializer.Deserialize(result.ToString())!; + this.OutputResult(user); + + // Send a request and pass prompt execution settings with desired response schema. + executionSettings.ResponseSchema = typeof(MovieResult); + result = await kernel.InvokePromptAsync("What are the top 10 movies of all time?", new(executionSettings)); + + // Deserialize string response to a strong type to access type properties. + // At this point, the deserialization logic won't fail, because MovieResult type was described using JSON schema. + // This ensures that response string is a serialized version of MovieResult type. + var movieResult = JsonSerializer.Deserialize(result.ToString())!; + + // Output the result. + this.OutputResult(movieResult); + } + + /// + /// This method shows how to use Structured Outputs feature in combination with Function Calling and Gemini models. + /// function returns a of email bodies. + /// As for final result, the desired response format should be , which contains additional property. + /// This shows how the data can be transformed with AI using strong types without additional instructions in the prompt. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StructuredOutputsWithFunctionCalling(bool useGoogleAI) + { + // Initialize kernel. + var kernel = this.InitializeKernel(useGoogleAI); + + kernel.ImportPluginFromType(); + + // Specify response format by setting Type object in prompt execution settings and enable automatic function calling. + var executionSettings = new GeminiPromptExecutionSettings + { + ResponseSchema = typeof(EmailResult), + ResponseMimeType = "application/json", + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Send a request and pass prompt execution settings with desired response format. + var result = await kernel.InvokePromptAsync("Process the emails.", new(executionSettings)); + + // Deserialize string response to a strong type to access type properties. + // At this point, the deserialization logic won't fail, because EmailResult type was specified as desired response format. + // This ensures that response string is a serialized version of EmailResult type. + var emailResult = JsonSerializer.Deserialize(result.ToString())!; + + // Output the result. + this.OutputResult(emailResult); + } + + /// + /// This method shows how to enable Structured Outputs feature with Semantic Kernel functions from prompt + /// using Semantic Kernel template engine. + /// In this scenario, JSON Schema for response is specified in a prompt configuration file. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StructuredOutputsWithFunctionsFromPrompt(bool useGoogleAI) + { + // Initialize kernel. + var kernel = this.InitializeKernel(useGoogleAI); + + // Initialize a path to plugin directory: Resources/Plugins/MoviePlugins/MoviePluginPrompt. + var pluginDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), "Resources", "Plugins", "MoviePlugins", "MoviePluginPrompt"); + + // Create a function from prompt. + kernel.ImportPluginFromPromptDirectory(pluginDirectoryPath, pluginName: "MoviePlugin"); + + var executionSettings = new GeminiPromptExecutionSettings + { + ResponseSchema = typeof(MovieResult), + ResponseMimeType = "application/json", + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + var result = await kernel.InvokeAsync("MoviePlugin", "TopMovies", new(executionSettings)); + + // Deserialize string response to a strong type to access type properties. + // At this point, the deserialization logic won't fail, because MovieResult type was specified as desired response format. + // This ensures that response string is a serialized version of MovieResult type. + var movieResult = JsonSerializer.Deserialize(result.ToString())!; + + // Output the result. + this.OutputResult(movieResult); + } + + /// + /// This method shows how to enable Structured Outputs feature with Semantic Kernel functions from YAML + /// using Semantic Kernel template engine. + /// In this scenario, JSON Schema for response is specified in YAML prompt file. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StructuredOutputsWithFunctionsFromYaml(bool useGoogleAI) + { + // Initialize kernel. + var kernel = this.InitializeKernel(useGoogleAI); + + // Initialize a path to YAML function: Resources/Plugins/MoviePlugins/MoviePluginYaml. + var functionPath = Path.Combine(Directory.GetCurrentDirectory(), "Resources", "Plugins", "MoviePlugins", "MoviePluginYaml", "TopMovies.yaml"); + + // Load YAML prompt. + var topMoviesYaml = File.ReadAllText(functionPath); + + // Import a function from YAML. + var function = kernel.CreateFunctionFromPromptYaml(topMoviesYaml); + kernel.ImportPluginFromFunctions("MoviePlugin", [function]); + + var executionSettings = new GeminiPromptExecutionSettings + { + ResponseSchema = typeof(MovieResult), + ResponseMimeType = "application/json", + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + var result = await kernel.InvokeAsync("MoviePlugin", "TopMovies", new(executionSettings)); + + // Deserialize string response to a strong type to access type properties. + // At this point, the deserialization logic won't fail, because MovieResult type was specified as desired response format. + // This ensures that response string is a serialized version of MovieResult type. + var movieResult = JsonSerializer.Deserialize(result.ToString())!; + + // Output the result. + this.OutputResult(movieResult); + } + + #region private + + /// Movie result struct that will be used as desired chat completion response format (structured output). + private struct MovieResult + { + public List Movies { get; set; } + } + + /// Movie struct that will be used as desired chat completion response format (structured output). + private struct Movie + { + public string Title { get; set; } + + public string Director { get; set; } + + public int ReleaseYear { get; set; } + + public double Rating { get; set; } + + public bool IsAvailableOnStreaming { get; set; } + + public List Tags { get; set; } + } + + private sealed class EmailResult + { + public List Emails { get; set; } + } + + private sealed class Email + { + public string Body { get; set; } + + public string Category { get; set; } + } + + /// Plugin to simulate RAG scenario and return collection of data. + private sealed class EmailPlugin + { + /// Function to simulate RAG scenario and return collection of data. + [KernelFunction] + private List GetEmails() + { + return + [ + "Hey, just checking in to see how you're doing!", + "Can you pick up some groceries on your way back home? We need milk and bread.", + "Happy Birthday! Wishing you a fantastic day filled with love and joy.", + "Let's catch up over coffee this Saturday. It's been too long!", + "Please review the attached document and provide your feedback by EOD.", + ]; + } + } + + [Description("User")] + private sealed class User + { + [Description("This field contains name of user")] + [JsonPropertyName("name")] + [AllowNull] + public string? Name { get; set; } + + [Description("This field contains user email")] + [JsonPropertyName("email")] + [AllowNull] + public string? Email { get; set; } + + [Description("This field contains user age")] + [JsonPropertyName("age")] + [AllowNull] + public int? Age { get; set; } + } + + /// Helper method to output object content. + private void OutputResult(MovieResult movieResult) + { + for (var i = 0; i < movieResult.Movies.Count; i++) + { + var movie = movieResult.Movies[i]; + + this.Output.WriteLine($""" + - Movie #{i + 1} + Title: {movie.Title} + Director: {movie.Director} + Release year: {movie.ReleaseYear} + Rating: {movie.Rating} + Is available on streaming: {movie.IsAvailableOnStreaming} + Tags: {string.Join(",", movie.Tags ?? [])} + """); + } + } + + /// Helper method to output object content. + private void OutputResult(EmailResult emailResult) + { + for (var i = 0; i < emailResult.Emails.Count; i++) + { + var email = emailResult.Emails[i]; + + this.Output.WriteLine($""" + - Email #{i + 1} + Body: {email.Body} + Category: {email.Category} + """); + } + } + + private void OutputResult(User user) + { + this.Output.WriteLine($""" + - User + Name: {user.Name} + Email: {user.Email} + Age: {user.Age} + """); + } + + private Kernel InitializeKernel(bool useGoogleAI) + { + Kernel kernel; + if (useGoogleAI) + { + this.Console.WriteLine("============= Google AI - Gemini Chat Completion Structured Outputs ============="); + + Assert.NotNull(TestConfiguration.GoogleAI.ApiKey); + Assert.NotNull(TestConfiguration.GoogleAI.Gemini.ModelId); + + kernel = Kernel.CreateBuilder() + .AddGoogleAIGeminiChatCompletion( + modelId: TestConfiguration.GoogleAI.Gemini.ModelId, + apiKey: TestConfiguration.GoogleAI.ApiKey) + .Build(); + } + else + { + this.Console.WriteLine("============= Vertex AI - Gemini Chat Completion Structured Outputs ============="); + + Assert.NotNull(TestConfiguration.VertexAI.ClientId); + Assert.NotNull(TestConfiguration.VertexAI.ClientSecret); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); + + string? bearerToken = TestConfiguration.VertexAI.BearerKey; + kernel = Kernel.CreateBuilder() + .AddVertexAIGeminiChatCompletion( + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerTokenProvider: GetBearerToken, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) + .Build(); + + // To generate bearer key, you need installed google sdk or use google web console with command: + // + // gcloud auth print-access-token + // + // Above code pass bearer key as string, it is not recommended way in production code, + // especially if IChatCompletionService will be long lived, tokens generated by google sdk lives for 1 hour. + // You should use bearer key provider, which will be used to generate token on demand: + // + // Example: + // + // Kernel kernel = Kernel.CreateBuilder() + // .AddVertexAIGeminiChatCompletion( + // modelId: TestConfiguration.VertexAI.Gemini.ModelId, + // bearerKeyProvider: () => + // { + // // This is just example, in production we recommend using Google SDK to generate your BearerKey token. + // // This delegate will be called on every request, + // // when providing the token consider using caching strategy and refresh token logic when it is expired or close to expiration. + // return GetBearerToken(); + // }, + // location: TestConfiguration.VertexAI.Location, + // projectId: TestConfiguration.VertexAI.ProjectId); + + async ValueTask GetBearerToken() + { + if (!string.IsNullOrEmpty(bearerToken)) + { + return bearerToken; + } + + var credential = GoogleWebAuthorizationBroker.AuthorizeAsync( + new ClientSecrets + { + ClientId = TestConfiguration.VertexAI.ClientId, + ClientSecret = TestConfiguration.VertexAI.ClientSecret + }, + ["https://www.googleapis.com/auth/cloud-platform"], + "user", + CancellationToken.None); + + var userCredential = await credential.WaitAsync(CancellationToken.None); + bearerToken = userCredential.Token.AccessToken; + + return bearerToken; + } + } + + return kernel; + } + #endregion +} diff --git a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs index 179b2b40937d..a46a1a352230 100644 --- a/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs +++ b/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs @@ -6,10 +6,13 @@ namespace ChatCompletion; +/// +/// This sample shows how to use Google's Gemini Chat Completion model with vision using VertexAI and GoogleAI APIs. +/// public sealed class Google_GeminiVision(ITestOutputHelper output) : BaseTest(output) { [Fact] - public async Task GoogleAIAsync() + public async Task GoogleAIChatCompletionWithVision() { Console.WriteLine("============= Google AI - Gemini Chat Completion with vision ============="); @@ -50,27 +53,21 @@ public async Task GoogleAIAsync() } [Fact] - public async Task VertexAIAsync() + public async Task VertexAIChatCompletionWithVision() { Console.WriteLine("============= Vertex AI - Gemini Chat Completion with vision ============="); - string geminiBearerKey = TestConfiguration.VertexAI.BearerKey; - string geminiModelId = TestConfiguration.VertexAI.Gemini.ModelId; - string geminiLocation = TestConfiguration.VertexAI.Location; - string geminiProject = TestConfiguration.VertexAI.ProjectId; - - if (geminiBearerKey is null || geminiLocation is null || geminiProject is null) - { - Console.WriteLine("Gemini vertex ai credentials not found. Skipping example."); - return; - } + Assert.NotNull(TestConfiguration.VertexAI.BearerKey); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( - modelId: geminiModelId, - bearerKey: geminiBearerKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerKey: TestConfiguration.VertexAI.BearerKey, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .Build(); // To generate bearer key, you need installed google sdk or use google web console with command: diff --git a/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs b/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs index 33784679a886..4c52461f6e9b 100644 --- a/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs +++ b/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs @@ -26,58 +26,46 @@ namespace FunctionCalling; public sealed class Gemini_FunctionCalling(ITestOutputHelper output) : BaseTest(output) { [RetryFact] - public async Task GoogleAIAsync() + public async Task GoogleAIChatCompletionWithFunctionCalling() { Console.WriteLine("============= Google AI - Gemini Chat Completion with function calling ============="); - string geminiApiKey = TestConfiguration.GoogleAI.ApiKey; - string geminiModelId = TestConfiguration.GoogleAI.Gemini.ModelId; - - if (geminiApiKey is null || geminiModelId is null) - { - Console.WriteLine("Gemini credentials not found. Skipping example."); - return; - } + Assert.NotNull(TestConfiguration.GoogleAI.ApiKey); + Assert.NotNull(TestConfiguration.GoogleAI.Gemini.ModelId); Kernel kernel = Kernel.CreateBuilder() .AddGoogleAIGeminiChatCompletion( - modelId: geminiModelId, - apiKey: geminiApiKey) + modelId: TestConfiguration.GoogleAI.Gemini.ModelId, + apiKey: TestConfiguration.GoogleAI.ApiKey) .Build(); await this.RunSampleAsync(kernel); } [RetryFact] - public async Task VertexAIAsync() + public async Task VertexAIChatCompletionWithFunctionCalling() { Console.WriteLine("============= Vertex AI - Gemini Chat Completion with function calling ============="); - string geminiApiKey = TestConfiguration.VertexAI.BearerKey; - string geminiModelId = TestConfiguration.VertexAI.Gemini.ModelId; - string geminiLocation = TestConfiguration.VertexAI.Location; - string geminiProject = TestConfiguration.VertexAI.ProjectId; - - if (geminiApiKey is null || geminiModelId is null || geminiLocation is null || geminiProject is null) - { - Console.WriteLine("Gemini vertex ai credentials not found. Skipping example."); - return; - } + Assert.NotNull(TestConfiguration.VertexAI.BearerKey); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( - modelId: geminiModelId, - bearerKey: geminiApiKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerKey: TestConfiguration.VertexAI.BearerKey, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .Build(); - // To generate bearer key, you need installed google sdk or use google web console with command: + // To generate bearer key, you need installed google sdk or use Google web console with command: // // gcloud auth print-access-token // // Above code pass bearer key as string, it is not recommended way in production code, - // especially if IChatCompletionService will be long lived, tokens generated by google sdk lives for 1 hour. + // especially if IChatCompletionService will be long-lived, tokens generated by google sdk lives for 1 hour. // You should use bearer key provider, which will be used to generate token on demand: // // Example: diff --git a/dotnet/samples/Concepts/Memory/TextMemoryPlugin_GeminiEmbeddingGeneration.cs b/dotnet/samples/Concepts/Memory/TextMemoryPlugin_GeminiEmbeddingGeneration.cs index 57c9d21cfdcb..0313370782e0 100644 --- a/dotnet/samples/Concepts/Memory/TextMemoryPlugin_GeminiEmbeddingGeneration.cs +++ b/dotnet/samples/Concepts/Memory/TextMemoryPlugin_GeminiEmbeddingGeneration.cs @@ -19,23 +19,16 @@ public async Task GoogleAIAsync() { Console.WriteLine("============= Google AI - Gemini Embedding Generation ============="); - string googleAIApiKey = TestConfiguration.GoogleAI.ApiKey; - string geminiModelId = TestConfiguration.GoogleAI.Gemini.ModelId; - string embeddingModelId = TestConfiguration.GoogleAI.EmbeddingModelId; - - if (googleAIApiKey is null || geminiModelId is null || embeddingModelId is null) - { - Console.WriteLine("GoogleAI credentials not found. Skipping example."); - return; - } + Assert.NotNull(TestConfiguration.GoogleAI.ApiKey); + Assert.NotNull(TestConfiguration.GoogleAI.EmbeddingModelId); Kernel kernel = Kernel.CreateBuilder() .AddGoogleAIGeminiChatCompletion( - modelId: geminiModelId, - apiKey: googleAIApiKey) + modelId: TestConfiguration.GoogleAI.EmbeddingModelId, + apiKey: TestConfiguration.GoogleAI.ApiKey) .AddGoogleAIEmbeddingGeneration( - modelId: embeddingModelId, - apiKey: googleAIApiKey) + modelId: TestConfiguration.GoogleAI.EmbeddingModelId, + apiKey: TestConfiguration.GoogleAI.ApiKey) .Build(); await this.RunSimpleSampleAsync(kernel); @@ -47,30 +40,23 @@ public async Task VertexAIAsync() { Console.WriteLine("============= Vertex AI - Gemini Embedding Generation ============="); - string vertexBearerKey = TestConfiguration.VertexAI.BearerKey; - string geminiModelId = TestConfiguration.VertexAI.Gemini.ModelId; - string geminiLocation = TestConfiguration.VertexAI.Location; - string geminiProject = TestConfiguration.VertexAI.ProjectId; - string embeddingModelId = TestConfiguration.VertexAI.EmbeddingModelId; - - if (vertexBearerKey is null || geminiModelId is null || geminiLocation is null - || geminiProject is null || embeddingModelId is null) - { - Console.WriteLine("VertexAI credentials not found. Skipping example."); - return; - } + Assert.NotNull(TestConfiguration.VertexAI.BearerKey); + Assert.NotNull(TestConfiguration.VertexAI.Location); + Assert.NotNull(TestConfiguration.VertexAI.ProjectId); + Assert.NotNull(TestConfiguration.VertexAI.Gemini.ModelId); + Assert.NotNull(TestConfiguration.VertexAI.EmbeddingModelId); Kernel kernel = Kernel.CreateBuilder() .AddVertexAIGeminiChatCompletion( - modelId: geminiModelId, - bearerKey: vertexBearerKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.Gemini.ModelId, + bearerKey: TestConfiguration.VertexAI.BearerKey, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .AddVertexAIEmbeddingGeneration( - modelId: embeddingModelId, - bearerKey: vertexBearerKey, - location: geminiLocation, - projectId: geminiProject) + modelId: TestConfiguration.VertexAI.EmbeddingModelId, + bearerKey: TestConfiguration.VertexAI.BearerKey, + location: TestConfiguration.VertexAI.Location, + projectId: TestConfiguration.VertexAI.ProjectId) .Build(); // To generate bearer key, you need installed google sdk or use google web console with command: diff --git a/dotnet/samples/Concepts/README.md b/dotnet/samples/Concepts/README.md index 70f3f8e670d3..814e6341a644 100644 --- a/dotnet/samples/Concepts/README.md +++ b/dotnet/samples/Concepts/README.md @@ -59,6 +59,7 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom - [Google_GeminiChatCompletion](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletion.cs) - [Google_GeminiChatCompletionStreaming](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiChatCompletionStreaming.cs) - [Google_GeminiGetModelResult](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiGetModelResult.cs) +- [Google_GeminiStructuredOutputs](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiStructuredOutputs.cs) - [Google_GeminiVision](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/Google_GeminiVision.cs) - [HuggingFace_ChatCompletion](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/HuggingFace_ChatCompletion.cs) - [HuggingFace_ChatCompletionStreaming](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/ChatCompletion/HuggingFace_ChatCompletionStreaming.cs) diff --git a/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj b/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj index ed2d670a7ef8..6f27fe618eba 100644 --- a/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj +++ b/dotnet/samples/Demos/AgentFrameworkWithAspire/ChatWithAgent.AppHost/ChatWithAgent.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/AuthorRoleExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/AuthorRoleExtensions.cs new file mode 100644 index 000000000000..ecd98dbdd1ab --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/AuthorRoleExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel.ChatCompletion; +using ModelContextProtocol.Protocol.Types; + +namespace MCPClient; + +/// +/// Extension methods for the . +/// +internal static class AuthorRoleExtensions +{ + /// + /// Converts a to a . + /// + /// The author role to convert. + /// The corresponding . + public static Role ToMCPRole(this AuthorRole role) + { + if (role == AuthorRole.User) + { + return Role.User; + } + + if (role == AuthorRole.Assistant) + { + return Role.Assistant; + } + + throw new InvalidOperationException($"Unexpected role '{role}'"); + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ChatMessageContentExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ChatMessageContentExtensions.cs new file mode 100644 index 000000000000..9ad85cbfb59c --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ChatMessageContentExtensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Protocol.Types; + +namespace MCPClient; + +/// +/// Extension methods for . +/// +public static class ChatMessageContentExtensions +{ + /// + /// Converts a to a . + /// + /// The to convert. + /// The corresponding . + public static CreateMessageResult ToCreateMessageResult(this ChatMessageContent chatMessageContent) + { + // Using the same heuristic as in the original MCP SDK code: McpClientExtensions.ToCreateMessageResult for consistency. + // ChatMessageContent can contain multiple items of different modalities, while the CreateMessageResult + // can only have a single content type: text, image, or audio. First, look for image or audio content, + // and if not found, fall back to the text content type by concatenating the text of all text contents. + Content? content = null; + + foreach (KernelContent item in chatMessageContent.Items) + { + if (item is ImageContent image) + { + content = new Content + { + Type = "image", + Data = Convert.ToBase64String(image.Data!.Value.Span), + MimeType = image.MimeType + }; + break; + } + else if (item is AudioContent audio) + { + content = new Content + { + Type = "audio", + Data = Convert.ToBase64String(audio.Data!.Value.Span), + MimeType = audio.MimeType + }; + break; + } + } + + content ??= new Content + { + Type = "text", + Text = string.Concat(chatMessageContent.Items.OfType()), + MimeType = "text/plain" + }; + + return new CreateMessageResult + { + Role = chatMessageContent.Role.ToMCPRole(), + Model = chatMessageContent.ModelId ?? "unknown", + Content = content + }; + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ContentExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ContentExtensions.cs new file mode 100644 index 000000000000..6cc4ac42111b --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/ContentExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Protocol.Types; + +namespace MCPClient; + +/// +/// Extension methods for the class. +/// +public static class ContentExtensions +{ + /// + /// Converts a object to a object. + /// + /// The object to convert. + /// The corresponding object. + public static KernelContent ToKernelContent(this Content content) + { + return content.Type switch + { + "text" => new TextContent(content.Text), + "image" => new ImageContent(Convert.FromBase64String(content.Data!), content.MimeType), + "audio" => new AudioContent(Convert.FromBase64String(content.Data!), content.MimeType), + _ => throw new InvalidOperationException($"Unexpected message content type '{content.Type}'"), + }; + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/PromptResultExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/PromptResultExtensions.cs index 12ecb9458f1b..4ab3747dfa98 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/PromptResultExtensions.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/PromptResultExtensions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; +using System.Linq; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using ModelContextProtocol.Protocol.Types; @@ -20,30 +20,16 @@ internal static class PromptResultExtensions /// The corresponding . public static IList ToChatMessageContents(this GetPromptResult result) { - List contents = []; - - foreach (PromptMessage message in result.Messages) - { - ChatMessageContentItemCollection items = []; - - switch (message.Content.Type) - { - case "text": - items.Add(new TextContent(message.Content.Text)); - break; - case "image": - items.Add(new ImageContent(Convert.FromBase64String(message.Content.Data!), message.Content.MimeType)); - break; - case "audio": - items.Add(new AudioContent(Convert.FromBase64String(message.Content.Data!), message.Content.MimeType)); - break; - default: - throw new InvalidOperationException($"Unexpected message content type '{message.Content.Type}'"); - } - - contents.Add(new ChatMessageContent(message.Role.ToAuthorRole(), items)); - } + return [.. result.Messages.Select(ToChatMessageContent)]; + } - return contents; + /// + /// Converts a to a . + /// + /// The to convert. + /// The corresponding . + public static ChatMessageContent ToChatMessageContent(this PromptMessage message) + { + return new ChatMessageContent(role: message.Role.ToAuthorRole(), items: [message.Content.ToKernelContent()]); } } diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/SamplingMessageExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/SamplingMessageExtensions.cs new file mode 100644 index 000000000000..d172a61e2358 --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Extensions/SamplingMessageExtensions.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Protocol.Types; + +namespace MCPClient; + +/// +/// Extension methods for . +/// +public static class SamplingMessageExtensions +{ + /// + /// Converts a collection of to a list of . + /// + /// The collection of to convert. + /// The corresponding list of . + public static List ToChatMessageContents(this IEnumerable samplingMessages) + { + return [.. samplingMessages.Select(ToChatMessageContent)]; + } + + /// + /// Converts a to a . + /// + /// The to convert. + /// The corresponding . + public static ChatMessageContent ToChatMessageContent(this SamplingMessage message) + { + return new ChatMessageContent(role: message.Role.ToAuthorRole(), items: [message.Content.ToKernelContent()]); + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/HumanInTheLoopFilter.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/HumanInTheLoopFilter.cs new file mode 100644 index 000000000000..f910c00b0052 --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/HumanInTheLoopFilter.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Protocol.Types; + +namespace MCPClient; + +/// +/// A filter that intercepts function invocations to allow for human-in-the-loop processing. +/// +public class HumanInTheLoopFilter : IFunctionInvocationFilter +{ + /// + public Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func next) + { + // Intercept the MCP sampling handler before invoking it + if (context.Function.Name == "MCPSamplingHandler") + { + CreateMessageRequestParams request = (CreateMessageRequestParams)context.Arguments["request"]!; + + if (!GetUserApprovalForSamplingMessages(request)) + { + context.Result = new FunctionResult(context.Result, "Operation was rejected due to PII."); + return Task.CompletedTask; + } + } + + // Proceed with the handler invocation + return next.Invoke(context); + } + + /// + /// Checks if the user approves the messages for further sampling request processing. + /// + /// + /// This method serves as a placeholder for the actual implementation, which may involve user interaction through a user interface. + /// The user will be presented with a list of messages and given two options: to approve or reject the request. + /// + /// The sampling request. + /// Returns true if the user approves; otherwise, false. + private static bool GetUserApprovalForSamplingMessages(CreateMessageRequestParams request) + { + // Approve the request + return true; + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/MCPClient.csproj b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/MCPClient.csproj index f474e7a84189..aa5adbd60b9d 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/MCPClient.csproj +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/MCPClient.csproj @@ -5,7 +5,7 @@ net8.0 enable 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 - $(NoWarn);CA2249;CS0612;SKEXP0001;VSTHRD111;CA2007 + $(NoWarn);CA2249;CS0612;SKEXP0001;SKEXP0110;VSTHRD111;CA2007 @@ -18,6 +18,8 @@ + + diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Program.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Program.cs index 06f52e253c98..3abcc6e94b09 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Program.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPClient/Program.cs @@ -4,11 +4,17 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.OpenAI; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol.Transport; using ModelContextProtocol.Protocol.Types; @@ -26,6 +32,12 @@ public static async Task Main(string[] args) await UseMCPResourcesAsync(); await UseMCPResourceTemplatesAsync(); + + await UseMCPSamplingAsync(); + + await UseChatCompletionAgentWithMCPToolsAsync(); + + await UseAzureAIAgentWithMCPToolsAsync(); } /// @@ -220,6 +232,186 @@ private static async Task UseMCPPromptAsync() // The rain in Boston could make walking less enjoyable and potentially inconvenient. } + /// + /// Demonstrates how to use the MCP sampling with the Semantic Kernel. + /// The code in this method: + /// 1. Creates an MCP client and register the sampling request handler. + /// 2. Retrieves the list of tools provided by the MCP server and registers them as Kernel functions. + /// 3. Prompts the AI model to create a schedule based on the latest unread emails in the mailbox. + /// 4. The AI model calls the `MailboxUtils-SummarizeUnreadEmails` function to summarize the unread emails. + /// 5. The `MailboxUtils-SummarizeUnreadEmails` function creates a few sample emails with attachments and + /// sends a sampling request to the client to summarize them: + /// 5.1. The client receive sampling request from server and invokes the sampling request handler. + /// 5.2. SK intercepts the sampling request invocation via `HumanInTheLoopFilter` filter to enable human-in-the-loop processing. + /// 5.3. The `HumanInTheLoopFilter` allows invocation of the sampling request handler. + /// 5.5. The sampling request handler sends the sampling request to the AI model to summarize the emails. + /// 5.6. The AI model processes the request and returns the summary to the handler which sends it back to the server. + /// 5.7. The `MailboxUtils-SummarizeUnreadEmails` function receives the result and returns it to the AI model. + /// 7. Having received the summary, the AI model creates a schedule based on the unread emails. + /// + private static async Task UseMCPSamplingAsync() + { + Console.WriteLine($"Running the {nameof(UseMCPSamplingAsync)} sample."); + + // Create a kernel + Kernel kernel = CreateKernelWithChatCompletionService(); + + // Register the human-in-the-loop filter that intercepts function calls allowing users to review and approve or reject them + kernel.FunctionInvocationFilters.Add(new HumanInTheLoopFilter()); + + // Create an MCP client with a custom sampling request handler + await using IMcpClient mcpClient = await CreateMcpClientAsync(kernel, SamplingRequestHandlerAsync); + + // Import MCP tools as Kernel functions so AI model can call them + IList tools = await mcpClient.ListToolsAsync(); + kernel.Plugins.AddFromFunctions("Tools", tools.Select(aiFunction => aiFunction.AsKernelFunction())); + + // Enable automatic function calling + OpenAIPromptExecutionSettings executionSettings = new() + { + Temperature = 0, + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) + }; + + // Execute a prompt + string prompt = "Create a schedule for me based on the latest unread emails in my inbox."; + IChatCompletionService chatCompletion = kernel.GetRequiredService(); + ChatMessageContent result = await chatCompletion.GetChatMessageContentAsync(prompt, executionSettings, kernel); + + Console.WriteLine(result); + Console.WriteLine(); + + // The expected output is: + // ### Today + // - **Review Sales Report:** + // - **Task:** Provide feedback on the Carretera Sales Report for January to June 2014. + // - **Deadline:** End of the day. + // - **Details:** Check the attached spreadsheet for sales data. + // + // ### Tomorrow + // - **Update Employee Information:** + // - **Task:** Update the list of employee birthdays and positions. + // - **Deadline:** By the end of the day. + // - **Details:** Refer to the attached table for employee details. + // + // ### Saturday + // - **Attend BBQ:** + // - **Event:** BBQ Invitation + // - **Details:** Join the BBQ as mentioned in the sales report email. + // + // ### Sunday + // - **Join Hike:** + // - **Event:** Hiking Invitation + // - **Details:** Participate in the hike as mentioned in the HR email. + } + + /// + /// Demonstrates how to use with MCP tools represented as Kernel functions. + /// The code in this method: + /// 1. Creates an MCP client. + /// 2. Retrieves the list of tools provided by the MCP server. + /// 3. Creates a kernel and registers the MCP tools as Kernel functions. + /// 4. Defines chat completion agent with instructions, name, kernel, and arguments. + /// 5. Invokes the agent with a prompt. + /// 6. The agent sends the prompt to the AI model, together with the MCP tools represented as Kernel functions. + /// 7. The AI model calls DateTimeUtils-GetCurrentDateTimeInUtc function to get the current date time in UTC required as an argument for the next function. + /// 8. The AI model calls WeatherUtils-GetWeatherForCity function with the current date time and the `Boston` arguments extracted from the prompt to get the weather information. + /// 9. Having received the weather information from the function call, the AI model returns the answer to the agent and the agent returns the answer to the user. + /// + private static async Task UseChatCompletionAgentWithMCPToolsAsync() + { + Console.WriteLine($"Running the {nameof(UseChatCompletionAgentWithMCPToolsAsync)} sample."); + + // Create an MCP client + await using IMcpClient mcpClient = await CreateMcpClientAsync(); + + // Retrieve and display the list provided by the MCP server + IList tools = await mcpClient.ListToolsAsync(); + DisplayTools(tools); + + // Create a kernel and register the MCP tools as kernel functions + Kernel kernel = CreateKernelWithChatCompletionService(); + kernel.Plugins.AddFromFunctions("Tools", tools.Select(aiFunction => aiFunction.AsKernelFunction())); + + // Enable automatic function calling + OpenAIPromptExecutionSettings executionSettings = new() + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }) + }; + + string prompt = "What is the likely color of the sky in Boston today?"; + Console.WriteLine(prompt); + + // Define the agent + ChatCompletionAgent agent = new() + { + Instructions = "Answer questions about the weather.", + Name = "WeatherAgent", + Kernel = kernel, + Arguments = new KernelArguments(executionSettings), + }; + + // Invokes agent with a prompt + ChatMessageContent response = await agent.InvokeAsync(prompt).FirstAsync(); + + Console.WriteLine(response); + Console.WriteLine(); + + // The expected output is: The sky in Boston today is likely gray due to rainy weather. + } + + /// + /// Demonstrates how to use with MCP tools represented as Kernel functions. + /// The code in this method: + /// 1. Creates an MCP client. + /// 2. Retrieves the list of tools provided by the MCP server. + /// 3. Creates a kernel and registers the MCP tools as Kernel functions. + /// 4. Defines Azure AI agent with instructions, name, kernel, and arguments. + /// 5. Invokes the agent with a prompt. + /// 6. The agent sends the prompt to the AI model, together with the MCP tools represented as Kernel functions. + /// 7. The AI model calls DateTimeUtils-GetCurrentDateTimeInUtc function to get the current date time in UTC required as an argument for the next function. + /// 8. The AI model calls WeatherUtils-GetWeatherForCity function with the current date time and the `Boston` arguments extracted from the prompt to get the weather information. + /// 9. Having received the weather information from the function call, the AI model returns the answer to the agent and the agent returns the answer to the user. + /// + private static async Task UseAzureAIAgentWithMCPToolsAsync() + { + Console.WriteLine($"Running the {nameof(UseAzureAIAgentWithMCPToolsAsync)} sample."); + + // Create an MCP client + await using IMcpClient mcpClient = await CreateMcpClientAsync(); + + // Retrieve and display the list provided by the MCP server + IList tools = await mcpClient.ListToolsAsync(); + DisplayTools(tools); + + // Create a kernel and register the MCP tools as Kernel functions + Kernel kernel = new(); + kernel.Plugins.AddFromFunctions("Tools", tools.Select(aiFunction => aiFunction.AsKernelFunction())); + + // Define the agent using the kernel with registered MCP tools + AzureAIAgent agent = await CreateAzureAIAgentAsync( + name: "WeatherAgent", + instructions: "Answer questions about the weather.", + kernel: kernel + ); + + // Invokes agent with a prompt + string prompt = "What is the likely color of the sky in Boston today?"; + Console.WriteLine(prompt); + + AgentResponseItem response = await agent.InvokeAsync(message: prompt).FirstAsync(); + Console.WriteLine(response.Message); + Console.WriteLine(); + + // The expected output is: Today in Boston, the weather is 61°F and rainy. Due to the rain, the likely color of the sky will be gray. + + // Delete the agent thread after use + await response!.Thread.DeleteAsync(); + + // Delete the agent after use + await agent.Client.DeleteAgentAsync(agent.Id); + } + /// /// Creates an instance of with the OpenAI chat completion service registered. /// @@ -251,15 +443,128 @@ private static Kernel CreateKernelWithChatCompletionService() /// /// Creates an MCP client and connects it to the MCPServer server. /// + /// Optional kernel instance to use for the MCP client. + /// Optional handler for MCP sampling requests. /// An instance of . - private static Task CreateMcpClientAsync() + private static Task CreateMcpClientAsync( + Kernel? kernel = null, + Func, CancellationToken, Task>? samplingRequestHandler = null) { - return McpClientFactory.CreateAsync(new StdioClientTransport(new() + KernelFunction? skSamplingHandler = null; + + // Create and return the MCP client + return McpClientFactory.CreateAsync( + clientTransport: new StdioClientTransport(new StdioClientTransportOptions + { + Name = "MCPServer", + Command = GetMCPServerPath(), // Path to the MCPServer executable + }), + clientOptions: samplingRequestHandler != null ? new McpClientOptions() + { + Capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability + { + SamplingHandler = InvokeHandlerAsync + }, + }, + } : null + ); + + async ValueTask InvokeHandlerAsync(CreateMessageRequestParams? request, IProgress progress, CancellationToken cancellationToken) { - Name = "MCPServer", - // Point the client to the MCPServer server executable - Command = GetMCPServerPath() - })); + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + skSamplingHandler ??= KernelFunctionFactory.CreateFromMethod( + (CreateMessageRequestParams? request, IProgress progress, CancellationToken ct) => + { + return samplingRequestHandler(kernel!, request, progress, ct); + }, + "MCPSamplingHandler" + ); + + // The argument names must match the parameter names of the delegate the SK Function is created from + KernelArguments kernelArguments = new() + { + ["request"] = request, + ["progress"] = progress + }; + + FunctionResult functionResult = await skSamplingHandler.InvokeAsync(kernel!, kernelArguments, cancellationToken); + + return functionResult.GetValue()!; + } + } + + /// + /// Handles sampling requests from the MCP client. + /// + /// The kernel instance. + /// The sampling request. + /// The progress notification. + /// The cancellation token. + /// The result of the sampling request. + private static async Task SamplingRequestHandlerAsync(Kernel kernel, CreateMessageRequestParams? request, IProgress progress, CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + // Map the MCP sampling request to the Semantic Kernel prompt execution settings + OpenAIPromptExecutionSettings promptExecutionSettings = new() + { + Temperature = request.Temperature, + MaxTokens = request.MaxTokens, + StopSequences = request.StopSequences?.ToList(), + }; + + // Create a chat history from the MCP sampling request + ChatHistory chatHistory = []; + if (!string.IsNullOrEmpty(request.SystemPrompt)) + { + chatHistory.AddSystemMessage(request.SystemPrompt); + } + chatHistory.AddRange(request.Messages.ToChatMessageContents()); + + // Prompt the AI model to generate a response + IChatCompletionService chatCompletion = kernel.GetRequiredService(); + ChatMessageContent result = await chatCompletion.GetChatMessageContentAsync(chatHistory, promptExecutionSettings, cancellationToken: cancellationToken); + + return result.ToCreateMessageResult(); + } + + private static async Task CreateAzureAIAgentAsync(Kernel kernel, string name, string instructions) + { + // Load and validate configuration + IConfigurationRoot config = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + if (config["AzureAI:ConnectionString"] is not { } connectionString) + { + const string Message = "Please provide a valid `AzureAI:ConnectionString` secret to run this sample. See the associated README.md for more details."; + Console.Error.WriteLine(Message); + throw new InvalidOperationException(Message); + } + + string modelId = config["AzureAI:ChatModelId"] ?? "gpt-4o-mini"; + + // Create the Azure AI Agent + AIProjectClient projectClient = AzureAIAgent.CreateAzureAIClient(connectionString, new AzureCliCredential()); + + AgentsClient agentsClient = projectClient.GetAgentsClient(); + + Azure.AI.Projects.Agent agent = await agentsClient.CreateAgentAsync(modelId, name, null, instructions); + + return new AzureAIAgent(agent, agentsClient) + { + Kernel = kernel + }; } /// diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Extensions/McpServerBuilderExtensions.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Extensions/McpServerBuilderExtensions.cs index 07c1aa2bc37b..cedcf95fb138 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Extensions/McpServerBuilderExtensions.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Extensions/McpServerBuilderExtensions.cs @@ -17,19 +17,20 @@ public static class McpServerBuilderExtensions /// Adds all functions of the kernel plugins as tools to the server. /// /// The MCP builder instance. - /// The kernel plugins to add as tools to the server if specified. - /// Otherwise, all functions from the kernel plugins registered in DI container will be added. + /// An optional kernel instance which plugins will be added as tools. + /// If not provided, all functions from the kernel plugins registered in DI container will be added. + /// /// The builder instance. - public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, KernelPluginCollection? plugins = null) + public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, Kernel? kernel = null) { // If plugins are provided directly, add them as tools - if (plugins is not null) + if (kernel is not null) { - foreach (var plugin in plugins) + foreach (var plugin in kernel.Plugins) { foreach (var function in plugin) { - builder.Services.AddSingleton(McpServerTool.Create(function.AsAIFunction())); + builder.Services.AddSingleton(McpServerTool.Create(function.AsAIFunction(kernel))); } } @@ -40,6 +41,7 @@ public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, Kernel builder.Services.AddSingleton>(services => { IEnumerable plugins = services.GetServices(); + Kernel kernel = services.GetRequiredService(); List tools = new(plugins.Count()); @@ -47,7 +49,7 @@ public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, Kernel { foreach (var function in plugin) { - tools.Add(McpServerTool.Create(function.AsAIFunction())); + tools.Add(McpServerTool.Create(function.AsAIFunction(kernel))); } } @@ -58,23 +60,36 @@ public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, Kernel } /// - /// Adds a resource template to the server. + /// Adds a prompt definition and handlers for listing and reading prompts. /// /// The MCP server builder. - /// The resource template definition. + /// The prompt definition. /// The builder instance. - public static IMcpServerBuilder WithPrompt(this IMcpServerBuilder builder, PromptDefinition templateDefinition) + public static IMcpServerBuilder WithPrompt(this IMcpServerBuilder builder, PromptDefinition promptDefinition) { - PromptRegistry.RegisterPrompt(templateDefinition); + // Register the prompt definition in the DI container + builder.Services.AddSingleton(promptDefinition); - builder.WithListPromptsHandler(PromptRegistry.HandlerListPromptRequestsAsync); - builder.WithGetPromptHandler(PromptRegistry.HandlerGetPromptRequestsAsync); + builder.WithPromptHandlers(); return builder; } /// - /// Adds a resource template to the server. + /// Adds handlers for listing and reading prompts. + /// + /// The MCP server builder. + /// The builder instance. + public static IMcpServerBuilder WithPromptHandlers(this IMcpServerBuilder builder) + { + builder.WithListPromptsHandler(HandleListPromptRequestsAsync); + builder.WithGetPromptHandler(HandleGetPromptRequestsAsync); + + return builder; + } + + /// + /// Adds a resource template and handlers for listing and reading resource templates. /// /// The MCP server builder. /// The kernel instance. @@ -93,23 +108,36 @@ public static IMcpServerBuilder WithResourceTemplate( } /// - /// Adds a resource template to the server. + /// Adds a resource template and handlers for listing and reading resource templates. /// /// The MCP server builder. /// The resource template definition. /// The builder instance. public static IMcpServerBuilder WithResourceTemplate(this IMcpServerBuilder builder, ResourceTemplateDefinition templateDefinition) { - ResourceRegistry.RegisterResourceTemplate(templateDefinition); + // Register the resource template definition in the DI container + builder.Services.AddSingleton(templateDefinition); - builder.WithListResourceTemplatesHandler(ResourceRegistry.HandleListResourceTemplatesRequestAsync); - builder.WithReadResourceHandler(ResourceRegistry.HandleReadResourceRequestAsync); + builder.WithResourceTemplateHandlers(); return builder; } /// - /// Adds a resource to the server. + /// Adds handlers for listing and reading resource templates. + /// + /// The MCP server builder. + /// The builder instance. + public static IMcpServerBuilder WithResourceTemplateHandlers(this IMcpServerBuilder builder) + { + builder.WithListResourceTemplatesHandler(HandleListResourceTemplatesRequestAsync); + builder.WithReadResourceHandler(HandleReadResourceRequestAsync); + + return builder; + } + + /// + /// Adds a resource and handlers for listing and reading resources. /// /// The MCP server builder. /// The kernel instance. @@ -128,18 +156,117 @@ public static IMcpServerBuilder WithResource( } /// - /// Adds a resource to the server. + /// Adds a resource and handlers for listing and reading resources. /// /// The MCP server builder. /// The resource definition. /// The builder instance. public static IMcpServerBuilder WithResource(this IMcpServerBuilder builder, ResourceDefinition resourceDefinition) { - ResourceRegistry.RegisterResource(resourceDefinition); + // Register the resource definition in the DI container + builder.Services.AddSingleton(resourceDefinition); + + builder.WithResourceHandlers(); + + return builder; + } - builder.WithListResourcesHandler(ResourceRegistry.HandleListResourcesRequestAsync); - builder.WithReadResourceHandler(ResourceRegistry.HandleReadResourceRequestAsync); + /// + /// Adds handlers for listing and reading resources. + /// + /// The MCP server builder. + /// The builder instance. + public static IMcpServerBuilder WithResourceHandlers(this IMcpServerBuilder builder) + { + builder.WithListResourcesHandler(HandleListResourcesRequestAsync); + builder.WithReadResourceHandler(HandleReadResourceRequestAsync); return builder; } + + private static ValueTask HandleListPromptRequestsAsync(RequestContext context, CancellationToken cancellationToken) + { + // Get and return all prompt definitions registered in the DI container + IEnumerable promptDefinitions = context.Server.Services!.GetServices(); + + return ValueTask.FromResult(new ListPromptsResult + { + Prompts = [.. promptDefinitions.Select(d => d.Prompt)] + }); + } + + private static async ValueTask HandleGetPromptRequestsAsync(RequestContext context, CancellationToken cancellationToken) + { + // Make sure the prompt name is provided + if (context.Params?.Name is not string { } promptName || string.IsNullOrEmpty(promptName)) + { + throw new ArgumentException("Prompt name is required."); + } + + // Get all prompt definitions registered in the DI container + IEnumerable promptDefinitions = context.Server.Services!.GetServices(); + + // Look up the prompt definition + PromptDefinition? definition = promptDefinitions.FirstOrDefault(d => d.Prompt.Name == promptName); + if (definition is null) + { + throw new ArgumentException($"No handler found for the prompt '{promptName}'."); + } + + // Invoke the handler + return await definition.Handler(context, cancellationToken); + } + + private static ValueTask HandleReadResourceRequestAsync(RequestContext context, CancellationToken cancellationToken) + { + // Make sure the uri of the resource or resource template is provided + if (context.Params?.Uri is not string { } resourceUri || string.IsNullOrEmpty(resourceUri)) + { + throw new ArgumentException("Resource uri is required."); + } + + // Look up in registered resource first + IEnumerable resourceDefinitions = context.Server.Services!.GetServices(); + + ResourceDefinition? resourceDefinition = resourceDefinitions.FirstOrDefault(d => d.Resource.Uri == resourceUri); + if (resourceDefinition is not null) + { + return resourceDefinition.InvokeHandlerAsync(context, cancellationToken); + } + + // Look up in registered resource templates + IEnumerable resourceTemplateDefinitions = context.Server.Services!.GetServices(); + + foreach (var resourceTemplateDefinition in resourceTemplateDefinitions) + { + if (resourceTemplateDefinition.IsMatch(resourceUri)) + { + return resourceTemplateDefinition.InvokeHandlerAsync(context, cancellationToken); + } + } + + throw new ArgumentException($"No handler found for the resource uri '{resourceUri}'."); + } + + private static ValueTask HandleListResourceTemplatesRequestAsync(RequestContext context, CancellationToken cancellationToken) + { + // Get and return all resource template definitions registered in the DI container + IEnumerable definitions = context.Server.Services!.GetServices(); + + return ValueTask.FromResult(new ListResourceTemplatesResult + { + ResourceTemplates = [.. definitions.Select(d => d.ResourceTemplate)] + }); + } + + private static ValueTask HandleListResourcesRequestAsync(RequestContext context, CancellationToken cancellationToken) + { + // Get and return all resource template definitions registered in the DI container + IEnumerable definitions = context.Server.Services!.GetServices(); + + return ValueTask.FromResult(new ListResourcesResult + { + Resources = [.. definitions.Select(d => d.Resource)] + }); + } } diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/MCPServer.csproj b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/MCPServer.csproj index 881f48278f5c..82b30ff15251 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/MCPServer.csproj +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/MCPServer.csproj @@ -13,11 +13,18 @@ - - + + + + + + + + Always - + + Always diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Program.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Program.cs index 39b01e56e2e5..1d8182e01895 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Program.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using MCPServer; +using MCPServer.ProjectResources; using MCPServer.Prompts; using MCPServer.Resources; using MCPServer.Tools; @@ -20,6 +21,7 @@ // Register SK plugins kernelBuilder.Plugins.AddFromType(); kernelBuilder.Plugins.AddFromType(); +kernelBuilder.Plugins.AddFromType(); // Register embedding generation service and in-memory vector store (string modelId, string apiKey) = GetConfiguration(); @@ -35,7 +37,7 @@ .WithTools() // Register the `getCurrentWeatherForCity` prompt - .WithPrompt(PromptDefinition.Create(EmbeddedResource.ReadAsString("Prompts.getCurrentWeatherForCity.json"))) + .WithPrompt(PromptDefinition.Create(EmbeddedResource.ReadAsString("getCurrentWeatherForCity.json"))) // Register vector search as MCP resource template .WithResourceTemplate(CreateVectorStoreSearchResourceTemplate()) @@ -44,7 +46,7 @@ .WithResource(ResourceDefinition.CreateBlobResource( uri: "image://cat.jpg", name: "cat-image", - content: EmbeddedResource.ReadAsBytes("Resources.cat.jpg"), + content: EmbeddedResource.ReadAsBytes("cat.jpg"), mimeType: "image/jpeg")); await builder.Build().RunAsync(); @@ -87,8 +89,8 @@ static ResourceTemplateDefinition CreateVectorStoreSearchResourceTemplate(Kernel RequestContext context, string collection, string prompt, - [FromKernelServicesAttribute] ITextEmbeddingGenerationService embeddingGenerationService, - [FromKernelServicesAttribute] IVectorStore vectorStore, + [FromKernelServices] ITextEmbeddingGenerationService embeddingGenerationService, + [FromKernelServices] IVectorStore vectorStore, CancellationToken cancellationToken) => { // Get the vector store collection @@ -107,7 +109,7 @@ static TextDataModel CreateRecord(string text, ReadOnlyMemory embedding) }; } - string content = EmbeddedResource.ReadAsString("Resources.semantic-kernel-info.txt"); + string content = EmbeddedResource.ReadAsString("semantic-kernel-info.txt"); // Create a collection from the lines in the file await vectorStore.CreateCollectionFromListAsync(collection, content.Split('\n'), embeddingGenerationService, CreateRecord); diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/EmbeddedResource.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmbeddedResource.cs similarity index 98% rename from dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/EmbeddedResource.cs rename to dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmbeddedResource.cs index ba7e28273b16..9317f07ffcf0 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/EmbeddedResource.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmbeddedResource.cs @@ -2,7 +2,7 @@ using System.Reflection; -namespace MCPServer; +namespace MCPServer.ProjectResources; /// /// Reads embedded resources. diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmployeeBirthdaysAndPositions.png b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/ProjectResources/EmployeeBirthdaysAndPositions.png new file mode 100644 index 0000000000000000000000000000000000000000..ea329d174660b527e4bc800abdb3f021b98998c6 GIT binary patch literal 117037 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 z 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 z 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 -/// Represents the prompt registry that contains the prompt definitions and provides the handlers for the prompt `List` and `Get` requests. -/// -internal static class PromptRegistry -{ - private static readonly Dictionary s_definitions = new(); - - /// - /// Registers a prompt definition. - /// - /// The prompt definition to register. - public static void RegisterPrompt(PromptDefinition definition) - { - if (s_definitions.ContainsKey(definition.Prompt.Name)) - { - throw new ArgumentException($"A prompt with the name '{definition.Prompt.Name}' is already registered."); - } - - s_definitions[definition.Prompt.Name] = definition; - } - - /// - /// Handles the `Get` prompt requests. - /// - /// The request context. - /// The cancellation token. - /// The result of the `Get` prompt request. - public static async Task HandlerGetPromptRequestsAsync(RequestContext context, CancellationToken cancellationToken) - { - // Make sure the prompt name is provided - if (context.Params?.Name is not string { } promptName || string.IsNullOrEmpty(promptName)) - { - throw new ArgumentException("Prompt name is required."); - } - - // Look up the prompt handler - if (!s_definitions.TryGetValue(promptName, out PromptDefinition? definition)) - { - throw new ArgumentException($"No handler found for the prompt '{promptName}'."); - } - - // Invoke the handler - return await definition.Handler(context, cancellationToken); - } - - /// - /// Handles the `List` prompt requests. - /// - /// Context of the request. - /// The cancellation token. - /// The result of the `List` prompt request. - public static Task HandlerListPromptRequestsAsync(RequestContext context, CancellationToken cancellationToken) - { - return Task.FromResult(new ListPromptsResult - { - Prompts = [.. s_definitions.Values.Select(d => d.Prompt)] - }); - } -} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceDefinition.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceDefinition.cs index b10368d207c2..ac9e74f808b7 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceDefinition.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceDefinition.cs @@ -74,7 +74,7 @@ public static ResourceDefinition CreateBlobResource(string uri, string name, byt /// The MCP server context. /// The cancellation token. /// The result of the invocation. - public async Task InvokeHandlerAsync(RequestContext context, CancellationToken cancellationToken) + public async ValueTask InvokeHandlerAsync(RequestContext context, CancellationToken cancellationToken) { if (this._kernelFunction == null) { diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceRegistry.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceRegistry.cs deleted file mode 100644 index ea2a89eef594..000000000000 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceRegistry.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using ModelContextProtocol.Protocol.Types; -using ModelContextProtocol.Server; - -namespace MCPServer.Resources; - -/// -/// Represents the resource registry that contains the resource and resource template definitions and provides the handlers for the `List` and `Get` requests. -/// -internal static class ResourceRegistry -{ - private static readonly Dictionary s_resourceDefinitions = []; - - private static readonly IList s_resourceTemplateDefinitions = []; - - /// - /// Registers a resource. - /// - /// The resource to register. - public static void RegisterResource(ResourceDefinition definition) - { - if (s_resourceDefinitions.ContainsKey(definition.Resource.Uri)) - { - throw new ArgumentException($"A resource with the uri '{definition.Resource.Uri}' is already registered."); - } - - s_resourceDefinitions[definition.Resource.Uri] = definition; - } - - /// - /// Registers a resource template. - /// - /// The resource template to register. - public static void RegisterResourceTemplate(ResourceTemplateDefinition definition) - { - if (s_resourceTemplateDefinitions.Any(d => d.ResourceTemplate.UriTemplate == definition.ResourceTemplate.UriTemplate)) - { - throw new ArgumentException($"A resource template with the uri template '{definition.ResourceTemplate.UriTemplate}' is already registered."); - } - - s_resourceTemplateDefinitions.Add(definition); - } - - /// - /// Handles the `ListResourceTemplates` request. - /// - /// The MCP server context. - /// The cancellation token. - /// The result of the request. - public static Task HandleListResourceTemplatesRequestAsync(RequestContext context, CancellationToken cancellationToken) - { - return Task.FromResult(new ListResourceTemplatesResult - { - ResourceTemplates = [.. s_resourceTemplateDefinitions.Select(d => d.ResourceTemplate)] - }); - } - - /// - /// Handles the `ListResources` request. - /// - /// The MCP server context. - /// The cancellation token. - /// The result of the request. - public static Task HandleListResourcesRequestAsync(RequestContext context, CancellationToken cancellationToken) - { - return Task.FromResult(new ListResourcesResult - { - Resources = [.. s_resourceDefinitions.Values.Select(d => d.Resource)] - }); - } - - /// - /// Handles the `ReadResource` request. - /// - /// The MCP server context. - /// The cancellation token. - /// The result of the request. - public static Task HandleReadResourceRequestAsync(RequestContext context, CancellationToken cancellationToken) - { - // Make sure the uri of the resource or resource template is provided - if (context.Params?.Uri is not string { } resourceUri || string.IsNullOrEmpty(resourceUri)) - { - throw new ArgumentException("Resource uri is required."); - } - - // Look up in registered resource first - if (s_resourceDefinitions.TryGetValue(resourceUri, out ResourceDefinition? resourceDefinition)) - { - return resourceDefinition.InvokeHandlerAsync(context, cancellationToken); - } - - // Look up in registered resource templates - foreach (var resourceTemplateDefinition in s_resourceTemplateDefinitions) - { - if (resourceTemplateDefinition.IsMatch(resourceUri)) - { - return resourceTemplateDefinition.InvokeHandlerAsync(context, cancellationToken); - } - } - - throw new ArgumentException($"No handler found for the resource uri '{resourceUri}'."); - } -} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceTemplateDefinition.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceTemplateDefinition.cs index bd0b6316a6df..3ee8a1e6860d 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceTemplateDefinition.cs +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Resources/ResourceTemplateDefinition.cs @@ -53,12 +53,9 @@ public bool IsMatch(string uri) /// The MCP server context. /// The cancellation token. /// The result of the invocation. - public async Task InvokeHandlerAsync(RequestContext context, CancellationToken cancellationToken) + public async ValueTask InvokeHandlerAsync(RequestContext context, CancellationToken cancellationToken) { - if (this._kernelFunction == null) - { - this._kernelFunction = KernelFunctionFactory.CreateFromMethod(this.Handler); - } + this._kernelFunction ??= KernelFunctionFactory.CreateFromMethod(this.Handler); this.Kernel ??= context.Server.Services?.GetRequiredService() diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Tools/MailboxUtils.cs b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Tools/MailboxUtils.cs new file mode 100644 index 000000000000..cb93e5a4edd3 --- /dev/null +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/Tools/MailboxUtils.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using MCPServer.ProjectResources; +using Microsoft.SemanticKernel; +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Server; + +namespace MCPServer.Tools; + +/// +/// A collection of utility methods for working with mailbox. +/// +internal sealed class MailboxUtils +{ + /// + /// Summarizes unread emails in the mailbox by using MCP sampling + /// mechanism for summarization. + /// + [KernelFunction] + public static async Task SummarizeUnreadEmailsAsync([FromKernelServices] IMcpServer server) + { + if (server.ClientCapabilities?.Sampling is null) + { + throw new InvalidOperationException("The client does not support sampling."); + } + + // Create two sample emails with attachments + var email1 = new Email + { + Sender = "sales.report@example.com", + Subject = "Carretera Sales Report - Jan & Jun 2014", + Body = "Hi there, I hope this email finds you well! Please find attached the sales report for the first half of 2014. " + + "Please review the report and provide your feedback today, if possible." + + "By the way, we're having a BBQ this Saturday at my place, and you're welcome to join. Let me know if you can make it!", + Attachments = [EmbeddedResource.ReadAsBytes("SalesReport2014.png")] + }; + + var email2 = new Email + { + Sender = "hr.department@example.com", + Subject = "Employee Birthdays and Positions", + Body = "Attached is the list of employee birthdays and their positions. Please check it and let me know of any updates by tomorrow." + + "Also, we're planning a hike this Sunday morning. It would be great if you could join us. Let me know if you're interested!", + Attachments = [EmbeddedResource.ReadAsBytes("EmployeeBirthdaysAndPositions.png")] + }; + + CreateMessageRequestParams request = new() + { + SystemPrompt = "You are a helpful assistant. You will be provided with a list of emails. Please summarize them. Each email is followed by its attachments.", + Messages = CreateMessagesFromEmails(email1, email2), + Temperature = 0 + }; + + // Send the sampling request to the client to summarize the emails + CreateMessageResult result = await server.RequestSamplingAsync(request, cancellationToken: CancellationToken.None); + + // Assuming the response is a text message + return result.Content.Text!; + } + + /// + /// Creates a list of SamplingMessage objects from a list of emails. + /// + /// The list of emails. + /// A list of SamplingMessage objects. + private static List CreateMessagesFromEmails(params Email[] emails) + { + var messages = new List(); + + foreach (var email in emails) + { + messages.Add(new SamplingMessage + { + Role = Role.User, + Content = new Content + { + Text = $"Email from {email.Sender} with subject {email.Subject}. Body: {email.Body}", + Type = "text", + MimeType = "text/plain" + } + }); + + if (email.Attachments != null && email.Attachments.Count != 0) + { + foreach (var attachment in email.Attachments) + { + messages.Add(new SamplingMessage + { + Role = Role.User, + Content = new Content + { + Type = "image", + Data = Convert.ToBase64String(attachment), + MimeType = "image/png", + } + }); + } + } + } + + return messages; + } + + /// + /// Represents an email. + /// + private sealed class Email + { + /// + /// Gets or sets the email sender. + /// + public required string Sender { get; set; } + + /// + /// Gets or sets the email subject. + /// + public required string Subject { get; set; } + + /// + /// Gets or sets the email body. + /// + public required string Body { get; set; } + + /// + /// Gets or sets the email attachments. + /// + public List? Attachments { get; set; } + } +} diff --git a/dotnet/samples/Demos/ModelContextProtocolClientServer/README.md b/dotnet/samples/Demos/ModelContextProtocolClientServer/README.md index e6cfd7fcee8f..0620d9ca5729 100644 --- a/dotnet/samples/Demos/ModelContextProtocolClientServer/README.md +++ b/dotnet/samples/Demos/ModelContextProtocolClientServer/README.md @@ -1,19 +1,25 @@ -# Model Context Protocol Client Server Sample +# Model Context Protocol Client Server Samples -This sample demonstrates how to use Semantic Kernel with the [Model Context Protocol (MCP) C# SDK](https://github.com/modelcontextprotocol/csharp-sdk) to build an MCP server and client. +These samples use the [Model Context Protocol (MCP) C# SDK](https://github.com/modelcontextprotocol/csharp-sdk) and show: +1. How to create an MCP server powered by SK: + - Expose SK plugins as MCP tools. + - Expose SK prompt templates as MCP prompts. + - Use Kernel Function as MCP `Read` resource handlers. + - Use Kernel Function as MCP `Read` resource template handlers. -MCP is an open protocol that standardizes how applications provide context to LLMs. Please refer to the [documentation](https://modelcontextprotocol.io/introduction) for more information. +2. How a hosting app can use MCP client and SK: -The sample shows: - -1. How to create an MCP server powered by SK: SK plugins are exposed as MCP tools. -2. How to create an MCP client and import the MCP tools to SK and use them. + - Import MCP tools as SK functions and utilize them via the Chat Completion service. + - Use MCP prompts as additional context for prompting. + - Use MCP resources and resource templates as additional context for prompting. + - Intercept and handle sampling requests from the MCP server in human-in-the-loop scenarios. + - Import MCP tools as SK functions and utilize them via Chat Completion and Azure AI agents. +Please refer to the [MCP introduction](https://modelcontextprotocol.io/introduction) to get familiar with the protocol. + ## Configuring Secrets or Environment Variables -The example require credentials to access OpenAI. - -If you have set up those credentials as secrets within Secret Manager or through environment variables for other samples from the solution in which this project is found, they will be re-used. +The samples require credentials and other secrets to access AI models. If you have set up those credentials as secrets within Secret Manager or through environment variables for other samples from the solution in which this project is found, they will be re-used. ### Set Secrets with Secret Manager @@ -24,7 +30,9 @@ dotnet user-secrets init dotnet user-secrets set "OpenAI:ChatModelId" "..." dotnet user-secrets set "OpenAI:ApiKey" "..." - "..." +dotnet user-secrets set "AzureAI:ConnectionString" "..." +dotnet user-secrets set "AzureAI:ChatModelId" "..." + ``` ### Set Secrets with Environment Variables @@ -35,10 +43,67 @@ Use these names: # OpenAI OpenAI__ChatModelId OpenAI__ApiKey +AzureAI__ConnectionString +AzureAI__ChatModelId ``` ## Run the Sample To run the sample, follow these steps: + 1. Right-click on the `MCPClient` project in Visual Studio and select `Set as Startup Project`. -2. Press `F5` to run the project. \ No newline at end of file +2. Press `F5` to run the project. +3. All samples will be executed sequentially. You can find the output in the console window. +4. You can run individual samples by commenting out the other samples in the `Main` method of the `Program.cs` file of the `MCPClient` project. + +## Use MCP Inspector and Claude desktop app to access the MCP server + +Both the MCP Inspector and the Claude desktop app can be used to access MCP servers for exploring and testing MCP server capabilities: tools, prompts, resources, etc. + +### MCP Inspector + +To use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) follow these steps: + +1. Open a terminal in the MCPServer project directory. +2. Run the `npx @modelcontextprotocol/inspector dotnet run` command to start the MCP Inspector. Make sure you have [node.js](https://nodejs.org/en/download/) and npm installed + ```bash + npx @modelcontextprotocol/inspector dotnet run + ``` +3. When the inspector is running, it will display a URL in the terminal, like this: + ``` + MCP Inspector is up and running at http://127.0.0.1:6274 + ``` +4. Open a web browser and navigate to the URL displayed in the terminal. This will open the MCP Inspector interface. +5. Find and click the "Connect" button in the MCP Inspector interface to connect to the MCP server. +6. As soon as the connection is established, you will see a list of available tools, prompts, and resources in the MCP Inspector interface. + +### Claude Desktop App + +To use the [Claude desktop app](https://claude.ai/) to access the MCP server, follow these steps: + +1. 1. Download and install the app from the [Claude website](https://claude.ai/download). +2. In the app, go to File->Settings->Developer->Edit Config. +3. Open the `claude_desktop_config.json` file in a text editor and add the following configuration to the file: + ```Json + { + "mcpServers": { + "demo_mcp_server": { + "command": "/dotnet/samples/Demos/ModelContextProtocolClientServer/MCPServer/bin/Debug/net8.0/MCPServer.exe", + "args": [] + } + } + } + ``` +4. Save the file and restart the app. + +## Debugging the MCP Server + +To debug the MCP server in Visual Studio, follow these steps: + +1. Connect to the MCP server using either the MCP Inspector or the Claude desktop app. This should start the MCP server process. +2. Set breakpoints in the MCP server code where you want to debug. +3. In Visual Studio, go to `Debug` -> `Attach to Process`. +4. In the `Attach to Process` dialog, find the `MCPServer.exe` process and select it. +5. Click `Attach` to attach the debugger to the process. +6. Once the debugger is attached, access the MCP server tools, prompts, or resources using the MCP Inspector or the Claude desktop app. + This will trigger the breakpoints you set in the MCP server code. \ No newline at end of file diff --git a/dotnet/samples/Demos/ModelContextProtocolPlugin/Program.cs b/dotnet/samples/Demos/ModelContextProtocolPlugin/Program.cs index a959e3b2042b..3ce97b37480b 100644 --- a/dotnet/samples/Demos/ModelContextProtocolPlugin/Program.cs +++ b/dotnet/samples/Demos/ModelContextProtocolPlugin/Program.cs @@ -63,7 +63,7 @@ Instructions = "Answer questions about GitHub repositories.", Name = "GitHubAgent", Kernel = kernel, - Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), + Arguments = new KernelArguments(executionSettings), }; // Respond to user input, invoking functions where appropriate. diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj index 4c8cfe4b3363..9310b9a042eb 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/ProcessFramework.Aspire.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/Program.cs b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/Program.cs index d286b93ccf92..e5fc1b3f7a5f 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/Program.cs +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.AppHost/Program.cs @@ -12,6 +12,12 @@ var processOrchestrator = builder.AddProject("processorchestrator") .WithReference(translateAgent) - .WithReference(summaryAgent); + .WithReference(summaryAgent) + .WithHttpCommand("/api/processdoc", "Trigger Process", + commandOptions: new() + { + Method = HttpMethod.Get + } + ); builder.Build().Run(); diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ProcessOrchestrator/Program.cs b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ProcessOrchestrator/Program.cs index de3d58905e35..d59b135b9bac 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ProcessOrchestrator/Program.cs +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ProcessOrchestrator/Program.cs @@ -1,46 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.SemanticKernel; -using OpenTelemetry; -using OpenTelemetry.Exporter; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using ProcessFramework.Aspire.ProcessOrchestrator; using ProcessFramework.Aspire.ProcessOrchestrator.Models; using ProcessFramework.Aspire.ProcessOrchestrator.Steps; var builder = WebApplication.CreateBuilder(args); -string otelExporterEndpoint = builder.GetConfiguration("OTEL_EXPORTER_OTLP_ENDPOINT"); -string otelExporterHeaders = builder.GetConfiguration("OTEL_EXPORTER_OTLP_HEADERS"); - AppContext.SetSwitch("Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnosticsSensitive", true); -var loggerFactory = LoggerFactory.Create(builder => -{ - // Add OpenTelemetry as a logging provider - builder.AddOpenTelemetry(options => - { - options.AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }); - // Format log messages. This defaults to false. - options.IncludeFormattedMessage = true; - }); - - builder.AddTraceSource("Microsoft.SemanticKernel"); - builder.SetMinimumLevel(LogLevel.Information); -}); - -using var traceProvider = Sdk.CreateTracerProviderBuilder() - .AddSource("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - -using var meterProvider = Sdk.CreateMeterProviderBuilder() - .AddMeter("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - builder.AddServiceDefaults(); builder.Services.AddHttpClient(client => { client.BaseAddress = new("https+http://translatoragent"); }); builder.Services.AddHttpClient(client => { client.BaseAddress = new("https+http://summaryagent"); }); diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/Extensions.cs b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/CommonExtensions.cs similarity index 60% rename from dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/Extensions.cs rename to dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/CommonExtensions.cs index b95812023687..3a9b4d9241af 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/Extensions.cs +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.ServiceDefaults/CommonExtensions.cs @@ -12,30 +12,21 @@ namespace Microsoft.Extensions.Hosting; /// -/// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -/// This project should be referenced by each service project in your solution. -/// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +/// Provides extension methods for adding common .NET Aspire services, including service discovery, +/// resilience, health checks, and OpenTelemetry. /// -public static class ServiceExtensions +public static class CommonExtensions { - /// - /// Gets a configuration setting from the WebApplicationBuilder. - /// - /// The WebApplicationBuilder instance. - /// The name of the configuration setting. - /// The value of the configuration setting. - /// Thrown when the configuration setting is missing. - public static string GetConfiguration(this WebApplicationBuilder builder, string settingName) - { - return builder.Configuration[settingName] ?? throw new InvalidOperationException($"Missing configuration setting: {settingName}"); - } + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; /// - /// Adds default services to the application builder. + /// Adds default services to the application, including OpenTelemetry, health checks, + /// service discovery, and HTTP client defaults with resilience and service discovery enabled. /// - /// The type of the application builder. - /// The application builder instance. - /// The application builder instance with default services added. + /// The type of the host application builder. + /// The host application builder instance. + /// The updated host application builder. public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -63,13 +54,14 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where } /// - /// Configures OpenTelemetry for the application builder. + /// Configures OpenTelemetry for the application, including logging, metrics, and tracing. /// - /// The type of the application builder. - /// The application builder instance. - /// The application builder instance with OpenTelemetry configured. + /// The type of the host application builder. + /// The host application builder instance. + /// The updated host application builder. public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder { + builder.Logging.AddTraceSource("Microsoft.SemanticKernel"); builder.Logging.AddOpenTelemetry(logging => { logging.IncludeFormattedMessage = true; @@ -81,15 +73,22 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w { metrics.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); + .AddRuntimeInstrumentation() + .AddMeter("Microsoft.SemanticKernel*"); }) .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); + .AddHttpClientInstrumentation() + .AddSource("Microsoft.SemanticKernel*"); }); builder.AddOpenTelemetryExporters(); @@ -97,12 +96,6 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w return builder; } - /// - /// Adds OpenTelemetry exporters to the application builder. - /// - /// The type of the application builder. - /// The application builder instance. - /// The application builder instance with OpenTelemetry exporters added. private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); @@ -123,11 +116,11 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde } /// - /// Adds default health checks to the application builder. + /// Adds default health checks to the application, including a liveness check to ensure the app is responsive. /// - /// The type of the application builder. - /// The application builder instance. - /// The application builder instance with default health checks added. + /// The type of the host application builder. + /// The host application builder instance. + /// The updated host application builder. public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Services.AddHealthChecks() @@ -138,10 +131,11 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) w } /// - /// Maps default endpoints for the application. + /// Maps default health check endpoints for the application. + /// Adds "/health" and "/alive" endpoints in development environments. /// - /// The WebApplication instance. - /// The WebApplication instance with default endpoints mapped. + /// The web application instance. + /// The updated web application instance. public static WebApplication MapDefaultEndpoints(this WebApplication app) { // Adding health checks endpoints to applications in non-development environments has security implications. @@ -149,10 +143,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.SummaryAgent/Program.cs b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.SummaryAgent/Program.cs index 0fb9020d5202..2a31b2b9b796 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.SummaryAgent/Program.cs +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.SummaryAgent/Program.cs @@ -1,57 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.OpenAI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; -using OpenTelemetry; -using OpenTelemetry.Exporter; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using ProcessFramework.Aspire.Shared; var builder = WebApplication.CreateBuilder(args); -string otelExporterEndpoint = builder.GetConfiguration("OTEL_EXPORTER_OTLP_ENDPOINT"); -string otelExporterHeaders = builder.GetConfiguration("OTEL_EXPORTER_OTLP_HEADERS"); - AppContext.SetSwitch("Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnosticsSensitive", true); -var loggerFactory = LoggerFactory.Create(builder => -{ - // Add OpenTelemetry as a logging provider - builder.AddOpenTelemetry(options => - { - options.AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }); - // Format log messages. This defaults to false. - options.IncludeFormattedMessage = true; - }); - - builder.AddTraceSource("Microsoft.SemanticKernel"); - builder.SetMinimumLevel(LogLevel.Information); -}); - -using var traceProvider = Sdk.CreateTracerProviderBuilder() - .AddSource("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - -using var meterProvider = Sdk.CreateMeterProviderBuilder() - .AddMeter("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - builder.AddServiceDefaults(); builder.AddAzureOpenAIClient("openAiConnectionName"); -builder.Services.AddSingleton(builder => -{ - var kernelBuilder = Kernel.CreateBuilder(); - - kernelBuilder.AddAzureOpenAIChatCompletion("gpt-4o", builder.GetService()); - - return kernelBuilder.Build(); -}); +builder.Services.AddKernel().AddAzureOpenAIChatCompletion("gpt-4o"); var app = builder.Build(); diff --git a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.TranslatorAgent/Program.cs b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.TranslatorAgent/Program.cs index ac6080e44f18..1dbc6a991df9 100644 --- a/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.TranslatorAgent/Program.cs +++ b/dotnet/samples/Demos/ProcessFrameworkWithAspire/ProcessFramework.Aspire/ProcessFramework.Aspire.TranslatorAgent/Program.cs @@ -1,57 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.OpenAI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; -using OpenTelemetry; -using OpenTelemetry.Exporter; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using ProcessFramework.Aspire.Shared; var builder = WebApplication.CreateBuilder(args); -string otelExporterEndpoint = builder.GetConfiguration("OTEL_EXPORTER_OTLP_ENDPOINT"); -string otelExporterHeaders = builder.GetConfiguration("OTEL_EXPORTER_OTLP_HEADERS"); - AppContext.SetSwitch("Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnosticsSensitive", true); -var loggerFactory = LoggerFactory.Create(builder => -{ - // Add OpenTelemetry as a logging provider - builder.AddOpenTelemetry(options => - { - options.AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }); - // Format log messages. This defaults to false. - options.IncludeFormattedMessage = true; - }); - - builder.AddTraceSource("Microsoft.SemanticKernel"); - builder.SetMinimumLevel(LogLevel.Information); -}); - -using var traceProvider = Sdk.CreateTracerProviderBuilder() - .AddSource("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - -using var meterProvider = Sdk.CreateMeterProviderBuilder() - .AddMeter("Microsoft.SemanticKernel*") - .AddOtlpExporter(exporter => { exporter.Endpoint = new Uri(otelExporterEndpoint); exporter.Headers = otelExporterHeaders; exporter.Protocol = OtlpExportProtocol.Grpc; }) - .Build(); - builder.AddServiceDefaults(); builder.AddAzureOpenAIClient("openAiConnectionName"); -builder.Services.AddSingleton(builder => -{ - var kernelBuilder = Kernel.CreateBuilder(); - - kernelBuilder.AddAzureOpenAIChatCompletion("gpt-4o", builder.GetService()); - - return kernelBuilder.Build(); -}); +builder.Services.AddKernel().AddAzureOpenAIChatCompletion("gpt-4o"); var app = builder.Build(); diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/package.json b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/package.json index 8ab7c9311b80..acbbc171a693 100644 --- a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/package.json +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/package.json @@ -32,6 +32,6 @@ "globals": "^15.15.0", "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", - "vite": "^6.2.0" + "vite": "^6.2.6" } } diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/yarn.lock b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/yarn.lock index 3f5d6a00d014..759623e2a996 100644 --- a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/yarn.lock +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.Client/yarn.lock @@ -3373,10 +3373,10 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" -vite@^6.2.0: - version "6.2.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.2.5.tgz#d093b5fe8eb96e594761584a966ab13f24457820" - integrity sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA== +vite@^6.2.6: + version "6.2.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.2.6.tgz#7f0ccf2fdc0c1eda079ce258508728e2473d3f61" + integrity sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw== dependencies: esbuild "^0.25.0" postcss "^8.5.3" diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 147ff1c40203..81feff2ae4d2 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -7,7 +7,7 @@ true false - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStarted/Step1_Create_Kernel.cs b/dotnet/samples/GettingStarted/Step1_Create_Kernel.cs index e7f2d50462ed..bcb704b6654d 100644 --- a/dotnet/samples/GettingStarted/Step1_Create_Kernel.cs +++ b/dotnet/samples/GettingStarted/Step1_Create_Kernel.cs @@ -14,7 +14,7 @@ public sealed class Step1_Create_Kernel(ITestOutputHelper output) : BaseTest(out /// Show how to create a and use it to execute prompts. /// [Fact] - public async Task CreateKernelAsync() + public async Task CreateKernel() { // Create a kernel with OpenAI chat completion Kernel kernel = Kernel.CreateBuilder() diff --git a/dotnet/samples/GettingStarted/Step2_Add_Plugins.cs b/dotnet/samples/GettingStarted/Step2_Add_Plugins.cs index b3294919607f..3f6b277fe5f3 100644 --- a/dotnet/samples/GettingStarted/Step2_Add_Plugins.cs +++ b/dotnet/samples/GettingStarted/Step2_Add_Plugins.cs @@ -17,7 +17,7 @@ public sealed class Step2_Add_Plugins(ITestOutputHelper output) : BaseTest(outpu /// Shows different ways to load a instances. /// [Fact] - public async Task AddPluginsAsync() + public async Task AddPlugins() { // Create a kernel with OpenAI chat completion IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); diff --git a/dotnet/samples/GettingStarted/Step3_Yaml_Prompt.cs b/dotnet/samples/GettingStarted/Step3_Yaml_Prompt.cs index a848779d4e96..911933b0909c 100644 --- a/dotnet/samples/GettingStarted/Step3_Yaml_Prompt.cs +++ b/dotnet/samples/GettingStarted/Step3_Yaml_Prompt.cs @@ -15,7 +15,7 @@ public sealed class Step3_Yaml_Prompt(ITestOutputHelper output) : BaseTest(outpu /// Show how to create a prompt from a YAML resource. /// [Fact] - public async Task CreatePromptFromYamlAsync() + public async Task CreatePromptFromYaml() { // Create a kernel with OpenAI chat completion Kernel kernel = Kernel.CreateBuilder() diff --git a/dotnet/samples/GettingStarted/Step4_Dependency_Injection.cs b/dotnet/samples/GettingStarted/Step4_Dependency_Injection.cs index b5c31acfd3a8..4ee22ba39261 100644 --- a/dotnet/samples/GettingStarted/Step4_Dependency_Injection.cs +++ b/dotnet/samples/GettingStarted/Step4_Dependency_Injection.cs @@ -16,7 +16,7 @@ public sealed class Step4_Dependency_Injection(ITestOutputHelper output) : BaseT /// Show how to create a that participates in Dependency Injection. /// [Fact] - public async Task GetKernelUsingDependencyInjectionAsync() + public async Task GetKernelUsingDependencyInjection() { // If an application follows DI guidelines, the following line is unnecessary because DI will inject an instance of the KernelClient class to a class that references it. // DI container guidelines - https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines#recommendations @@ -36,7 +36,7 @@ public async Task GetKernelUsingDependencyInjectionAsync() /// Show how to use a plugin that participates in Dependency Injection. /// [Fact] - public async Task PluginUsingDependencyInjectionAsync() + public async Task PluginUsingDependencyInjection() { // If an application follows DI guidelines, the following line is unnecessary because DI will inject an instance of the KernelClient class to a class that references it. // DI container guidelines - https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines#recommendations diff --git a/dotnet/samples/GettingStarted/Step5_Chat_Prompt.cs b/dotnet/samples/GettingStarted/Step5_Chat_Prompt.cs index 5541b1f07838..dc7eb4206592 100644 --- a/dotnet/samples/GettingStarted/Step5_Chat_Prompt.cs +++ b/dotnet/samples/GettingStarted/Step5_Chat_Prompt.cs @@ -10,7 +10,7 @@ public sealed class Step5_Chat_Prompt(ITestOutputHelper output) : BaseTest(outpu /// Show how to construct a chat prompt and invoke it. /// [Fact] - public async Task InvokeChatPromptAsync() + public async Task InvokeChatPrompt() { // Create a kernel with OpenAI chat completion Kernel kernel = Kernel.CreateBuilder() diff --git a/dotnet/samples/GettingStarted/Step6_Responsible_AI.cs b/dotnet/samples/GettingStarted/Step6_Responsible_AI.cs index d1f717aa47e0..255e9d2bc619 100644 --- a/dotnet/samples/GettingStarted/Step6_Responsible_AI.cs +++ b/dotnet/samples/GettingStarted/Step6_Responsible_AI.cs @@ -11,7 +11,7 @@ public sealed class Step6_Responsible_AI(ITestOutputHelper output) : BaseTest(ou /// Show how to use prompt filters to ensure that prompts are rendered in a responsible manner. /// [Fact] - public async Task AddPromptFilterAsync() + public async Task AddPromptFilter() { // Create a kernel with OpenAI chat completion var builder = Kernel.CreateBuilder() diff --git a/dotnet/samples/GettingStarted/Step7_Observability.cs b/dotnet/samples/GettingStarted/Step7_Observability.cs index 116f3995c0ff..1504097cbbf6 100644 --- a/dotnet/samples/GettingStarted/Step7_Observability.cs +++ b/dotnet/samples/GettingStarted/Step7_Observability.cs @@ -13,7 +13,7 @@ public sealed class Step7_Observability(ITestOutputHelper output) : BaseTest(out /// Shows how to observe the execution of a instance with filters. /// [Fact] - public async Task ObservabilityWithFiltersAsync() + public async Task ObservabilityWithFilters() { // Create a kernel with OpenAI chat completion IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); diff --git a/dotnet/samples/GettingStarted/Step8_Pipelining.cs b/dotnet/samples/GettingStarted/Step8_Pipelining.cs index a7d554f11cd5..96f305c37a17 100644 --- a/dotnet/samples/GettingStarted/Step8_Pipelining.cs +++ b/dotnet/samples/GettingStarted/Step8_Pipelining.cs @@ -14,7 +14,7 @@ public sealed class Step8_Pipelining(ITestOutputHelper output) : BaseTest(output /// them in a sequence, passing the output from one as input to the next. /// [Fact] - public async Task CreateFunctionPipelineAsync() + public async Task CreateFunctionPipeline() { IKernelBuilder builder = Kernel.CreateBuilder(); builder.AddOpenAIChatCompletion( diff --git a/dotnet/samples/GettingStarted/Step9_OpenAPI_Plugins.cs b/dotnet/samples/GettingStarted/Step9_OpenAPI_Plugins.cs index 2813bac110ba..5bff73bab0ca 100644 --- a/dotnet/samples/GettingStarted/Step9_OpenAPI_Plugins.cs +++ b/dotnet/samples/GettingStarted/Step9_OpenAPI_Plugins.cs @@ -15,7 +15,7 @@ public sealed class Step9_OpenAPI_Plugins(ITestOutputHelper output) : BaseTest(o /// Shows how to load an Open API instance. /// [Fact] - public async Task AddOpenAPIPluginsAsync() + public async Task AddOpenAPIPlugins() { // Create a kernel with OpenAI chat completion IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); @@ -36,7 +36,7 @@ public async Task AddOpenAPIPluginsAsync() /// Shows how to transform an Open API instance to support dependency injection. /// [Fact] - public async Task TransformOpenAPIPluginsAsync() + public async Task TransformOpenAPIPlugins() { // Create a kernel with OpenAI chat completion var serviceProvider = BuildServiceProvider(); diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step01_AzureAIAgent.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step01_AzureAIAgent.cs index f002173b4a29..8db5be7a7984 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step01_AzureAIAgent.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step01_AzureAIAgent.cs @@ -13,7 +13,7 @@ namespace GettingStarted.AzureAgents; public class Step01_AzureAIAgent(ITestOutputHelper output) : BaseAzureAgentTest(output) { [Fact] - public async Task UseTemplateForAzureAgentAsync() + public async Task UseTemplateForAzureAgent() { // Define the agent string generateStoryYaml = EmbeddedResource.Read("GenerateStory.yaml"); diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step02_AzureAIAgent_Plugins.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step02_AzureAIAgent_Plugins.cs index 745d9f265236..908948650425 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step02_AzureAIAgent_Plugins.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step02_AzureAIAgent_Plugins.cs @@ -14,7 +14,7 @@ namespace GettingStarted.AzureAgents; public class Step02_AzureAIAgent_Plugins(ITestOutputHelper output) : BaseAzureAgentTest(output) { [Fact] - public async Task UseAzureAgentWithPluginAsync() + public async Task UseAzureAgentWithPlugin() { // Define the agent AzureAIAgent agent = await CreateAzureAgentAsync( @@ -41,7 +41,7 @@ public async Task UseAzureAgentWithPluginAsync() } [Fact] - public async Task UseAzureAgentWithPluginEnumParameterAsync() + public async Task UseAzureAgentWithPluginEnumParameter() { // Define the agent AzureAIAgent agent = await CreateAzureAgentAsync(plugin: KernelPluginFactory.CreateFromType()); diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step03_AzureAIAgent_Chat.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step03_AzureAIAgent_Chat.cs index c71b7124b463..c54d9cf78829 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step03_AzureAIAgent_Chat.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step03_AzureAIAgent_Chat.cs @@ -36,7 +36,7 @@ Consider suggestions when refining an idea. """; [Fact] - public async Task UseGroupChatWithTwoAgentsAsync() + public async Task UseGroupChatWithTwoAgents() { // Define the agents Agent reviewerModel = await this.AgentsClient.CreateAgentAsync( diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs index e7721814e0fd..4c36e882d949 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step04_AzureAIAgent_CodeInterpreter.cs @@ -12,7 +12,7 @@ namespace GettingStarted.AzureAgents; public class Step04_AzureAIAgent_CodeInterpreter(ITestOutputHelper output) : BaseAzureAgentTest(output) { [Fact] - public async Task UseCodeInterpreterToolWithAgentAsync() + public async Task UseCodeInterpreterToolWithAgent() { // Define the agent Azure.AI.Projects.Agent definition = await this.AgentsClient.CreateAgentAsync( diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step05_AzureAIAgent_FileSearch.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step05_AzureAIAgent_FileSearch.cs index 1828577dc778..1716c48d1b6f 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step05_AzureAIAgent_FileSearch.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step05_AzureAIAgent_FileSearch.cs @@ -13,7 +13,7 @@ namespace GettingStarted.AzureAgents; public class Step05_AzureAIAgent_FileSearch(ITestOutputHelper output) : BaseAzureAgentTest(output) { [Fact] - public async Task UseFileSearchToolWithAgentAsync() + public async Task UseFileSearchToolWithAgent() { // Define the agent await using Stream stream = EmbeddedResource.ReadStream("employees.pdf")!; diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step06_AzureAIAgent_OpenAPI.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step06_AzureAIAgent_OpenAPI.cs index 1453c4db0525..194ae7b1638d 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step06_AzureAIAgent_OpenAPI.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step06_AzureAIAgent_OpenAPI.cs @@ -17,7 +17,7 @@ namespace GettingStarted.AzureAgents; public class Step06_AzureAIAgent_OpenAPI(ITestOutputHelper output) : BaseAzureAgentTest(output) { [Fact] - public async Task UseOpenAPIToolWithAgentAsync() + public async Task UseOpenAPIToolWithAgent() { // Retrieve Open API specifications string apiCountries = EmbeddedResource.Read("countries.json"); diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step07_AzureAIAgent_Functions.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step07_AzureAIAgent_Functions.cs index 6a993bf3b753..6eb018aecbe2 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step07_AzureAIAgent_Functions.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step07_AzureAIAgent_Functions.cs @@ -18,7 +18,7 @@ public class Step07_AzureAIAgent_Functions(ITestOutputHelper output) : BaseAzure private const string HostInstructions = "Answer questions about the menu."; [Fact] - public async Task UseSingleAgentWithFunctionToolsAsync() + public async Task UseSingleAgentWithFunctionTools() { // Define the agent // In this sample the function tools are added to the agent this is diff --git a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs index b0ee0d003c93..6db4c1d0534a 100644 --- a/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs +++ b/dotnet/samples/GettingStartedWithAgents/AzureAIAgent/Step08_AzureAIAgent_Declarative.cs @@ -21,7 +21,7 @@ public class Step08_AzureAIAgent_Declarative : BaseAzureAgentTest /// Demonstrates creating and using a Chat Completion Agent with a Kernel. /// [Fact] - public async Task AzureAIAgentWithConfigurationAsync() + public async Task AzureAIAgentWithConfiguration() { var text = """ @@ -47,7 +47,7 @@ public async Task AzureAIAgentWithConfigurationAsync() } [Fact] - public async Task AzureAIAgentWithKernelAsync() + public async Task AzureAIAgentWithKernel() { var text = """ @@ -67,7 +67,7 @@ public async Task AzureAIAgentWithKernelAsync() } [Fact] - public async Task AzureAIAgentWithCodeInterpreterAsync() + public async Task AzureAIAgentWithCodeInterpreter() { var text = """ @@ -89,7 +89,7 @@ public async Task AzureAIAgentWithCodeInterpreterAsync() } [Fact] - public async Task AzureAIAgentWithFunctionsAsync() + public async Task AzureAIAgentWithFunctions() { var text = """ @@ -127,7 +127,7 @@ public async Task AzureAIAgentWithFunctionsAsync() } [Fact] - public async Task AzureAIAgentWithBingGroundingAsync() + public async Task AzureAIAgentWithBingGrounding() { var text = $""" @@ -157,7 +157,7 @@ public async Task AzureAIAgentWithBingGroundingAsync() } [Fact] - public async Task AzureAIAgentWithFileSearchAsync() + public async Task AzureAIAgentWithFileSearch() { var text = $""" @@ -188,7 +188,7 @@ public async Task AzureAIAgentWithFileSearchAsync() } [Fact] - public async Task AzureAIAgentWithOpenAPIAsync() + public async Task AzureAIAgentWithOpenAPI() { var text = """ @@ -278,7 +278,7 @@ public async Task AzureAIAgentWithOpenAPIAsync() } [Fact] - public async Task AzureAIAgentWithOpenAPIYamlAsync() + public async Task AzureAIAgentWithOpenAPIYaml() { var text = """ @@ -345,7 +345,7 @@ public async Task AzureAIAgentWithOpenAPIYamlAsync() } [Fact] - public async Task AzureAIAgentWithTemplateAsync() + public async Task AzureAIAgentWithTemplate() { var text = """ @@ -365,7 +365,8 @@ public async Task AzureAIAgentWithTemplateAsync() required: true default: 2 outputs: - - description: output1 description + output1: + description: output1 description template: format: semantic-kernel """; diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step01_BedrockAgent.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step01_BedrockAgent.cs index 2c636bc417a2..a3e1f3bf8c45 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step01_BedrockAgent.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step01_BedrockAgent.cs @@ -19,7 +19,7 @@ public class Step01_BedrockAgent(ITestOutputHelper output) : BaseBedrockAgentTes /// The agent will respond to the user query. /// [Fact] - public async Task UseNewAgentAsync() + public async Task UseNewAgent() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step01_BedrockAgent"); @@ -46,7 +46,7 @@ public async Task UseNewAgentAsync() /// The agent will respond to the user query. /// [Fact] - public async Task UseExistingAgentAsync() + public async Task UseExistingAgent() { // Retrieve the agent // Replace "bedrock-agent-id" with the ID of the agent you want to use @@ -75,7 +75,7 @@ public async Task UseExistingAgentAsync() /// The agent will respond to the user query. /// [Fact] - public async Task UseNewAgentStreamingAsync() + public async Task UseNewAgentStreaming() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step01_BedrockAgent_Streaming"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step02_BedrockAgent_CodeInterpreter.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step02_BedrockAgent_CodeInterpreter.cs index bb0c72f66f7b..fea8f312d1d8 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step02_BedrockAgent_CodeInterpreter.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step02_BedrockAgent_CodeInterpreter.cs @@ -26,7 +26,7 @@ Monkey 6 /// The output of the code interpreter will be a file containing the bar chart, which will be returned to the user. /// [Fact] - public async Task UseAgentWithCodeInterpreterAsync() + public async Task UseAgentWithCodeInterpreter() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step02_BedrockAgent_CodeInterpreter"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step03_BedrockAgent_Functions.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step03_BedrockAgent_Functions.cs index 469800161d9d..be11edba4435 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step03_BedrockAgent_Functions.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step03_BedrockAgent_Functions.cs @@ -17,7 +17,7 @@ public class Step03_BedrockAgent_Functions(ITestOutputHelper output) : BaseBedro /// The agent will respond to the user query by calling kernel functions to provide weather information. /// [Fact] - public async Task UseAgentWithFunctionsAsync() + public async Task UseAgentWithFunctions() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step03_BedrockAgent_Functions"); @@ -48,7 +48,7 @@ public async Task UseAgentWithFunctionsAsync() /// information about the menu. /// [Fact] - public async Task UseAgentWithFunctionsComplexTypeAsync() + public async Task UseAgentWithFunctionsComplexType() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step03_BedrockAgent_Functions_Complex_Types"); @@ -78,7 +78,7 @@ public async Task UseAgentWithFunctionsComplexTypeAsync() /// The agent will respond to the user query by calling kernel functions to provide weather information. /// [Fact] - public async Task UseAgentStreamingWithFunctionsAsync() + public async Task UseAgentStreamingWithFunctions() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step03_BedrockAgent_Functions_Streaming"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step04_BedrockAgent_Trace.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step04_BedrockAgent_Trace.cs index 872b860a217b..0c8ae73cd5bf 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step04_BedrockAgent_Trace.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step04_BedrockAgent_Trace.cs @@ -20,7 +20,7 @@ public class Step04_BedrockAgent_Trace(ITestOutputHelper output) : BaseBedrockAg /// Demonstrates how to inspect the thought process of a by enabling trace. /// [Fact] - public async Task UseAgentWithTraceAsync() + public async Task UseAgentWithTrace() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step04_BedrockAgent_Trace"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step05_BedrockAgent_FileSearch.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step05_BedrockAgent_FileSearch.cs index 6d0b897bf731..496bfda4fabc 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step05_BedrockAgent_FileSearch.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step05_BedrockAgent_FileSearch.cs @@ -39,7 +39,7 @@ await bedrockAgent.AssociateAgentKnowledgeBaseAsync( /// Demonstrates how to use a with file search. /// [Fact(Skip = "This test is skipped because it requires a valid KnowledgeBaseId.")] - public async Task UseAgentWithFileSearchAsync() + public async Task UseAgentWithFileSearch() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step05_BedrockAgent_FileSearch"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step06_BedrockAgent_AgentChat.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step06_BedrockAgent_AgentChat.cs index 81bbe846786d..e06a337f992c 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step06_BedrockAgent_AgentChat.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step06_BedrockAgent_AgentChat.cs @@ -26,7 +26,7 @@ protected override async Task CreateAgentAsync(string agentName) /// Demonstrates how to put two instances in a chat. /// [Fact] - public async Task UseAgentWithAgentChatAsync() + public async Task UseAgentWithAgentChat() { // Create the agent var bedrockAgent = await this.CreateAgentAsync("Step06_BedrockAgent_AgentChat"); diff --git a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step07_BedrockAgent_Declarative.cs b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step07_BedrockAgent_Declarative.cs index 63a19ea1b01f..68f1f46ee1f1 100644 --- a/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step07_BedrockAgent_Declarative.cs +++ b/dotnet/samples/GettingStartedWithAgents/BedrockAgent/Step07_BedrockAgent_Declarative.cs @@ -18,7 +18,7 @@ public class Step07_BedrockAgent_Declarative : BaseBedrockAgentTest /// Demonstrates creating and using a Bedrock Agent with using configuration settings. /// [Fact] - public async Task BedrockAgentWithConfigurationAsync() + public async Task BedrockAgentWithConfiguration() { var text = """ @@ -43,7 +43,7 @@ public async Task BedrockAgentWithConfigurationAsync() /// Demonstrates creating and using a Bedrock Agent with a code interpreter. /// [Fact] - public async Task BedrockAgentWithCodeInterpreterAsync() + public async Task BedrockAgentWithCodeInterpreter() { var text = """ @@ -70,7 +70,7 @@ public async Task BedrockAgentWithCodeInterpreterAsync() /// Demonstrates creating and using a Bedrock Agent with functions. /// [Fact] - public async Task BedrockAgentWithFunctionsAsync() + public async Task BedrockAgentWithFunctions() { var text = """ @@ -117,7 +117,7 @@ public async Task BedrockAgentWithFunctionsAsync() /// Demonstrates creating and using a Bedrock Agent with a knowledge base. /// [Fact] - public async Task BedrockAgentWithKnowledgeBaseAsync() + public async Task BedrockAgentWithKnowledgeBase() { var text = """ diff --git a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj index 23bda38be4a9..9b6a55ea36cf 100644 --- a/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj +++ b/dotnet/samples/GettingStartedWithAgents/GettingStartedWithAgents.csproj @@ -9,7 +9,7 @@ true - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001,IDE1006 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101,SKEXP0110,OPENAI001 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step01_Assistant.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step01_Assistant.cs index e245381c4248..b18dab91c1ed 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step01_Assistant.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step01_Assistant.cs @@ -13,7 +13,7 @@ namespace GettingStarted.OpenAIAssistants; public class Step01_Assistant(ITestOutputHelper output) : BaseAssistantTest(output) { [Fact] - public async Task UseTemplateForAssistantAgentAsync() + public async Task UseTemplateForAssistantAgent() { // Define the agent string generateStoryYaml = EmbeddedResource.Read("GenerateStory.yaml"); diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step02_Assistant_Plugins.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step02_Assistant_Plugins.cs index 5705c46d7d6b..ce3b0c772633 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step02_Assistant_Plugins.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step02_Assistant_Plugins.cs @@ -15,7 +15,7 @@ namespace GettingStarted.OpenAIAssistants; public class Step02_Assistant_Plugins(ITestOutputHelper output) : BaseAssistantTest(output) { [Fact] - public async Task UseAssistantWithPluginAsync() + public async Task UseAssistantWithPlugin() { // Define the agent OpenAIAssistantAgent agent = await CreateAssistantAgentAsync( @@ -42,7 +42,7 @@ public async Task UseAssistantWithPluginAsync() } [Fact] - public async Task UseAssistantWithPluginEnumParameterAsync() + public async Task UseAssistantWithPluginEnumParameter() { // Define the agent OpenAIAssistantAgent agent = await CreateAssistantAgentAsync(plugin: KernelPluginFactory.CreateFromType()); diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step03_Assistant_Vision.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step03_Assistant_Vision.cs index 85dbc9bedbe1..74b2273f0582 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step03_Assistant_Vision.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step03_Assistant_Vision.cs @@ -19,7 +19,7 @@ public class Step03_Assistant_Vision(ITestOutputHelper output) : BaseAssistantTe protected override bool ForceOpenAI => true; [Fact] - public async Task UseImageContentWithAssistantAsync() + public async Task UseImageContentWithAssistant() { // Define the assistant Assistant assistant = diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step04_AssistantTool_CodeInterpreter.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step04_AssistantTool_CodeInterpreter.cs index 5d5c7a324ee2..6f7598900522 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step04_AssistantTool_CodeInterpreter.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step04_AssistantTool_CodeInterpreter.cs @@ -13,7 +13,7 @@ namespace GettingStarted.OpenAIAssistants; public class Step04_AssistantTool_CodeInterpreter(ITestOutputHelper output) : BaseAssistantTest(output) { [Fact] - public async Task UseCodeInterpreterToolWithAssistantAgentAsync() + public async Task UseCodeInterpreterToolWithAssistantAgent() { // Define the assistant Assistant assistant = diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_FileSearch.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_FileSearch.cs index 51b8862ae918..8e9a9cd2d77d 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_FileSearch.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_FileSearch.cs @@ -15,7 +15,7 @@ namespace GettingStarted.OpenAIAssistants; public class Step05_AssistantTool_FileSearch(ITestOutputHelper output) : BaseAssistantTest(output) { [Fact] - public async Task UseFileSearchToolWithAssistantAgentAsync() + public async Task UseFileSearchToolWithAssistantAgent() { // Define the assistant Assistant assistant = diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step06_AssistantTool_Function.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step06_AssistantTool_Function.cs index d1f7b262e119..90a8bb78831e 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step06_AssistantTool_Function.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step06_AssistantTool_Function.cs @@ -19,7 +19,7 @@ public class Step06_AssistantTool_Function(ITestOutputHelper output) : BaseAssis private const string HostInstructions = "Answer questions about the menu."; [Fact] - public async Task UseSingleAssistantWithFunctionToolsAsync() + public async Task UseSingleAssistantWithFunctionTools() { // Define the agent AssistantCreationOptions creationOptions = diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step07_Assistant_Declarative.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step07_Assistant_Declarative.cs index 6482d2232bf0..9bce55ea9dd9 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step07_Assistant_Declarative.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step07_Assistant_Declarative.cs @@ -19,7 +19,7 @@ public class Step07_Assistant_Declarative : BaseAssistantTest /// Demonstrates creating and using a OpenAI Assistant using configuration. /// [Fact] - public async Task OpenAIAssistantAgentWithConfigurationForOpenAIAsync() + public async Task OpenAIAssistantAgentWithConfigurationForOpenAI() { var text = """ @@ -44,7 +44,7 @@ public async Task OpenAIAssistantAgentWithConfigurationForOpenAIAsync() /// Demonstrates creating and using a OpenAI Assistant using configuration for Azure OpenAI. /// [Fact] - public async Task OpenAIAssistantAgentWithConfigurationForAzureOpenAIAsync() + public async Task OpenAIAssistantAgentWithConfigurationForAzureOpenAI() { var text = """ @@ -73,7 +73,7 @@ public async Task OpenAIAssistantAgentWithConfigurationForAzureOpenAIAsync() /// Demonstrates creating and using a OpenAI Assistant using a Kernel. /// [Fact] - public async Task OpenAIAssistantAgentWithKernelAsync() + public async Task OpenAIAssistantAgentWithKernel() { var text = """ @@ -95,7 +95,7 @@ public async Task OpenAIAssistantAgentWithKernelAsync() /// Demonstrates creating and using a OpenAI Assistant with templated instructions. /// [Fact] - public async Task OpenAIAssistantAgentWithTemplateAsync() + public async Task OpenAIAssistantAgentWithTemplate() { var text = """ @@ -115,7 +115,8 @@ public async Task OpenAIAssistantAgentWithTemplateAsync() required: true default: 2 outputs: - - description: output1 description + output1: + description: output1 description template: format: semantic-kernel """; diff --git a/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs b/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs index 268bef9d8e6e..e62b869c0af1 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step01_Agent.cs @@ -62,7 +62,7 @@ async Task InvokeAgentAsync(string input) /// Demonstrate the usage of where a conversation history is maintained. /// [Fact] - public async Task UseSingleChatCompletionAgentWithConversationAsync() + public async Task UseSingleChatCompletionAgentWithConversation() { Kernel kernel = this.CreateKernelWithChatCompletion(); @@ -103,7 +103,7 @@ async Task InvokeAgentAsync(string input) /// and where the thread containing the conversation is created manually. /// [Fact] - public async Task UseSingleChatCompletionAgentWithManuallyCreatedThreadAsync() + public async Task UseSingleChatCompletionAgentWithManuallyCreatedThread() { Kernel kernel = this.CreateKernelWithChatCompletion(); diff --git a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs index 39106b957841..92188d298594 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step06_DependencyInjection.cs @@ -32,7 +32,7 @@ Think step-by-step and rate the user input on creativity and expressiveness from [Theory] [InlineData(true)] [InlineData(false)] - public async Task UseDependencyInjectionToCreateAgentAsync(bool useChatClient) + public async Task UseDependencyInjectionToCreateAgent(bool useChatClient) { ServiceCollection serviceContainer = new(); diff --git a/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs b/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs index 658f5a4e091c..0fa19b93cbf1 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step07_Telemetry.cs @@ -34,7 +34,7 @@ public class Step07_Telemetry(ITestOutputHelper output) : BaseAssistantTest(outp [Theory] [InlineData(true)] [InlineData(false)] - public async Task LoggingAsync(bool useChatClient) + public async Task Logging(bool useChatClient) { await RunExampleAsync(loggerFactory: this.LoggerFactory, useChatClient: useChatClient); @@ -61,7 +61,7 @@ public async Task LoggingAsync(bool useChatClient) [InlineData(false, false, true)] [InlineData(true, true, true)] [InlineData(false, true, true)] - public async Task TracingAsync(bool useApplicationInsights, bool useStreaming, bool useChatClient) + public async Task Tracing(bool useApplicationInsights, bool useStreaming, bool useChatClient) { using var tracerProvider = GetTracerProvider(useApplicationInsights); diff --git a/dotnet/samples/GettingStartedWithAgents/Step08_AgentAsKernelFunction.cs b/dotnet/samples/GettingStartedWithAgents/Step08_AgentAsKernelFunction.cs index e4775f4555d1..638a0e75e443 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step08_AgentAsKernelFunction.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step08_AgentAsKernelFunction.cs @@ -15,7 +15,7 @@ public class Step08_AgentAsKernelFunction(ITestOutputHelper output) : BaseAgents protected override bool ForceOpenAI { get; } = true; [Fact] - public async Task SalesAssistantAgentAsync() + public async Task SalesAssistantAgent() { Kernel kernel = this.CreateKernelWithChatCompletion(); kernel.Plugins.AddFromType(); @@ -40,7 +40,7 @@ public async Task SalesAssistantAgentAsync() } [Fact] - public async Task RefundAgentAsync() + public async Task RefundAgent() { Kernel kernel = this.CreateKernelWithChatCompletion(); kernel.Plugins.AddFromType(); @@ -65,7 +65,7 @@ public async Task RefundAgentAsync() } [Fact] - public async Task MultipleAgentsAsync() + public async Task MultipleAgents() { Kernel kernel = this.CreateKernelWithChatCompletion(); var agentPlugin = KernelPluginFactory.CreateFromFunctions("AgentPlugin", diff --git a/dotnet/samples/GettingStartedWithAgents/Step09_Declarative.cs b/dotnet/samples/GettingStartedWithAgents/Step09_Declarative.cs index 4b49c97f840c..873c83fe56e6 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step09_Declarative.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step09_Declarative.cs @@ -16,7 +16,7 @@ public class Step09_Declarative(ITestOutputHelper output) : BaseAgentsTest(outpu /// Demonstrates creating and using a Chat Completion Agent with a Kernel. /// [Fact] - public async Task ChatCompletionAgentWithKernelAsync() + public async Task ChatCompletionAgentWithKernel() { Kernel kernel = this.CreateKernelWithChatCompletion(); @@ -41,7 +41,7 @@ public async Task ChatCompletionAgentWithKernelAsync() /// Demonstrates creating and using a Chat Completion Agent with functions. /// [Fact] - public async Task ChatCompletionAgentWithFunctionsAsync() + public async Task ChatCompletionAgentWithFunctions() { Kernel kernel = this.CreateKernelWithChatCompletion(); KernelPlugin plugin = KernelPluginFactory.CreateFromType(); @@ -76,7 +76,7 @@ public async Task ChatCompletionAgentWithFunctionsAsync() /// Demonstrates creating and using a Chat Completion Agent with templated instructions. /// [Fact] - public async Task ChatCompletionAgentWithTemplateAsync() + public async Task ChatCompletionAgentWithTemplate() { Kernel kernel = this.CreateKernelWithChatCompletion(); @@ -96,7 +96,8 @@ public async Task ChatCompletionAgentWithTemplateAsync() required: true default: 2 outputs: - - description: output1 description + output1: + description: output1 description template: format: semantic-kernel """; diff --git a/dotnet/samples/GettingStartedWithAgents/Step10_MultiAgent_Declarative.cs b/dotnet/samples/GettingStartedWithAgents/Step10_MultiAgent_Declarative.cs index df4f96233cdd..03d13cb3c7e0 100644 --- a/dotnet/samples/GettingStartedWithAgents/Step10_MultiAgent_Declarative.cs +++ b/dotnet/samples/GettingStartedWithAgents/Step10_MultiAgent_Declarative.cs @@ -22,7 +22,7 @@ public class Step10_MultiAgent_Declarative : BaseAgentsTest /// Demonstrates creating and using a Chat Completion Agent with a Kernel. /// [Fact] - public async Task ChatCompletionAgentWithKernelAsync() + public async Task ChatCompletionAgentWithKernel() { Kernel kernel = this.CreateKernelWithChatCompletion(); @@ -46,7 +46,7 @@ public async Task ChatCompletionAgentWithKernelAsync() /// Demonstrates creating and using an Azure AI Agent with a Kernel. /// [Fact] - public async Task AzureAIAgentWithKernelAsync() + public async Task AzureAIAgentWithKernel() { var text = """ diff --git a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj index bd9433aec82d..ecf78b4050b9 100644 --- a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj +++ b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj @@ -10,7 +10,7 @@ - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0080,SKEXP0101,SKEXP0110,OPENAI001 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0080,SKEXP0101,SKEXP0110,OPENAI001 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj b/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj index 29e91554d092..8ace8365812c 100644 --- a/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj +++ b/dotnet/samples/GettingStartedWithTextSearch/GettingStartedWithTextSearch.csproj @@ -7,7 +7,7 @@ true false - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj b/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj index e07f4f979be1..96c1aee3641d 100644 --- a/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj +++ b/dotnet/samples/GettingStartedWithVectorStores/GettingStartedWithVectorStores.csproj @@ -7,7 +7,7 @@ true false - $(NoWarn);CS8618,IDE0009,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 + $(NoWarn);CS8618,IDE0009,IDE1006,CA1051,CA1050,CA1707,CA1054,CA2007,VSTHRD111,CS1591,RCS1110,RCS1243,CA5394,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0101 Library 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 diff --git a/dotnet/src/Agents/Abstractions/Definition/AgentDefinition.cs b/dotnet/src/Agents/Abstractions/Definition/AgentDefinition.cs index 9f49b30a71c3..de1f816ab9e4 100644 --- a/dotnet/src/Agents/Abstractions/Definition/AgentDefinition.cs +++ b/dotnet/src/Agents/Abstractions/Definition/AgentDefinition.cs @@ -59,7 +59,7 @@ public sealed class AgentDefinition /// /// Gets or sets the collection of outputs supported by the agent. /// - public IList? Outputs { get; set; } + public IDictionary? Outputs { get; set; } /// /// Gets or sets the template options used by the agent. diff --git a/dotnet/src/Agents/Abstractions/Definition/AgentOutput.cs b/dotnet/src/Agents/Abstractions/Definition/AgentOutput.cs index 95935edb9639..86231a0eea4a 100644 --- a/dotnet/src/Agents/Abstractions/Definition/AgentOutput.cs +++ b/dotnet/src/Agents/Abstractions/Definition/AgentOutput.cs @@ -23,5 +23,5 @@ public sealed class AgentOutput /// /// Gets or sets JSON Schema describing this output. /// - public string? Schema { get; set; } + public string? JsonSchema { get; set; } } diff --git a/dotnet/src/Agents/UnitTests/Yaml/AgentDefinitionYamlTests.cs b/dotnet/src/Agents/UnitTests/Yaml/AgentDefinitionYamlTests.cs index c5f25c902b74..b250ad2b58b1 100644 --- a/dotnet/src/Agents/UnitTests/Yaml/AgentDefinitionYamlTests.cs +++ b/dotnet/src/Agents/UnitTests/Yaml/AgentDefinitionYamlTests.cs @@ -57,7 +57,8 @@ public void VerifyAgentDefinitionFromYaml() default: input2 default sample: input2 sample outputs: - - description: output1 description + output1: + description: output1 description template: format: liquid parser: semantic-kernel diff --git a/dotnet/src/Agents/UnitTests/Yaml/AgentYamlTests.cs b/dotnet/src/Agents/UnitTests/Yaml/AgentYamlTests.cs index d8ba153c707c..13e67933744b 100644 --- a/dotnet/src/Agents/UnitTests/Yaml/AgentYamlTests.cs +++ b/dotnet/src/Agents/UnitTests/Yaml/AgentYamlTests.cs @@ -100,7 +100,8 @@ public void VerifyAgentDefinitionFromYaml() default: input2 default sample: input2 sample outputs: - - description: output1 description + output1: + description: output1 description template: format: liquid parser: semantic-kernel diff --git a/dotnet/src/Agents/Yaml/AgentDefinitionYaml.cs b/dotnet/src/Agents/Yaml/AgentDefinitionYaml.cs index 7d621cdc753c..6d3c10820aea 100644 --- a/dotnet/src/Agents/Yaml/AgentDefinitionYaml.cs +++ b/dotnet/src/Agents/Yaml/AgentDefinitionYaml.cs @@ -59,6 +59,14 @@ public static AgentDefinition Normalize(AgentDefinition agentDefinition, IConfig } } + if (agentDefinition?.Outputs is not null) + { + foreach (var keyValuePair in agentDefinition.Outputs) + { + keyValuePair.Value.Name = keyValuePair.Key; + } + } + if (configuration is not null) { agentDefinition!.Normalize(configuration); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientCoreTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientCoreTests.cs index 6024a1c38a8f..63247188c488 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientCoreTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/ClientCoreTests.cs @@ -9,7 +9,6 @@ using Azure.Core; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.AzureOpenAI; -using SemanticKernel.Connectors.AzureOpenAI.Core; namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; @@ -46,9 +45,6 @@ public async Task AuthenticationHeaderShouldBeProvidedOnlyOnce() NetworkTimeout = TimeSpan.FromSeconds(10), }; - // Bug fix workaround - options.AddPolicy(new SingleAuthorizationHeaderPolicy(), PipelinePosition.PerTry); - var azureClient = new AzureOpenAIClient( endpoint: new Uri("http://any"), credential: new TestJWTBearerTokenCredential(), diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs index 30f2a417e0df..bda194225ccd 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs @@ -15,7 +15,6 @@ using Microsoft.SemanticKernel.Connectors.OpenAI; using Microsoft.SemanticKernel.Http; using OpenAI; -using SemanticKernel.Connectors.AzureOpenAI.Core; namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; @@ -153,7 +152,6 @@ internal static AzureOpenAIClientOptions GetAzureOpenAIClientOptions(HttpClient? options.UserAgentApplicationId = HttpHeaderConstant.Values.UserAgent; options.AddPolicy(CreateRequestHeaderPolicy(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(AzureClientCore))), PipelinePosition.PerCall); - options.AddPolicy(new SingleAuthorizationHeaderPolicy(), PipelinePosition.PerTry); if (httpClient is not null) { diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/SingleAuthorizationHeaderPolicy.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/SingleAuthorizationHeaderPolicy.cs deleted file mode 100644 index e2b9804242be..000000000000 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/SingleAuthorizationHeaderPolicy.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SemanticKernel.Connectors.AzureOpenAI.Core; - -/// -/// This class is used to remove duplicate Authorization headers from the request Azure OpenAI Bug. -/// https://github.com/Azure/azure-sdk-for-net/issues/46109 (Remove when beta.2 is released) -/// -internal sealed class SingleAuthorizationHeaderPolicy : PipelinePolicy -{ - private const string AuthorizationHeaderName = "Authorization"; - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RemoveDuplicateHeaderValues(message.Request.Headers); - - ProcessNext(message, pipeline, currentIndex); - } - - public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - RemoveDuplicateHeaderValues(message.Request.Headers); - - await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); - } - - private static void RemoveDuplicateHeaderValues(PipelineRequestHeaders headers) - { - if (headers.TryGetValues(AuthorizationHeaderName, out var headerValues) - && headerValues is not null -#if NET - && headerValues.TryGetNonEnumeratedCount(out var count) && count > 1 -#else - && headerValues.Count() > 1 -#endif - ) - { - headers.Set(AuthorizationHeaderName, headerValues.First()); - } - } -} 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 3cbf9973ccbb..4499b9afca37 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/GeminiRequestTests.cs @@ -5,11 +5,13 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.Google; using Microsoft.SemanticKernel.Connectors.Google.Core; using Xunit; +using TextContent = Microsoft.SemanticKernel.TextContent; namespace SemanticKernel.Connectors.Google.UnitTests.Core.Gemini; @@ -39,7 +41,7 @@ public void FromPromptItReturnsWithConfiguration() Assert.Equal(executionSettings.MaxTokens, request.Configuration.MaxOutputTokens); Assert.Equal(executionSettings.AudioTimestamp, request.Configuration.AudioTimestamp); Assert.Equal(executionSettings.ResponseMimeType, request.Configuration.ResponseMimeType); - Assert.Equal(executionSettings.ResponseSchema, request.Configuration.ResponseSchema); + Assert.Equal(executionSettings.ResponseSchema.ToString(), request.Configuration.ResponseSchema.ToString()); Assert.Equal(executionSettings.TopP, request.Configuration.TopP); } @@ -51,7 +53,7 @@ public void JsonElementResponseSchemaFromPromptReturnsAsExpected() var executionSettings = new GeminiPromptExecutionSettings { ResponseMimeType = "application/json", - ResponseSchema = Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema(typeof(int)) + ResponseSchema = Microsoft.Extensions.AI.AIJsonUtilities.CreateJsonSchema(typeof(int), serializerOptions: GeminiRequest.GetDefaultOptions()) }; // Act @@ -59,8 +61,11 @@ public void JsonElementResponseSchemaFromPromptReturnsAsExpected() // Assert Assert.NotNull(request.Configuration); + Assert.NotNull(request.Configuration.ResponseSchema); Assert.Equal(executionSettings.ResponseMimeType, request.Configuration.ResponseMimeType); - Assert.Equal(executionSettings.ResponseSchema, request.Configuration.ResponseSchema); + var settingsSchema = Assert.IsType(executionSettings.ResponseSchema); + + AssertDeepEquals(settingsSchema, request.Configuration.ResponseSchema.Value); } [Fact] @@ -79,8 +84,9 @@ public void KernelJsonSchemaFromPromptReturnsAsExpected() // Assert Assert.NotNull(request.Configuration); + Assert.NotNull(request.Configuration.ResponseSchema); Assert.Equal(executionSettings.ResponseMimeType, request.Configuration.ResponseMimeType); - Assert.Equal(((KernelJsonSchema)executionSettings.ResponseSchema).RootElement, request.Configuration.ResponseSchema); + AssertDeepEquals(((KernelJsonSchema)executionSettings.ResponseSchema).RootElement, request.Configuration.ResponseSchema.Value); } [Fact] @@ -535,6 +541,73 @@ public void CachedContentFromChatHistoryReturnsAsExpected() Assert.Equal(executionSettings.CachedContent, request.CachedContent); } + [Fact] + public void ResponseSchemaConvertsNullableTypesToOpenApiFormat() + { + // Arrange + var prompt = "prompt-example"; + var schemaWithNullableArray = """ + { + "type": "object", + "properties": { + "name": { + "type": ["string", "null"], + "description": "user name" + }, + "age": { + "type": ["integer", "null"], + "description": "user age" + } + } + } + """; + + var executionSettings = new GeminiPromptExecutionSettings + { + ResponseMimeType = "application/json", + ResponseSchema = JsonSerializer.Deserialize(schemaWithNullableArray) + }; + + // Act + var request = GeminiRequest.FromPromptAndExecutionSettings(prompt, executionSettings); + + // Assert + Assert.NotNull(request.Configuration?.ResponseSchema); + var properties = request.Configuration.ResponseSchema.Value.GetProperty("properties"); + + var nameProperty = properties.GetProperty("name"); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.True(nameProperty.GetProperty("nullable").GetBoolean()); + + var ageProperty = properties.GetProperty("age"); + Assert.Equal("integer", ageProperty.GetProperty("type").GetString()); + Assert.True(ageProperty.GetProperty("nullable").GetBoolean()); + } + private sealed class DummyContent(object? innerContent, string? modelId = null, IReadOnlyDictionary? metadata = null) : KernelContent(innerContent, modelId, metadata); + + private static bool DeepEquals(JsonElement element1, JsonElement element2) + { +#if NET9_0_OR_GREATER + return JsonElement.DeepEquals(element1, element2); +#else + return JsonNode.DeepEquals( + JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); +#endif + } + + private static void AssertDeepEquals(JsonElement element1, JsonElement element2) + { +#pragma warning disable SA1118 // Parameter should not span multiple lines + Assert.True(DeepEquals(element1, element2), $""" + Elements are not equal. + Expected: + {element1} + Actual: + {element2} + """); +#pragma warning restore SA1118 // Parameter should not span multiple lines + } } 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 787e369d7d25..0bf47bd27f9b 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Models/GeminiRequest.cs @@ -326,9 +326,60 @@ private static void AddConfiguration(GeminiPromptExecutionSettings executionSett _ => CreateSchema(responseSchemaSettings.GetType(), GetDefaultOptions()) }; + jsonElement = AdjustOpenApi3Nullables(jsonElement); return jsonElement; } + /// + /// Adjusts the schema to conform to OpenAPI 3.0 nullable format by converting properties with type arrays + /// containing "null" (e.g., ["string", "null"]) to use the "nullable" keyword instead (e.g., { "type": "string", "nullable": true }). + /// + /// The JSON schema to be transformed. + /// A new JsonElement with the adjusted schema format. + /// + /// This method recursively processes all nested objects in the schema. For each property that has a type array + /// containing "null", it: + /// - Extracts the main type (non-null value) + /// - Replaces the type array with a single type value + /// - Adds "nullable": true as a property + /// + private static JsonElement AdjustOpenApi3Nullables(JsonElement jsonElement) + { + JsonNode? node = JsonNode.Parse(jsonElement.GetRawText()); + if (node is JsonObject rootObject) + { + AdjustOpenApi3Object(rootObject); + } + + return JsonSerializer.SerializeToElement(node, GetDefaultOptions()); + + static void AdjustOpenApi3Object(JsonObject obj) + { + if (obj.TryGetPropertyValue("properties", out JsonNode? propsNode) && propsNode is JsonObject properties) + { + foreach (var property in properties) + { + if (property.Value is JsonObject propertyObj) + { + if (propertyObj.TryGetPropertyValue("type", out JsonNode? typeNode) && typeNode is JsonArray typeArray) + { + var types = typeArray.Select(t => t?.GetValue()).Where(t => t != null).ToList(); + if (types.Contains("null")) + { + var mainType = types.First(t => t != "null"); + propertyObj["type"] = JsonValue.Create(mainType); + propertyObj["nullable"] = JsonValue.Create(true); + } + } + + // Recursively process nested objects + AdjustOpenApi3Object(propertyObj); + } + } + } + } + } + private static JsonElement CreateSchema( Type type, JsonSerializerOptions options, @@ -339,7 +390,7 @@ private static JsonElement CreateSchema( return AIJsonUtilities.CreateJsonSchema(type, description, serializerOptions: options, inferenceOptions: configuration); } - private static JsonSerializerOptions GetDefaultOptions() + internal static JsonSerializerOptions GetDefaultOptions() { if (s_options is null) { diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionCreateMapping.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionCreateMapping.cs index ece189e61d75..d2b5d1c55cab 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionCreateMapping.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionCreateMapping.cs @@ -40,11 +40,9 @@ internal static class QdrantVectorStoreCollectionCreateMapping { typeof(decimal?), PayloadSchemaType.Float }, { typeof(string), PayloadSchemaType.Keyword }, - { typeof(DateTime), PayloadSchemaType.Datetime }, { typeof(DateTimeOffset), PayloadSchemaType.Datetime }, { typeof(bool), PayloadSchemaType.Bool }, - { typeof(DateTime?), PayloadSchemaType.Datetime }, { typeof(DateTimeOffset?), PayloadSchemaType.Datetime }, { typeof(bool?), PayloadSchemaType.Bool }, }; diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionSearchMapping.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionSearchMapping.cs index 15a95467672f..7151cc5fbf0a 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionSearchMapping.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreCollectionSearchMapping.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Extensions.VectorData; using Qdrant.Client.Grpc; @@ -52,13 +53,9 @@ public static Filter BuildFromLegacyFilter(VectorSearchFilter basicVectorSearchF throw new InvalidOperationException($"Property name '{fieldName}' provided as part of the filter clause is not a valid property name."); } - // Map datetime equality. - if (filterValue is DateTime or DateTimeOffset) + // Map DateTimeOffset equality. + if (filterValue is DateTimeOffset dateTimeOffset) { - var dateTimeOffset = filterValue is DateTime dateTime - ? new DateTimeOffset(dateTime, TimeSpan.Zero) - : (DateTimeOffset)filterValue; - var range = new global::Qdrant.Client.Grpc.DatetimeRange { Gte = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(dateTimeOffset), @@ -103,10 +100,28 @@ public static VectorSearchResult MapScoredPointToVectorSearchResult payloadEntry in point.Payload) { pointStruct.Payload.Add(payloadEntry.Key, payloadEntry.Value); diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs index 760aeaae24f4..24cbd097f422 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordCollection.cs @@ -461,10 +461,28 @@ private async IAsyncEnumerable GetBatchByPointIdAsync( var pointStruct = new PointStruct { Id = retrievedPoint.Id, - Vectors = retrievedPoint.Vectors, Payload = { } }; + if (includeVectors) + { + pointStruct.Vectors = new(); + switch (retrievedPoint.Vectors.VectorsOptionsCase) + { + case VectorsOutput.VectorsOptionsOneofCase.Vector: + pointStruct.Vectors.Vector = retrievedPoint.Vectors.Vector.Data.ToArray(); + break; + case VectorsOutput.VectorsOptionsOneofCase.Vectors: + pointStruct.Vectors.Vectors_ = new(); + foreach (var v in retrievedPoint.Vectors.Vectors.Vectors) + { + // TODO: Refactor mapper to not require pre-mapping to pointstruct to avoid this ToArray conversion. + pointStruct.Vectors.Vectors_.Vectors.Add(v.Key, v.Value.Data.ToArray()); + } + break; + } + } + foreach (KeyValuePair payloadEntry in retrievedPoint.Payload) { pointStruct.Payload.Add(payloadEntry.Key, payloadEntry.Value); diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordFieldMapping.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordFieldMapping.cs index ba125f52edbf..7134d45f47d7 100644 --- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordFieldMapping.cs +++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordFieldMapping.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Microsoft.Extensions.VectorData; using Qdrant.Client.Grpc; @@ -23,14 +24,12 @@ internal static class QdrantVectorStoreRecordFieldMapping typeof(double), typeof(float), typeof(bool), - typeof(DateTime), typeof(DateTimeOffset), typeof(int?), typeof(long?), typeof(double?), typeof(float?), typeof(bool?), - typeof(DateTime?), typeof(DateTimeOffset?), ]; @@ -79,8 +78,7 @@ object ConvertStringValue(string stringValue) { return targetType switch { - Type t when t == typeof(DateTime) || t == typeof(DateTime?) => DateTime.Parse(payloadValue.StringValue), - Type t when t == typeof(DateTimeOffset) || t == typeof(DateTimeOffset?) => DateTimeOffset.Parse(payloadValue.StringValue), + Type t when t == typeof(DateTimeOffset) || t == typeof(DateTimeOffset?) => DateTimeOffset.Parse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), _ => stringValue, }; } @@ -123,10 +121,6 @@ public static Value ConvertToGrpcFieldValue(object? sourceValue) { value.BoolValue = boolValue; } - else if (sourceValue is DateTime datetimeValue) - { - value.StringValue = datetimeValue.ToString("O"); - } else if (sourceValue is DateTimeOffset dateTimeOffsetValue) { value.StringValue = dateTimeOffsetValue.ToString("O"); diff --git a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreCollectionSearchMappingTests.cs b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreCollectionSearchMappingTests.cs index afd5e545030a..638bc2cbf861 100644 --- a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreCollectionSearchMappingTests.cs +++ b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreCollectionSearchMappingTests.cs @@ -92,12 +92,14 @@ public void BuildFilterThrowsForUnknownFieldName() [Fact] public void MapScoredPointToVectorSearchResultMapsResults() { + var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3] }"); + // Arrange. var scoredPoint = new ScoredPoint { Id = 1, Payload = { ["storage_DataField"] = "data 1" }, - Vectors = new float[] { 1, 2, 3 }, + Vectors = new VectorsOutput() { Vector = responseVector }, Score = 0.5f }; diff --git a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordCollectionTests.cs b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordCollectionTests.cs index 3d071066ae2b..216828137953 100644 --- a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordCollectionTests.cs +++ b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordCollectionTests.cs @@ -691,15 +691,17 @@ private void SetupUpsertMock() private static RetrievedPoint CreateRetrievedPoint(bool hasNamedVectors, TKey recordKey) { + var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }"); + RetrievedPoint point; if (hasNamedVectors) { - var namedVectors = new NamedVectors(); - namedVectors.Vectors.Add("vector_storage_name", new[] { 1f, 2f, 3f, 4f }); + var namedVectors = new NamedVectorsOutput(); + namedVectors.Vectors.Add("vector_storage_name", responseVector); point = new RetrievedPoint() { Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" }, - Vectors = new Vectors { Vectors_ = namedVectors } + Vectors = new VectorsOutput { Vectors = namedVectors } }; } else @@ -707,7 +709,7 @@ private static RetrievedPoint CreateRetrievedPoint(bool hasNamedVectors, T point = new RetrievedPoint() { Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" }, - Vectors = new[] { 1f, 2f, 3f, 4f } + Vectors = new VectorsOutput() { Vector = responseVector } }; } @@ -726,16 +728,18 @@ private static RetrievedPoint CreateRetrievedPoint(bool hasNamedVectors, T private static ScoredPoint CreateScoredPoint(bool hasNamedVectors, TKey recordKey) { + var responseVector = VectorOutput.Parser.ParseJson("{ \"data\": [1, 2, 3, 4] }"); + ScoredPoint point; if (hasNamedVectors) { - var namedVectors = new NamedVectors(); - namedVectors.Vectors.Add("vector_storage_name", new[] { 1f, 2f, 3f, 4f }); + var namedVectors = new NamedVectorsOutput(); + namedVectors.Vectors.Add("vector_storage_name", responseVector); point = new ScoredPoint() { Score = 0.5f, Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" }, - Vectors = new Vectors { Vectors_ = namedVectors } + Vectors = new VectorsOutput { Vectors = namedVectors } }; } else @@ -744,7 +748,7 @@ private static ScoredPoint CreateScoredPoint(bool hasNamedVectors, TKey re { Score = 0.5f, Payload = { ["OriginalNameData"] = "data 1", ["data_storage_name"] = "data 1" }, - Vectors = new[] { 1f, 2f, 3f, 4f } + Vectors = new VectorsOutput() { Vector = responseVector } }; } diff --git a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordMapperTests.cs b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordMapperTests.cs index ad225ba7be09..29fa57ddb5d9 100644 --- a/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordMapperTests.cs +++ b/dotnet/src/Connectors/Connectors.Qdrant.UnitTests/QdrantVectorStoreRecordMapperTests.cs @@ -139,14 +139,13 @@ public void MapsMultiPropsFromDataToStorageModelWithUlong() // Assert. Assert.NotNull(actual); Assert.Equal(5ul, actual.Id.Num); - Assert.Equal(9, actual.Payload.Count); + Assert.Equal(8, actual.Payload.Count); Assert.Equal("data 1", actual.Payload["dataString"].StringValue); Assert.Equal(5, actual.Payload["dataInt"].IntegerValue); Assert.Equal(5, actual.Payload["dataLong"].IntegerValue); Assert.Equal(5.5f, actual.Payload["dataFloat"].DoubleValue); Assert.Equal(5.5d, actual.Payload["dataDouble"].DoubleValue); Assert.True(actual.Payload["dataBool"].BoolValue); - Assert.Equal("2025-02-10T05:10:15.0000000Z", actual.Payload["dataDateTime"].StringValue); Assert.Equal("2025-02-10T05:10:15.0000000+01:00", actual.Payload["dataDateTimeOffset"].StringValue); Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.Payload["dataArrayInt"].ListValue.Values.Select(x => (int)x.IntegerValue).ToArray()); Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vectors_.Vectors["vector1"].Data.ToArray()); @@ -167,14 +166,13 @@ public void MapsMultiPropsFromDataToStorageModelWithGuid() // Assert. Assert.NotNull(actual); Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), Guid.Parse(actual.Id.Uuid)); - Assert.Equal(9, actual.Payload.Count); + Assert.Equal(8, actual.Payload.Count); Assert.Equal("data 1", actual.Payload["dataString"].StringValue); Assert.Equal(5, actual.Payload["dataInt"].IntegerValue); Assert.Equal(5, actual.Payload["dataLong"].IntegerValue); Assert.Equal(5.5f, actual.Payload["dataFloat"].DoubleValue); Assert.Equal(5.5d, actual.Payload["dataDouble"].DoubleValue); Assert.True(actual.Payload["dataBool"].BoolValue); - Assert.Equal("2025-02-10T05:10:15.0000000Z", actual.Payload["dataDateTime"].StringValue); Assert.Equal("2025-02-10T05:10:15.0000000+01:00", actual.Payload["dataDateTimeOffset"].StringValue); Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.Payload["dataArrayInt"].ListValue.Values.Select(x => (int)x.IntegerValue).ToArray()); Assert.Equal(new float[] { 1, 2, 3, 4 }, actual.Vectors.Vectors_.Vectors["vector1"].Data.ToArray()); @@ -203,7 +201,6 @@ public void MapsMultiPropsFromStorageToDataModelWithUlong(bool includeVectors) Assert.Equal(5.5f, actual.DataFloat); Assert.Equal(5.5d, actual.DataDouble); Assert.True(actual.DataBool); - Assert.Equal(new DateTime(2025, 2, 10, 5, 10, 15, DateTimeKind.Utc), actual.DataDateTime); Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), actual.DataDateTimeOffset); Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.DataArrayInt); @@ -241,7 +238,6 @@ public void MapsMultiPropsFromStorageToDataModelWithGuid(bool includeVectors) Assert.Equal(5.5f, actual.DataFloat); Assert.Equal(5.5d, actual.DataDouble); Assert.True(actual.DataBool); - Assert.Equal(new DateTime(2025, 2, 10, 5, 10, 15, DateTimeKind.Utc), actual.DataDateTime); Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), actual.DataDateTimeOffset); Assert.Equal(new int[] { 1, 2, 3, 4 }, actual.DataArrayInt); @@ -279,7 +275,6 @@ private static MultiPropsModel CreateMultiPropsModel(TKey key) DataFloat = 5.5f, DataDouble = 5.5d, DataBool = true, - DataDateTime = new DateTime(2025, 2, 10, 5, 10, 15, DateTimeKind.Utc), DataDateTimeOffset = new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), DataArrayInt = new List { 1, 2, 3, 4 }, Vector1 = new float[] { 1, 2, 3, 4 }, @@ -344,7 +339,6 @@ private static void AddDataToMultiPropsPointStruct(PointStruct pointStruct) pointStruct.Payload.Add("dataFloat", 5.5f); pointStruct.Payload.Add("dataDouble", 5.5d); pointStruct.Payload.Add("dataBool", true); - pointStruct.Payload.Add("dataDateTime", "2025-02-10T05:10:15.0000000Z"); pointStruct.Payload.Add("dataDateTimeOffset", "2025-02-10T05:10:15.0000000+01:00"); var dataIntArray = new ListValue(); @@ -395,7 +389,6 @@ private sealed class SinglePropsModel new VectorStoreRecordDataProperty("DataFloat", typeof(float)) { StoragePropertyName = "dataFloat" }, new VectorStoreRecordDataProperty("DataDouble", typeof(double)) { StoragePropertyName = "dataDouble" }, new VectorStoreRecordDataProperty("DataBool", typeof(bool)) { StoragePropertyName = "dataBool" }, - new VectorStoreRecordDataProperty("DataDateTime", typeof(DateTime)) { StoragePropertyName = "dataDateTime" }, new VectorStoreRecordDataProperty("DataDateTimeOffset", typeof(DateTimeOffset)) { StoragePropertyName = "dataDateTimeOffset" }, new VectorStoreRecordDataProperty("DataArrayInt", typeof(List)) { StoragePropertyName = "dataArrayInt" }, new VectorStoreRecordVectorProperty("Vector1", typeof(ReadOnlyMemory)) { StoragePropertyName = "vector1" }, @@ -427,9 +420,6 @@ private sealed class MultiPropsModel [VectorStoreRecordData(StoragePropertyName = "dataBool")] public bool DataBool { get; set; } = false; - [VectorStoreRecordData(StoragePropertyName = "dataDateTime")] - public DateTime DataDateTime { get; set; } - [VectorStoreRecordData(StoragePropertyName = "dataDateTimeOffset")] public DateTimeOffset DataDateTimeOffset { get; set; } diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/Functions.Prompty.UnitTests.csproj b/dotnet/src/Functions/Functions.Prompty.UnitTests/Functions.Prompty.UnitTests.csproj index 77e97f711e7f..d58ba5a5c5dd 100644 --- a/dotnet/src/Functions/Functions.Prompty.UnitTests/Functions.Prompty.UnitTests.csproj +++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/Functions.Prompty.UnitTests.csproj @@ -39,4 +39,9 @@ + + + Always + + \ No newline at end of file diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTest.cs b/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTest.cs index a019f6bbfba9..aa255fe56b06 100644 --- a/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTest.cs +++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/PromptyTest.cs @@ -357,6 +357,47 @@ public void ItCreatesInputVariablesOnlyWhenNoneAreExplicitlySet() Assert.Equal(expectedVariables, kernelFunction.Metadata.Parameters.Select(p => p.Name)); } + [Fact] + public void ItShouldCreateFunctionFromPromptYamlContainingRelativeFileReferences() + { + // Arrange + Kernel kernel = new(); + var promptyPath = Path.Combine("TestData", "relativeFileReference.prompty"); + + // Act + var kernelFunction = kernel.CreateFunctionFromPromptyFile(promptyPath); + + // Assert + Assert.NotNull(kernelFunction); + var executionSettings = kernelFunction.ExecutionSettings; + Assert.Single(executionSettings!); + Assert.True(executionSettings!.ContainsKey("default")); + var defaultExecutionSetting = executionSettings["default"]; + Assert.Equal("gpt-35-turbo", defaultExecutionSetting.ModelId); + } + + [Fact] + public void ItShouldCreateFunctionFromPromptYamlContainingRelativeFileReferencesWithFileProvider() + { + // Arrange + Kernel kernel = new(); + var currentDirectory = Directory.GetCurrentDirectory(); + var promptyPath = Path.Combine("TestData", "relativeFileReference.prompty"); + using PhysicalFileProvider fileProvider = new(currentDirectory); + + // Act + var kernelFunction = kernel.CreateFunctionFromPromptyFile(promptyPath, + fileProvider); + + // Assert + Assert.NotNull(kernelFunction); + var executionSettings = kernelFunction.ExecutionSettings; + Assert.Single(executionSettings!); + Assert.True(executionSettings!.ContainsKey("default")); + var defaultExecutionSetting = executionSettings["default"]; + Assert.Equal("gpt-35-turbo", defaultExecutionSetting.ModelId); + } + private sealed class EchoTextGenerationService : ITextGenerationService { public IReadOnlyDictionary Attributes { get; } = new Dictionary(); diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json new file mode 100644 index 000000000000..03781ec6580d --- /dev/null +++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/model.json @@ -0,0 +1,11 @@ +{ + "api": "chat", + "configuration": { + "type": "azure_openai", + "api_version": "2023-07-01-preview" + }, + "parameters": { + "model_id": "gpt-35-turbo" + } +} + diff --git a/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty new file mode 100644 index 000000000000..696ca1272d4d --- /dev/null +++ b/dotnet/src/Functions/Functions.Prompty.UnitTests/TestData/relativeFileReference.prompty @@ -0,0 +1,10 @@ +--- +name: TestRelativeFileReference +description: A test prompt for relative file references +authors: + - Test Author +model: ${file:model.json} + +--- + +# This is a test prompt for relative file references diff --git a/dotnet/src/Functions/Functions.Prompty/Extensions/PromptyKernelExtensions.cs b/dotnet/src/Functions/Functions.Prompty/Extensions/PromptyKernelExtensions.cs index 19e883cb9b60..3144b49c56bb 100644 --- a/dotnet/src/Functions/Functions.Prompty/Extensions/PromptyKernelExtensions.cs +++ b/dotnet/src/Functions/Functions.Prompty/Extensions/PromptyKernelExtensions.cs @@ -34,7 +34,7 @@ public static KernelFunction CreateFunctionFromPromptyFile( Verify.NotNullOrWhiteSpace(promptyFilePath); var promptyTemplate = File.ReadAllText(promptyFilePath); - return kernel.CreateFunctionFromPrompty(promptyTemplate, promptTemplateFactory); + return kernel.CreateFunctionFromPrompty(promptyTemplate, promptTemplateFactory, promptyFilePath); } /// @@ -46,6 +46,7 @@ public static KernelFunction CreateFunctionFromPromptyFile( /// The to use when interpreting the prompt template configuration into a . /// If null, a will be used with support for Liquid and Handlebars prompt templates. /// + /// Optional: File path to the prompty file. /// The created . /// is null. /// is null. @@ -53,12 +54,13 @@ public static KernelFunction CreateFunctionFromPromptyFile( public static KernelFunction CreateFunctionFromPrompty( this Kernel kernel, string promptyTemplate, - IPromptTemplateFactory? promptTemplateFactory = null) + IPromptTemplateFactory? promptTemplateFactory = null, + string? promptyFilePath = null) { Verify.NotNull(kernel); Verify.NotNullOrWhiteSpace(promptyTemplate); - var promptTemplateConfig = KernelFunctionPrompty.ToPromptTemplateConfig(promptyTemplate); + var promptTemplateConfig = KernelFunctionPrompty.ToPromptTemplateConfig(promptyTemplate, promptyFilePath); return KernelFunctionFactory.CreateFromPrompt( promptTemplateConfig, @@ -118,6 +120,6 @@ public static KernelFunction CreateFunctionFromPromptyFile( using StreamReader reader = new(fileInfo.CreateReadStream()); var promptyTemplate = reader.ReadToEnd(); - return kernel.CreateFunctionFromPrompty(promptyTemplate, promptTemplateFactory); + return kernel.CreateFunctionFromPrompty(promptyTemplate, promptTemplateFactory, fileInfo.PhysicalPath); } } diff --git a/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs b/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs index 8b43f523f69f..8a7e821e5ad3 100644 --- a/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs +++ b/dotnet/src/Functions/Functions.Prompty/KernelFunctionPrompty.cs @@ -45,15 +45,16 @@ public static KernelFunction FromPrompty( /// Create a from a prompty template. /// /// Prompty representation of a prompt-based . + /// Optional: File path to the prompty file. /// The created . /// is null. /// is empty or composed entirely of whitespace. - public static PromptTemplateConfig ToPromptTemplateConfig(string promptyTemplate) + public static PromptTemplateConfig ToPromptTemplateConfig(string promptyTemplate, string? promptyFilePath = null) { Verify.NotNullOrWhiteSpace(promptyTemplate); Dictionary globalConfig = []; - PromptyCore.Prompty prompty = PromptyCore.Prompty.Load(promptyTemplate, globalConfig); + PromptyCore.Prompty prompty = PromptyCore.Prompty.Load(promptyTemplate, globalConfig, promptyFilePath); var promptTemplateConfig = new PromptTemplateConfig { diff --git a/dotnet/src/Functions/Functions.UnitTests/Yaml/Plugins/CreateKernelPluginYamlTests.cs b/dotnet/src/Functions/Functions.UnitTests/Yaml/Plugins/CreateKernelPluginYamlTests.cs index c8a636e3c7b9..129f960f2f94 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Yaml/Plugins/CreateKernelPluginYamlTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Yaml/Plugins/CreateKernelPluginYamlTests.cs @@ -42,6 +42,11 @@ public PromptYamlKernelExtensionsTests() File.WriteAllText(yamlFile1Path, YAML); File.WriteAllText(yamlFile2Path, YAMLWithCustomSettings); File.WriteAllText(yamlFile3Path, YAMLNoExecutionSettings); + + // Add .yml file to plugin2 to ensure both extensions are supported + string ymlFile1Path = Path.Combine(plugin2Directory, $"{nameof(YAML)}.yml"); + + File.WriteAllText(ymlFile1Path, YAML); } catch (Exception) { @@ -112,7 +117,7 @@ private static void VerifyPluginCounts(Kernel kernel, string expectedPlugin1, st Assert.NotNull(kernel.Plugins[expectedPlugin2]); Assert.Equal(2, kernel.Plugins[expectedPlugin1].Count()); - Assert.Single(kernel.Plugins[expectedPlugin2]); + Assert.Equal(2, kernel.Plugins[expectedPlugin2].Count()); } private const string YAML = """ diff --git a/dotnet/src/Functions/Functions.Yaml/PromptYamlKernelExtensions.cs b/dotnet/src/Functions/Functions.Yaml/PromptYamlKernelExtensions.cs index 8a61cfc99ab5..2af1ae6cfde1 100644 --- a/dotnet/src/Functions/Functions.Yaml/PromptYamlKernelExtensions.cs +++ b/dotnet/src/Functions/Functions.Yaml/PromptYamlKernelExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -86,8 +87,6 @@ private static KernelPlugin CreatePluginFromPromptDirectoryYaml( IPromptTemplateFactory? promptTemplateFactory = null, IServiceProvider? services = null) { - const string YamlFilePattern = "*.yaml"; - Verify.DirectoryExists(pluginDirectory); pluginName ??= new DirectoryInfo(pluginDirectory).Name; @@ -96,7 +95,9 @@ private static KernelPlugin CreatePluginFromPromptDirectoryYaml( var functions = new List(); ILogger logger = loggerFactory.CreateLogger(typeof(Kernel)) ?? NullLogger.Instance; - foreach (string functionFile in Directory.GetFiles(pluginDirectory, YamlFilePattern)) + var functionFiles = Directory.GetFiles(pluginDirectory, "*.yaml").Concat(Directory.GetFiles(pluginDirectory, "*.yml")); + + foreach (string functionFile in functionFiles) { var functionName = Path.GetFileName(functionFile); var functionYaml = File.ReadAllText(functionFile); diff --git a/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreFixture.cs b/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreFixture.cs index 638bf1f5602f..0c6bc64bd7d9 100644 --- a/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreFixture.cs +++ b/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreFixture.cs @@ -57,8 +57,7 @@ public QdrantVectorStoreFixture() new VectorStoreRecordDataProperty("HotelCode", typeof(int)) { IsFilterable = true }, new VectorStoreRecordDataProperty("ParkingIncluded", typeof(bool)) { IsFilterable = true, StoragePropertyName = "parking_is_included" }, new VectorStoreRecordDataProperty("HotelRating", typeof(float)) { IsFilterable = true }, - new VectorStoreRecordDataProperty("LastRenovationDate", typeof(DateTime)) { IsFilterable = true }, - new VectorStoreRecordDataProperty("OpeningDate", typeof(DateTimeOffset)) { IsFilterable = true }, + new VectorStoreRecordDataProperty("LastRenovationDate", typeof(DateTimeOffset)) { IsFilterable = true }, new VectorStoreRecordDataProperty("Tags", typeof(List)) { IsFilterable = true }, new VectorStoreRecordDataProperty("Description", typeof(string)), new VectorStoreRecordVectorProperty("DescriptionEmbedding", typeof(ReadOnlyMemory?)) { Dimensions = VectorDimensions, DistanceFunction = DistanceFunction.ManhattanDistance } @@ -179,7 +178,7 @@ await this.QdrantClient.CreateCollectionAsync( { Id = 11, Vectors = new Vectors { Vectors_ = namedVectors1 }, - Payload = { ["HotelName"] = "My Hotel 11", ["HotelCode"] = 11, ["parking_is_included"] = true, ["Tags"] = tagsValue, ["HotelRating"] = 4.5f, ["Description"] = "This is a great hotel.", ["LastRenovationDate"] = "2025-02-10T05:10:15.0000000Z", ["OpeningDate"] = "2025-02-10T05:10:15.0000000+01:00" } + Payload = { ["HotelName"] = "My Hotel 11", ["HotelCode"] = 11, ["parking_is_included"] = true, ["Tags"] = tagsValue, ["HotelRating"] = 4.5f, ["Description"] = "This is a great hotel.", ["LastRenovationDate"] = "2025-02-10T05:10:15.0000000Z" } }, new PointStruct { @@ -210,7 +209,7 @@ await this.QdrantClient.CreateCollectionAsync( { Id = 11, Vectors = embeddingArray, - Payload = { ["HotelName"] = "My Hotel 11", ["HotelCode"] = 11, ["parking_is_included"] = true, ["Tags"] = tagsValue, ["HotelRating"] = 4.5f, ["Description"] = "This is a great hotel.", ["LastRenovationDate"] = "2025-02-10T05:10:15.0000000Z", ["OpeningDate"] = "2025-02-10T05:10:15.0000000+01:00" } + Payload = { ["HotelName"] = "My Hotel 11", ["HotelCode"] = 11, ["parking_is_included"] = true, ["Tags"] = tagsValue, ["HotelRating"] = 4.5f, ["Description"] = "This is a great hotel.", ["LastRenovationDate"] = "2025-02-10T05:10:15.0000000Z" } }, new PointStruct { @@ -338,13 +337,9 @@ public record HotelInfo() [VectorStoreRecordData(IsFilterable = true)] public List Tags { get; set; } = new List(); - /// A datetime metadata field. + /// A DateTimeOffset metadata field. [VectorStoreRecordData(IsFilterable = true)] - public DateTime? LastRenovationDate { get; set; } - - /// A datetimeoffset metadata field. - [VectorStoreRecordData(IsFilterable = true)] - public DateTimeOffset? OpeningDate { get; set; } + public DateTimeOffset? LastRenovationDate { get; set; } /// A data field. [VectorStoreRecordData] diff --git a/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreRecordCollectionTests.cs b/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreRecordCollectionTests.cs index c34128bfdcbe..4b4a3529a7e4 100644 --- a/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreRecordCollectionTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreRecordCollectionTests.cs @@ -82,7 +82,6 @@ public async Task ItCanCreateACollectionUpsertGetAndSearchAsync(bool hasNamedVec Assert.Equal(record.HotelRating, getResult?.HotelRating); Assert.Equal(record.ParkingIncluded, getResult?.ParkingIncluded); Assert.Equal(record.LastRenovationDate, getResult?.LastRenovationDate); - Assert.Equal(record.OpeningDate, getResult?.OpeningDate); Assert.Equal(record.Tags.ToArray(), getResult?.Tags.ToArray()); Assert.Equal(record.Description, getResult?.Description); @@ -95,7 +94,6 @@ public async Task ItCanCreateACollectionUpsertGetAndSearchAsync(bool hasNamedVec Assert.Equal(record.HotelRating, searchResultRecord?.HotelRating); Assert.Equal(record.ParkingIncluded, searchResultRecord?.ParkingIncluded); Assert.Equal(record.LastRenovationDate, searchResultRecord?.LastRenovationDate); - Assert.Equal(record.OpeningDate, searchResultRecord?.OpeningDate); Assert.Equal(record.Tags.ToArray(), searchResultRecord?.Tags.ToArray()); Assert.Equal(record.Description, searchResultRecord?.Description); @@ -226,8 +224,7 @@ public async Task ItCanGetDocumentFromVectorStoreAsync(bool useRecordDefinition, Assert.Equal(11, getResult?.HotelCode); Assert.True(getResult?.ParkingIncluded); Assert.Equal(4.5f, getResult?.HotelRating); - Assert.Equal(new DateTime(2025, 2, 10, 5, 10, 15, DateTimeKind.Utc), getResult?.LastRenovationDate); - Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), getResult?.OpeningDate); + Assert.Equal(new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.Zero), getResult?.LastRenovationDate); Assert.Equal(2, getResult?.Tags.Count); Assert.Equal("t11.1", getResult?.Tags[0]); Assert.Equal("t11.2", getResult?.Tags[1]); @@ -483,8 +480,7 @@ private async Task CreateTestHotelAsync(uint hotelId, ITextEmbeddingG HotelCode = (int)hotelId, HotelRating = 4.5f, ParkingIncluded = true, - LastRenovationDate = new DateTime(2025, 2, 10, 5, 10, 15, DateTimeKind.Utc), - OpeningDate = new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.FromHours(1)), + LastRenovationDate = new DateTimeOffset(2025, 2, 10, 5, 10, 15, TimeSpan.Zero), Tags = { "t1", "t2" }, Description = "This is a great hotel.", DescriptionEmbedding = await embeddingGenerator.GenerateEmbeddingAsync("This is a great hotel."), diff --git a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs index ea6999d42ba7..e5588f819333 100644 --- a/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs +++ b/dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs @@ -256,7 +256,7 @@ public class GeminiConfig public class VertexAIConfig { - public string BearerKey { get; set; } + public string? BearerKey { get; set; } public string EmbeddingModelId { get; set; } public string Location { get; set; } public string ProjectId { get; set; } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_site_filter_what_is_the_semantic_kernel.json b/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_site_filter_what_is_the_semantic_kernel.json new file mode 100644 index 000000000000..34c57a4b844a --- /dev/null +++ b/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_site_filter_what_is_the_semantic_kernel.json @@ -0,0 +1,289 @@ +{ + "query": { + "original": "What is the Semantic Kernel?", + "show_strict_warning": false, + "is_navigational": false, + "is_news_breaking": false, + "spellcheck_off": true, + "country": "us", + "bad_results": false, + "should_fallback": false, + "postal_code": "", + "city": "", + "header_country": "", + "more_results_available": true, + "state": "" + }, + "mixed": { + "type": "mixed", + "main": [ + { + "type": "web", + "index": 0, + "all": false + }, + { + "type": "web", + "index": 1, + "all": false + }, + { + "type": "web", + "index": 2, + "all": false + }, + { + "type": "web", + "index": 3, + "all": false + }, + { + "type": "web", + "index": 4, + "all": false + }, + { + "type": "web", + "index": 5, + "all": false + }, + { + "type": "web", + "index": 6, + "all": false + }, + { + "type": "web", + "index": 7, + "all": false + }, + { + "type": "web", + "index": 8, + "all": false + }, + { + "type": "web", + "index": 9, + "all": false + }, + { + "type": "web", + "index": 10, + "all": false + }, + { + "type": "web", + "index": 11, + "all": false + }, + { + "type": "web", + "index": 12, + "all": false + }, + { + "type": "web", + "index": 13, + "all": false + }, + { + "type": "web", + "index": 14, + "all": false + }, + { + "type": "web", + "index": 15, + "all": false + }, + { + "type": "web", + "index": 16, + "all": false + }, + { + "type": "web", + "index": 17, + "all": false + }, + { + "type": "web", + "index": 18, + "all": false + }, + { + "type": "web", + "index": 19, + "all": false + } + ], + "top": [], + "side": [] + }, + "type": "search", + "web": { + "type": "search", + "results": [ + { + "title": "Introduction to Semantic Kernel | Microsoft Learn", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/overview/", + "is_source_local": false, + "is_source_both": false, + "description": "Upgrade to Microsoft Edge to take ... and Microsoft Edge ... Semantic Kernel is a lightweight, open-source development kit that lets you easily build AI agents and integrate the latest AI models into your C#, Python, or Java codebase....", + "profile": { + "name": "Microsoft", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/overview/", + "long_name": "learn.microsoft.com", + "img": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "learn.microsoft.com", + "hostname": "learn.microsoft.com", + "favicon": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw", + "path": "› en-us › semantic-kernel › overview" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/KxEtqQiadL_R-Mr9_FffhMDYK3gVHrYWjuByaTLSjYg/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9sZWFy/bi5taWNyb3NvZnQu/Y29tL2VuLXVzL21l/ZGlhL29wZW4tZ3Jh/cGgtaW1hZ2UucG5n", + "original": "https://learn.microsoft.com/en-us/media/open-graph-image.png", + "logo": false + } + }, + { + "title": "Understanding the kernel in Semantic Kernel | Microsoft Learn", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel", + "is_source_local": false, + "is_source_both": false, + "description": "This means that if you run any prompt or code in Semantic Kernel, the kernel will always be available to retrieve the necessary services and plugins. This is extremely powerful, because it means you as a developer have a single place where you can configure, and most importantly monitor, your ...", + "page_age": "2024-07-25T00:00:00", + "profile": { + "name": "Microsoft", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel", + "long_name": "learn.microsoft.com", + "img": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "learn.microsoft.com", + "hostname": "learn.microsoft.com", + "favicon": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw", + "path": "› en-us › semantic-kernel › concepts › kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/KxEtqQiadL_R-Mr9_FffhMDYK3gVHrYWjuByaTLSjYg/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9sZWFy/bi5taWNyb3NvZnQu/Y29tL2VuLXVzL21l/ZGlhL29wZW4tZ3Jh/cGgtaW1hZ2UucG5n", + "original": "https://learn.microsoft.com/en-us/media/open-graph-image.png", + "logo": false + }, + "age": "July 25, 2024" + }, + { + "title": "Semantic Kernel: The New Way to Create Artificial Intelligence Applications | by Adolfo | Globant | Medium", + "url": "https://medium.com/globant/semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca", + "is_source_local": false, + "is_source_both": false, + "description": "When developing a solution using Semantic Kernel, there are a series of components that we can employ to provide a better experience in our application. Not all of them are mandatory to use, although it is advisable to be familiar with them.", + "page_age": "2024-01-08T02:03:21", + "profile": { + "name": "Medium", + "url": "https://medium.com/globant/semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca", + "long_name": "medium.com", + "img": "https://imgs.search.brave.com/4R4hFITz_F_be0roUiWbTZKhsywr3fnLTMTkFL5HFow/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "article", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "medium.com", + "hostname": "medium.com", + "favicon": "https://imgs.search.brave.com/4R4hFITz_F_be0roUiWbTZKhsywr3fnLTMTkFL5HFow/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw", + "path": "› globant › semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/XcAfvJj3DldkElaNEYTVO16wc31dCRp7bSg0uLe2F7E/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9taXJv/Lm1lZGl1bS5jb20v/djIvcmVzaXplOmZp/dDoxMjAwLzEqbUlM/NExYcjFQTC0tUWlt/bTllTktHZy5qcGVn", + "original": "https://miro.medium.com/v2/resize:fit:1200/1*mIL4LXr1PL--Qimm9eNKGg.jpeg", + "logo": false + }, + "age": "January 8, 2024" + }, + { + "title": "Guide to Semantic Kernel", + "url": "https://www.analyticsvidhya.com/blog/2025/04/semantic-kernel/", + "is_source_local": false, + "is_source_both": false, + "description": "This transformation is driven by the rise of agentic frameworks like Autogen, LangGraph, and CrewAI. These frameworks enable large language models (LLMs) to act more like autonomous agents—capable of making decisions, calling functions, and collaborating across tasks. Among these, one particularly powerful yet developer-friendly option comes from Microsoft:Semantic Kernel...", + "page_age": "2025-04-05T06:44:24", + "profile": { + "name": "Analytics Vidhya", + "url": "https://www.analyticsvidhya.com/blog/2025/04/semantic-kernel/", + "long_name": "analyticsvidhya.com", + "img": "https://imgs.search.brave.com/hQWAHDfKiXo1CUqglQzzUNKabGxCuxr2m0u2YS9m8yQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYzYzYTc2NzY4/MWNhOWUzNzE0MGVl/OGYwMTI3MDI4YjYx/MjZiODkzNGRlNGJk/YjVlZjA2ZGE4Yjgz/ZTA1MTAzMy93d3cu/YW5hbHl0aWNzdmlk/aHlhLmNvbS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "faq", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "analyticsvidhya.com", + "hostname": "www.analyticsvidhya.com", + "favicon": "https://imgs.search.brave.com/hQWAHDfKiXo1CUqglQzzUNKabGxCuxr2m0u2YS9m8yQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYzYzYTc2NzY4/MWNhOWUzNzE0MGVl/OGYwMTI3MDI4YjYx/MjZiODkzNGRlNGJk/YjVlZjA2ZGE4Yjgz/ZTA1MTAzMy93d3cu/YW5hbHl0aWNzdmlk/aHlhLmNvbS8", + "path": " › home › guide to semantic kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/oGawcJrJiMGrsEjLviMBpPhI0CK2-W68PRvPXMaShOQ/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/YW5hbHl0aWNzdmlk/aHlhLmNvbS93cC1j/b250ZW50L3VwbG9h/ZHMvMjAyNS8wNC9F/eHBsb3JpbmctU2Vt/YW50aWMtS2VybmVs/LVVubGVhc2hpbmct/dGhlLVBvd2VyLW9m/LXRoZS1BZ2VudGlj/LUZyYW1ld29yay0u/d2VicA", + "original": "https://cdn.analyticsvidhya.com/wp-content/uploads/2025/04/Exploring-Semantic-Kernel-Unleashing-the-Power-of-the-Agentic-Framework-.webp", + "logo": false + }, + "age": "1 day ago" + }, + { + "title": "Semantic Kernel 101", + "url": "https://www.codemag.com/Article/2401091/Semantic-Kernel-101", + "is_source_local": false, + "is_source_both": false, + "description": "Currently, the Copilot approach rules the artificial intelligence (AI) world, and in that world, Microsoft created Semantic Kernel as a framework for building its own Copilots. Now, you can use Semantic Kernel too. Semantic Kernel (SK) is an open-source AI framework, created by Microsoft for ...", + "page_age": "2025-02-07T00:00:00", + "profile": { + "name": "CODE", + "url": "https://www.codemag.com/Article/2401091/Semantic-Kernel-101", + "long_name": "codemag.com", + "img": "https://imgs.search.brave.com/IHpYeNjjOa1q5GWEH6_DrSCQ_Srcj1PYu-zc5dTpQOE/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOWNiNzI5ODUy/MWU1NDkwZWE3N2Mw/NjllZGU3YWE3YjU2/Mzg0MWE2ZjU5MWNl/NTEwOTkyNTRkMjZj/YTM2NGQ3My93d3cu/Y29kZW1hZy5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "codemag.com", + "hostname": "www.codemag.com", + "favicon": "https://imgs.search.brave.com/IHpYeNjjOa1q5GWEH6_DrSCQ_Srcj1PYu-zc5dTpQOE/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOWNiNzI5ODUy/MWU1NDkwZWE3N2Mw/NjllZGU3YWE3YjU2/Mzg0MWE2ZjU5MWNl/NTEwOTkyNTRkMjZj/YTM2NGQ3My93d3cu/Y29kZW1hZy5jb20v", + "path": "› Article › 2401091 › Semantic-Kernel-101" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/oTv3hzudYH3G78UrviYe3l11IcQavn9IDUwGO7SBT00/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9lcHNl/bnRlcnByaXNlLmJs/b2IuY29yZS53aW5k/b3dzLm5ldC9wZXJt/YW5lbnQtZmlsZXMv/RmlsZUF0dGFjaG1l/bnRzL2Q3N2NjY2Zj/X2ZhOWZfNGVkY19h/YWZhXzI3ZmVkMjM1/NDg4NS8yMjEyMDUx/X0hlYWRlcl9SZWN0/YW5nbGUucG5n", + "original": "https://epsenterprise.blob.core.windows.net/permanent-files/FileAttachments/d77cccfc_fa9f_4edc_aafa_27fed2354885/2212051_Header_Rectangle.png", + "logo": false + }, + "age": "February 7, 2025" + } + ], + "family_friendly": true + } +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_what_is_the_semantic_kernel.json b/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_what_is_the_semantic_kernel.json new file mode 100644 index 000000000000..496d4d489da8 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.UnitTests/TestData/brave_what_is_the_semantic_kernel.json @@ -0,0 +1,445 @@ +{ + "query": { + "original": "What is Semantic Kernel", + "show_strict_warning": false, + "is_navigational": false, + "is_news_breaking": false, + "spellcheck_off": true, + "country": "us", + "bad_results": false, + "should_fallback": false, + "postal_code": "", + "city": "", + "header_country": "", + "more_results_available": true, + "state": "" + }, + "mixed": { + "type": "mixed", + "main": [ + { + "type": "web", + "index": 0, + "all": false + }, + { + "type": "web", + "index": 1, + "all": false + }, + { + "type": "web", + "index": 2, + "all": false + }, + { + "type": "web", + "index": 3, + "all": false + }, + { + "type": "web", + "index": 4, + "all": false + }, + { + "type": "web", + "index": 5, + "all": false + }, + { + "type": "web", + "index": 6, + "all": false + }, + { + "type": "web", + "index": 7, + "all": false + }, + { + "type": "web", + "index": 8, + "all": false + }, + { + "type": "web", + "index": 9, + "all": false + }, + { + "type": "web", + "index": 10, + "all": false + }, + { + "type": "web", + "index": 11, + "all": false + }, + { + "type": "web", + "index": 12, + "all": false + }, + { + "type": "web", + "index": 13, + "all": false + }, + { + "type": "web", + "index": 14, + "all": false + }, + { + "type": "web", + "index": 15, + "all": false + }, + { + "type": "web", + "index": 16, + "all": false + }, + { + "type": "web", + "index": 17, + "all": false + }, + { + "type": "web", + "index": 18, + "all": false + }, + { + "type": "web", + "index": 19, + "all": false + } + ], + "top": [], + "side": [] + }, + "type": "search", + "web": { + "type": "search", + "results": [ + { + "title": "Introduction to Semantic Kernel | Microsoft Learn", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/overview/", + "is_source_local": false, + "is_source_both": false, + "description": "Semantic Kernel combines prompts with existing APIs to perform actions. By describing your existing code to AI models, they’ll be called to address requests. When a request is made the model calls a function, and Semantic Kernel is the middleware translating the model's request to a function ...", + "profile": { + "name": "Microsoft", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/overview/", + "long_name": "learn.microsoft.com", + "img": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "learn.microsoft.com", + "hostname": "learn.microsoft.com", + "favicon": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw", + "path": "› en-us › semantic-kernel › overview" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/KxEtqQiadL_R-Mr9_FffhMDYK3gVHrYWjuByaTLSjYg/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9sZWFy/bi5taWNyb3NvZnQu/Y29tL2VuLXVzL21l/ZGlhL29wZW4tZ3Jh/cGgtaW1hZ2UucG5n", + "original": "https://learn.microsoft.com/en-us/media/open-graph-image.png", + "logo": false + } + }, + { + "title": "Understanding the kernel in Semantic Kernel | Microsoft Learn", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel", + "is_source_local": false, + "is_source_both": false, + "description": "This means that if you run any prompt or code in Semantic Kernel, the kernel will always be available to retrieve the necessary services and plugins. This is extremely powerful, because it means you as a developer have a single place where you can configure, and most importantly monitor, your ...", + "page_age": "2024-07-25T00:00:00", + "profile": { + "name": "Microsoft", + "url": "https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel", + "long_name": "learn.microsoft.com", + "img": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "learn.microsoft.com", + "hostname": "learn.microsoft.com", + "favicon": "https://imgs.search.brave.com/dKusAYBYTLeCBl16XSMYRZO-wCc_EyGpoH65Oj11tOU/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMmMzNjVjYjk4/NmJkODdmNTU4ZDU1/MGUwNjk0MWFmZWU0/NmYzZjVlYmZjZDIy/MWM4MGMwODc4MDhi/MDM5MmZkYy9sZWFy/bi5taWNyb3NvZnQu/Y29tLw", + "path": "› en-us › semantic-kernel › concepts › kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/KxEtqQiadL_R-Mr9_FffhMDYK3gVHrYWjuByaTLSjYg/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9sZWFy/bi5taWNyb3NvZnQu/Y29tL2VuLXVzL21l/ZGlhL29wZW4tZ3Jh/cGgtaW1hZ2UucG5n", + "original": "https://learn.microsoft.com/en-us/media/open-graph-image.png", + "logo": false + }, + "age": "July 25, 2024" + }, + { + "title": "Semantic Kernel: The New Way to Create Artificial Intelligence Applications | by Adolfo | Globant | Medium", + "url": "https://medium.com/globant/semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca", + "is_source_local": false, + "is_source_both": false, + "description": "When developing a solution using Semantic Kernel, there are a series of components that we can employ to provide a better experience in our application. Not all of them are mandatory to use, although it is advisable to be familiar with them.", + "page_age": "2024-01-08T02:03:21", + "profile": { + "name": "Medium", + "url": "https://medium.com/globant/semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca", + "long_name": "medium.com", + "img": "https://imgs.search.brave.com/4R4hFITz_F_be0roUiWbTZKhsywr3fnLTMTkFL5HFow/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "article", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "medium.com", + "hostname": "medium.com", + "favicon": "https://imgs.search.brave.com/4R4hFITz_F_be0roUiWbTZKhsywr3fnLTMTkFL5HFow/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw", + "path": "› globant › semantic-kernel-the-new-way-to-create-artificial-intelligence-applications-7959d5fc90ca" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/XcAfvJj3DldkElaNEYTVO16wc31dCRp7bSg0uLe2F7E/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9taXJv/Lm1lZGl1bS5jb20v/djIvcmVzaXplOmZp/dDoxMjAwLzEqbUlM/NExYcjFQTC0tUWlt/bTllTktHZy5qcGVn", + "original": "https://miro.medium.com/v2/resize:fit:1200/1*mIL4LXr1PL--Qimm9eNKGg.jpeg", + "logo": false + }, + "age": "January 8, 2024" + }, + { + "title": "Guide to Semantic Kernel", + "url": "https://www.analyticsvidhya.com/blog/2025/04/semantic-kernel/", + "is_source_local": false, + "is_source_both": false, + "description": "This transformation is driven by the rise of agentic frameworks like Autogen, LangGraph, and CrewAI. These frameworks enable large language models (LLMs) to act more like autonomous agents—capable of making decisions, calling functions, and collaborating across tasks. Among these, one particularly powerful yet developer-friendly option comes from Microsoft:Semantic Kernel...", + "page_age": "2025-04-05T06:44:24", + "profile": { + "name": "Analytics Vidhya", + "url": "https://www.analyticsvidhya.com/blog/2025/04/semantic-kernel/", + "long_name": "analyticsvidhya.com", + "img": "https://imgs.search.brave.com/hQWAHDfKiXo1CUqglQzzUNKabGxCuxr2m0u2YS9m8yQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYzYzYTc2NzY4/MWNhOWUzNzE0MGVl/OGYwMTI3MDI4YjYx/MjZiODkzNGRlNGJk/YjVlZjA2ZGE4Yjgz/ZTA1MTAzMy93d3cu/YW5hbHl0aWNzdmlk/aHlhLmNvbS8" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "faq", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "analyticsvidhya.com", + "hostname": "www.analyticsvidhya.com", + "favicon": "https://imgs.search.brave.com/hQWAHDfKiXo1CUqglQzzUNKabGxCuxr2m0u2YS9m8yQ/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYzYzYTc2NzY4/MWNhOWUzNzE0MGVl/OGYwMTI3MDI4YjYx/MjZiODkzNGRlNGJk/YjVlZjA2ZGE4Yjgz/ZTA1MTAzMy93d3cu/YW5hbHl0aWNzdmlk/aHlhLmNvbS8", + "path": " › home › guide to semantic kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/oGawcJrJiMGrsEjLviMBpPhI0CK2-W68PRvPXMaShOQ/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9jZG4u/YW5hbHl0aWNzdmlk/aHlhLmNvbS93cC1j/b250ZW50L3VwbG9h/ZHMvMjAyNS8wNC9F/eHBsb3JpbmctU2Vt/YW50aWMtS2VybmVs/LVVubGVhc2hpbmct/dGhlLVBvd2VyLW9m/LXRoZS1BZ2VudGlj/LUZyYW1ld29yay0u/d2VicA", + "original": "https://cdn.analyticsvidhya.com/wp-content/uploads/2025/04/Exploring-Semantic-Kernel-Unleashing-the-Power-of-the-Agentic-Framework-.webp", + "logo": false + }, + "age": "1 day ago" + }, + { + "title": "GitHub - microsoft/semantic-kernel: Integrate cutting-edge LLM technology quickly and easily into your apps", + "url": "https://github.com/microsoft/semantic-kernel", + "is_source_local": false, + "is_source_both": false, + "description": "Semantic Kernel is a model-agnostic SDK that empowers developers to build, orchestrate, and deploy AI agents and multi-agent systems.", + "profile": { + "name": "GitHub", + "url": "https://github.com/microsoft/semantic-kernel", + "long_name": "github.com", + "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "software", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "github.com", + "hostname": "github.com", + "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw", + "path": "› microsoft › semantic-kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/cfKkISJR8ySN1UFKPUS351zVDtTJw3u_4ysbvJWolYM/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9yZXBv/c2l0b3J5LWltYWdl/cy5naXRodWJ1c2Vy/Y29udGVudC5jb20v/NjA3Mjg5MTg1LzQw/MmFlNDAxLWQ2NTAt/NDM4YS1iYzA0LTc4/MGFmYjU4YjU2MA", + "original": "https://repository-images.githubusercontent.com/607289185/402ae401-d650-438a-bc04-780afb58b560", + "logo": false + } + }, + { + "title": "Understanding Semantic Kernel | Valorem Reply", + "url": "https://valoremreply.com/resources/insights/blog/2023/august/understanding-semantic-kernel/", + "is_source_local": false, + "is_source_both": false, + "description": "Semantic Kernel (SK) is an AI Software Development Kit (SDK) from Microsoft that brings you the large language capabilities of AI services like OpenAI to your apps. We’ve been excited since its launch and the subsequent announcements at BUILD 2023. We have used it for building several Gen ...", + "page_age": "2024-08-23T14:41:52", + "profile": { + "name": "Valorem Reply", + "url": "https://valoremreply.com/resources/insights/blog/2023/august/understanding-semantic-kernel/", + "long_name": "valoremreply.com", + "img": "https://imgs.search.brave.com/DV1uN3Uc8_YsnN2rfeKLnY3OESRjsaM5T9cRi2eaX8w/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMzY5MDJlZTZj/NWE3Mzg1ZmY1YWZm/ZGQ0YzI2NDUyYTRj/Mjk4MTVjYmI1NzM3/MWY2NTM4N2E4MTFj/YTgwZWVjOC92YWxv/cmVtcmVwbHkuY29t/Lw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "article", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "valoremreply.com", + "hostname": "valoremreply.com", + "favicon": "https://imgs.search.brave.com/DV1uN3Uc8_YsnN2rfeKLnY3OESRjsaM5T9cRi2eaX8w/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMzY5MDJlZTZj/NWE3Mzg1ZmY1YWZm/ZGQ0YzI2NDUyYTRj/Mjk4MTVjYmI1NzM3/MWY2NTM4N2E4MTFj/YTgwZWVjOC92YWxv/cmVtcmVwbHkuY29t/Lw", + "path": "› resources › insights › blog › 2023 › august › understanding-semantic-kernel" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/n2OodVXTFeoD9YHIHD6jyFLqB5CzeGxaoo09wMiLUB4/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly92YWxv/cmVtcHJvZGNkbi1m/eGhtaGdkZWIzYmdh/MGY5LmEwMy5henVy/ZWZkLm5ldC9tZWRp/YS9hdW9kdGR5MS9z/ZW1hbnRpYy1rZXJu/ZWwtYmxvZy1oZWFk/ZXIud2VicA", + "original": "https://valoremprodcdn-fxhmhgdeb3bga0f9.a03.azurefd.net/media/auodtdy1/semantic-kernel-blog-header.webp", + "logo": false + }, + "age": "August 23, 2024" + }, + { + "title": "Semantic Kernel - YouTube", + "url": "https://www.youtube.com/watch?v=OLdeHYobcDI", + "is_source_local": false, + "is_source_both": false, + "description": "What can Semantic Kernel do for you? Join Mike Richter for an in-depth walkthrough of this amazing tool for your Generative AI applications. Concept demystif...", + "page_age": "2024-04-04T23:04:00", + "profile": { + "name": "YouTube", + "url": "https://www.youtube.com/watch?v=OLdeHYobcDI", + "long_name": "youtube.com", + "img": "https://imgs.search.brave.com/Wg4wjE5SHAargkzePU3eSLmWgVz84BEZk1SjSglJK_U/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "video", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "youtube.com", + "hostname": "www.youtube.com", + "favicon": "https://imgs.search.brave.com/Wg4wjE5SHAargkzePU3eSLmWgVz84BEZk1SjSglJK_U/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v", + "path": " › microsoft academy hub" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/dOBOx7iTjOK1NjpEZ8V8_a613cxa3U5JMeEhIb4Pego/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9P/TGRlSFlvYmNESS9t/YXhyZXNkZWZhdWx0/LmpwZw", + "original": "https://i.ytimg.com/vi/OLdeHYobcDI/maxresdefault.jpg", + "logo": false + }, + "age": "April 4, 2024" + }, + { + "title": "Foundations of Semantic Kernel", + "url": "https://github.com/microsoft/SemanticKernelCookBook/blob/main/docs/en/02.IntroduceSemanticKernel.md", + "is_source_local": false, + "is_source_both": false, + "description": "This is a Semantic Kernel's book for beginners . Contribute to microsoft/SemanticKernelCookBook development by creating an account on GitHub.", + "profile": { + "name": "GitHub", + "url": "https://github.com/microsoft/SemanticKernelCookBook/blob/main/docs/en/02.IntroduceSemanticKernel.md", + "long_name": "github.com", + "img": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "software", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "github.com", + "hostname": "github.com", + "favicon": "https://imgs.search.brave.com/xxsA4YxzaR0cl-DBsH9-lpv2gsif3KMYgM87p26bs_o/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw", + "path": "› microsoft › SemanticKernelCookBook › blob › main › docs › en › 02.IntroduceSemanticKernel.md" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/Dgv2Cvw0zZnuFRGqIog6a1v0gmK_emglVGvswdyyasw/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS9j/NGM0YmJkODQzOGE2/ZmIyYjE2YTAzZjMw/M2VkYzE3MWYzNzYx/MTM2ZmQ0MDI2YmI0/MTVkMGMxMmFiMTNl/MzhjL21pY3Jvc29m/dC9TZW1hbnRpY0tl/cm5lbENvb2tCb29r", + "original": "https://opengraph.githubassets.com/c4c4bbd8438a6fb2b16a03f303edc171f3761136fd4026bb415d0c12ab13e38c/microsoft/SemanticKernelCookBook", + "logo": false + } + }, + { + "title": "Semantic Kernel 101. Part 1: Understanding the framework and… | by Valentina Alto | Medium", + "url": "https://valentinaalto.medium.com/semantic-kernel-101-1d0f403854ec", + "is_source_local": false, + "is_source_both": false, + "description": "Semantic kernel is a lightweight framework which make it easier to develop AI-powered applications. It falls into the category of AI orchestrators like Llama-Index, LangChain, TaskWeaver and so on…", + "page_age": "2025-03-23T07:28:42", + "profile": { + "name": "Medium", + "url": "https://valentinaalto.medium.com/semantic-kernel-101-1d0f403854ec", + "long_name": "valentinaalto.medium.com", + "img": "https://imgs.search.brave.com/2Hiq7SyBXt7vPzy8p5LrZ9TlW_BQuljP36a14uV4s0w/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYTE1YThiOWIy/ODJmMzMxNDBhZjk5/ZTAzMjlhM2Q0NjAz/NDUyOTU3ZGQ4YTdm/ZDJmOTRlZmZmYWZm/MWE3YTljYi92YWxl/bnRpbmFhbHRvLm1l/ZGl1bS5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "article", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "valentinaalto.medium.com", + "hostname": "valentinaalto.medium.com", + "favicon": "https://imgs.search.brave.com/2Hiq7SyBXt7vPzy8p5LrZ9TlW_BQuljP36a14uV4s0w/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYTE1YThiOWIy/ODJmMzMxNDBhZjk5/ZTAzMjlhM2Q0NjAz/NDUyOTU3ZGQ4YTdm/ZDJmOTRlZmZmYWZm/MWE3YTljYi92YWxl/bnRpbmFhbHRvLm1l/ZGl1bS5jb20v", + "path": "› semantic-kernel-101-1d0f403854ec" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/-cxRlw9tG25HMtYyNz2L1SK0RktV0mIGyQX8dbsD2JA/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9taXJv/Lm1lZGl1bS5jb20v/djIvcmVzaXplOmZp/dDoxMjAwLzEqazhz/NGItN1BTN1lZX2g1/eHFCdV9kUS5wbmc", + "original": "https://miro.medium.com/v2/resize:fit:1200/1*k8s4b-7PS7YY_h5xqBu_dQ.png", + "logo": false + }, + "age": "2 weeks ago" + }, + { + "title": "Semantic Kernel 101", + "url": "https://www.codemag.com/Article/2401091/Semantic-Kernel-101", + "is_source_local": false, + "is_source_both": false, + "description": "Currently, the Copilot approach rules the artificial intelligence (AI) world, and in that world, Microsoft created Semantic Kernel as a framework for building its own Copilots. Now, you can use Semantic Kernel too. Semantic Kernel (SK) is an open-source AI framework, created by Microsoft for ...", + "page_age": "2025-02-07T00:00:00", + "profile": { + "name": "CODE", + "url": "https://www.codemag.com/Article/2401091/Semantic-Kernel-101", + "long_name": "codemag.com", + "img": "https://imgs.search.brave.com/IHpYeNjjOa1q5GWEH6_DrSCQ_Srcj1PYu-zc5dTpQOE/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOWNiNzI5ODUy/MWU1NDkwZWE3N2Mw/NjllZGU3YWE3YjU2/Mzg0MWE2ZjU5MWNl/NTEwOTkyNTRkMjZj/YTM2NGQ3My93d3cu/Y29kZW1hZy5jb20v" + }, + "language": "en", + "family_friendly": true, + "type": "search_result", + "subtype": "generic", + "is_live": false, + "meta_url": { + "scheme": "https", + "netloc": "codemag.com", + "hostname": "www.codemag.com", + "favicon": "https://imgs.search.brave.com/IHpYeNjjOa1q5GWEH6_DrSCQ_Srcj1PYu-zc5dTpQOE/rs:fit:32:32:1:0/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOWNiNzI5ODUy/MWU1NDkwZWE3N2Mw/NjllZGU3YWE3YjU2/Mzg0MWE2ZjU5MWNl/NTEwOTkyNTRkMjZj/YTM2NGQ3My93d3cu/Y29kZW1hZy5jb20v", + "path": "› Article › 2401091 › Semantic-Kernel-101" + }, + "thumbnail": { + "src": "https://imgs.search.brave.com/oTv3hzudYH3G78UrviYe3l11IcQavn9IDUwGO7SBT00/rs:fit:200:200:1:0/g:ce/aHR0cHM6Ly9lcHNl/bnRlcnByaXNlLmJs/b2IuY29yZS53aW5k/b3dzLm5ldC9wZXJt/YW5lbnQtZmlsZXMv/RmlsZUF0dGFjaG1l/bnRzL2Q3N2NjY2Zj/X2ZhOWZfNGVkY19h/YWZhXzI3ZmVkMjM1/NDg4NS8yMjEyMDUx/X0hlYWRlcl9SZWN0/YW5nbGUucG5n", + "original": "https://epsenterprise.blob.core.windows.net/permanent-files/FileAttachments/d77cccfc_fa9f_4edc_aafa_27fed2354885/2212051_Header_Rectangle.png", + "logo": false + }, + "age": "February 7, 2025" + } + ], + "family_friendly": true + } +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs new file mode 100644 index 000000000000..8a98a3d81a47 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Data; +using Microsoft.SemanticKernel.Plugins.Web.Brave; +using Xunit; + +namespace SemanticKernel.Plugins.UnitTests.Web.Brave; + +public sealed class BraveTextSearchTests : IDisposable +{ + /// + /// Initializes a new instance of the class. + /// + public BraveTextSearchTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, disposeHandler: false); + this._kernel = new Kernel(); + } + + [Fact] + public void AddBraveTextSearchSucceeds() + { + // Arrange + var builder = Kernel.CreateBuilder(); + + // Act + builder.AddBraveTextSearch(apiKey: "ApiKey"); + var kernel = builder.Build(); + + // Assert + Assert.IsType(kernel.Services.GetRequiredService()); + } + + [Fact] + public async Task SearchReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(10, resultList.Count); + foreach (var stringResult in resultList) + { + Assert.NotEmpty(stringResult); + } + } + + [Fact] + public async Task GetTextSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(10, resultList.Count); + foreach (var textSearchResult in resultList) + { + Assert.NotNull(textSearchResult.Name); + Assert.NotNull(textSearchResult.Value); + Assert.NotNull(textSearchResult.Link); + } + } + + [Fact] + public async Task GetSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(10, resultList.Count); + foreach (BraveWebResult webPage in resultList) + { + Assert.NotNull(webPage.Title); + Assert.NotNull(webPage.Description); + Assert.NotNull(webPage.Url); + } + } + + [Fact] + public async Task SearchWithCustomStringMapperReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, StringMapper = new TestTextSearchStringMapper() }); + + // Act + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(10, resultList.Count); + foreach (var stringResult in resultList) + { + Assert.NotEmpty(stringResult); + var webPage = JsonSerializer.Deserialize(stringResult); + Assert.NotNull(webPage); + } + } + + [Fact] + public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, ResultMapper = new TestTextSearchResultMapper() }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(10, resultList.Count); + foreach (var textSearchResult in resultList) + { + Assert.NotNull(textSearchResult); + Assert.Equal(textSearchResult.Name, textSearchResult.Name?.ToUpperInvariant()); + Assert.Equal(textSearchResult.Value, textSearchResult.Value?.ToUpperInvariant()); + Assert.Equal(textSearchResult.Link, textSearchResult.Link?.ToUpperInvariant()); + } + } + + //https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&&country=US&search_lang=en&ui_lang=en-US&safesearch=moderate&text_decorations=True&spellcheck=False&result_filter=web&units=imperial&extra_snippets=True + + [Theory] + [InlineData("country", "US", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&country=US")] + [InlineData("search_lang", "en", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&search_lang=en")] + [InlineData("ui_lang", "en-US", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&ui_lang=en-US")] + [InlineData("safesearch", "off", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&safesearch=off")] + [InlineData("safesearch", "moderate", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&safesearch=moderate")] + [InlineData("safesearch", "strict", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&safesearch=strict")] + [InlineData("text_decorations", true, "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&text_decorations=True")] + [InlineData("spellcheck", false, "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&spellcheck=False")] + [InlineData("result_filter", "web", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&result_filter=web")] + [InlineData("units", "imperial", "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&units=imperial")] + [InlineData("extra_snippets", true, "https://api.search.brave.com/res/v1/web/search?q=What%20is%20the%20Semantic%20Kernel%3F&count=5&offset=0&extra_snippets=True")] + public async Task BuildsCorrectUriForEqualityFilterAsync(string paramName, object paramValue, string requestLink) + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality(paramName, paramValue) }; + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + + // Assert + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + Assert.NotNull(requestUris[0]); + Assert.Equal(requestLink, requestUris[0]!.AbsoluteUri); + } + + [Fact] + public async Task DoesNotBuildsUriForInvalidQueryParameterAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); + TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality("fooBar", "Baz") }; + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act && Assert + var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); + Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message); + } + + [Fact] + public async Task DoesNotBuildsUriForQueryParameterNullInputAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); + TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality("country", null!) }; + + // Create an ITextSearch instance using Brave search + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act && Assert + var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); + Assert.Equal("Unknown equality filter clause field name 'country', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message); + } + + /// + public void Dispose() + { + this._messageHandlerStub.Dispose(); + this._httpClient.Dispose(); + + GC.SuppressFinalize(this); + } + + #region private + private const string WhatIsTheSkResponseJson = "./TestData/brave_what_is_the_semantic_kernel.json"; + private const string SiteFilterSkResponseJson = "./TestData/brave_site_filter_what_is_the_semantic_kernel.json"; + + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Kernel _kernel; + + /// + /// Test mapper which converts a BraveWebPage search result to a string using JSON serialization. + /// + private sealed class TestTextSearchStringMapper : ITextSearchStringMapper + { + /// + public string MapFromResultToString(object result) + { + return JsonSerializer.Serialize(result); + } + } + + /// + /// Test mapper which converts a BraveWebPage search result to a string using JSON serialization. + /// + private sealed class TestTextSearchResultMapper : ITextSearchResultMapper + { + /// + public TextSearchResult MapFromResultToTextSearchResult(object result) + { + if (result is not BraveWebResult webPage) + { + throw new ArgumentException("Result must be a BraveWebPage", nameof(result)); + } + + return new TextSearchResult(webPage.Description?.ToUpperInvariant() ?? string.Empty) + { + Name = webPage.Title?.ToUpperInvariant(), + Link = webPage.Url?.ToUpperInvariant(), + }; + } + } + #endregion +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/SearchUrlSkillTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/SearchUrlSkillTests.cs index ac0bf4d48796..b180b4d6ca27 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/SearchUrlSkillTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/SearchUrlSkillTests.cs @@ -117,6 +117,71 @@ public void BingTravelSearchUrl() Assert.Equal($"https://www.bing.com/travel/search?q={this._encodedInput}", actual); } + [Fact] + public void BraveSearchUrl() + { + // Arrange + var plugin = new SearchUrlPlugin(); + + // Act + string actual = plugin.BraveSearchUrl(AnyInput); + + // Assert + Assert.Equal($"https://search.brave.com/search?q={this._encodedInput}", actual); + } + + [Fact] + public void BraveImagesSearchUrl() + { + // Arrange + var plugin = new SearchUrlPlugin(); + + // Act + string actual = plugin.BraveImagesSearchUrl(AnyInput); + + // Assert + Assert.Equal($"https://search.brave.com/images?q={this._encodedInput}", actual); + } + + [Fact] + public void BraveNewsSearchUrl() + { + // Arrange + var plugin = new SearchUrlPlugin(); + + // Act + string actual = plugin.BraveNewsSearchUrl(AnyInput); + + // Assert + Assert.Equal($"https://search.brave.com/news?q={this._encodedInput}", actual); + } + + [Fact] + public void BraveGooglesSearchUrl() + { + // Arrange + var plugin = new SearchUrlPlugin(); + + // Act + string actual = plugin.BraveGooglesSearchUrl(AnyInput); + + // Assert + Assert.Equal($"https://search.brave.com/goggles?q={this._encodedInput}", actual); + } + + [Fact] + public void BraveVideosSearchUrl() + { + // Arrange + var plugin = new SearchUrlPlugin(); + + // Act + string actual = plugin.BraveVideosSearchUrl(AnyInput); + + // Assert + Assert.Equal($"https://search.brave.com/videos?q={this._encodedInput}", actual); + } + [Fact] public void FacebookSearchUrl() { diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveConnector.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveConnector.cs new file mode 100644 index 000000000000..08103bbabad2 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveConnector.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.Http; + +namespace Microsoft.SemanticKernel.Plugins.Web.Brave; + +/// +/// Brave API connector. +/// +public sealed class BraveConnector : IWebSearchEngineConnector +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly string? _apiKey; + private readonly Uri? _uri = null; + private const string DefaultUri = "https://api.search.brave.com/res/v1/web/search?q"; + + /// + /// Initializes a new instance of the class. + /// + /// The API key to authenticate the connector. + /// The URI of the Bing Search instance. Defaults to "https://api.bing.microsoft.com/v7.0/search?q". + /// The to use for logging. If null, no logging will be performed. + public BraveConnector(string apiKey, Uri? uri = null, ILoggerFactory? loggerFactory = null) : + this(apiKey, HttpClientProvider.GetHttpClient(), uri, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The API key to authenticate the connector. + /// The HTTP client to use for making requests. + /// The URI of the Bing Search instance. Defaults to "https://api.bing.microsoft.com/v7.0/search?q". + /// The to use for logging. If null, no logging will be performed. + public BraveConnector(string apiKey, HttpClient httpClient, Uri? uri = null, ILoggerFactory? loggerFactory = null) + { + Verify.NotNull(httpClient); + + this._apiKey = apiKey; + this._logger = loggerFactory?.CreateLogger(typeof(BraveConnector)) ?? NullLogger.Instance; + this._httpClient = httpClient; + this._httpClient.DefaultRequestHeaders.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); + this._httpClient.DefaultRequestHeaders.Add(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(BraveConnector))); + this._uri = uri ?? new Uri(DefaultUri); + } + + /// + public async Task> SearchAsync(string query, int count = 1, int offset = 0, CancellationToken cancellationToken = default) + { + Verify.NotNull(query); + + if (count is <= 0 or >= 21) + { + throw new ArgumentOutOfRangeException(nameof(count), count, $"{nameof(count)} value must be greater than 0 and less than 21."); + } + + if (offset is < 0 or > 10) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, $"{nameof(count)} value must be equal or greater than 0 and less than 10."); + } + + Uri uri = new($"{this._uri}={Uri.EscapeDataString(query.Trim())}&count={count}&offset={offset}"); + + this._logger.LogDebug("Sending request: {Uri}", uri); + + using HttpResponseMessage response = await this.SendGetRequestAsync(uri, cancellationToken).ConfigureAwait(false); + + this._logger.LogDebug("Response received: {StatusCode}", response.StatusCode); + + string json = await response.Content.ReadAsStringWithExceptionMappingAsync(cancellationToken).ConfigureAwait(false); + + // Sensitive data, logging as trace, disabled by default + this._logger.LogTrace("Response content received: {Data}", json); + + var data = JsonSerializer.Deserialize>(json); + + List? returnValues = null; + if (data?.Web?.Results is not null) + { + if (typeof(T) == typeof(string)) + { + var results = data?.Web?.Results; + returnValues = results?.Select(x => x.Description).ToList() as List; + } + else if (typeof(T) == typeof(BraveWebResult)) + { + var results = data?.Web?.Results!; + returnValues = results.Take(count).ToList() as List; + } + else if (typeof(T) == typeof(WebPage)) + { + List? webPages = data.Web?.Results + .Select(x => new WebPage() { Name = x.Title, Snippet = x.Description, Url = x.Url }).ToList(); + + returnValues = webPages!.Take(count).ToList() as List; + } + else + { + throw new NotSupportedException($"Type {typeof(T)} is not supported."); + } + } + + if (data?.Videos?.Results is not null) + { + if (typeof(T) == typeof(string)) + { + var results = data?.Videos?.Results; + returnValues = results?.Select(x => x.Description).ToList() as List; + } + else if (typeof(T) == typeof(BraveWebResult)) + { + var results = data?.Videos?.Results!; + returnValues = results.Take(count).ToList() as List; + } + else if (typeof(T) == typeof(WebPage)) + { + List? webPages = data.Videos?.Results + .Select(x => new WebPage() { Name = x.Title, Snippet = x.Description, Url = x.Url }).ToList(); + + returnValues = webPages!.Take(count).ToList() as List; + } + else + { + throw new NotSupportedException($"Type {typeof(T)} is not supported."); + } + } + return + returnValues is null ? [] : + returnValues.Count <= count ? returnValues : + returnValues.Take(count); + } + + /// + /// Sends a GET request to the specified URI. + /// + /// The URI to send the request to. + /// A cancellation token to cancel the request. + /// A representing the response from the request. + private async Task SendGetRequestAsync(Uri uri, CancellationToken cancellationToken = default) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + if (!string.IsNullOrEmpty(this._apiKey)) + { + httpRequestMessage.Headers.Add("X-Subscription-Token", this._apiKey); + } + + return await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveSearchResponse.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveSearchResponse.cs new file mode 100644 index 000000000000..a3c2c921c6a2 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveSearchResponse.cs @@ -0,0 +1,480 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web.Brave; + +#pragma warning disable CA1812 // Instantiated by reflection +/// +/// Brave search response. +/// +public sealed class BraveSearchResponse +{ + /// + /// The type of web search API result. The value is always `search`. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The query string that Brave used for the request. + /// + [JsonPropertyName("query")] + public BraveQuery? Query { get; set; } + + /// + /// Preferred ranked order of search results. + /// + [JsonPropertyName("mixed")] + public MixedResponse? Mixed { get; set; } + + /// + /// News results relevant to the query. + /// + [JsonPropertyName("news")] + public BraveNews? News { get; set; } + /// + /// Videos relevant to the query return by Brave API. + /// + [JsonPropertyName("videos")] + public BraveVideos? Videos { get; set; } + + /// + /// Web search results relevant to the query return by Brave API. + /// + [JsonPropertyName("web")] + public BraveWeb? Web { get; set; } +} + +/// +/// A model representing information gathered around the requested query. +/// +/// +public sealed class BraveQuery +{ + /// + /// The query string as specified in the request. + /// + [JsonPropertyName("original")] + public string Original { get; set; } = string.Empty; + + /// + /// The query string that Brave used to perform the query. Brave uses the altered query string if the original query string contained spelling mistakes. + /// For example, if the query string is saling downwind, the altered query string is sailing downwind. + /// + /// + /// The object includes this field only if the original query string contains a spelling mistake. + /// + [JsonPropertyName("altered")] + public string? Altered { get; set; } + + /// + /// Whether Safe Search was enabled. + /// + [JsonPropertyName("safesearch")] + public bool? IsSafeSearchEnable { get; set; } + + /// + /// Whether there is more content available for query, but the response was restricted due to safesearch. + /// + [JsonPropertyName("show_strict_warning")] + public bool? ShowStrictWarning { get; set; } + + /// + /// Whether the query is a navigational query to a domain. + /// + [JsonPropertyName("is_navigational")] + public bool? IsNavigational { get; set; } + + /// + /// The index of the location . + /// + [JsonPropertyName("local_locations_idx")] + public int? LocalLocationsIdx { get; set; } + + /// + /// Whether the query is trending. + /// + [JsonPropertyName("is_trending")] + public bool? IsTrending { get; set; } + + /// + /// Whether the query has news breaking articles relevant to it. + /// + [JsonPropertyName("is_news_breaking")] + public bool? IsNewsBreaking { get; set; } + + /// + /// Whether the spellchecker was off. + /// + [JsonPropertyName("spellcheck_off")] + public bool? SpellcheckOff { get; set; } + + /// + /// The country that was used. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + + /// + /// Whether there are bad results for the query. + /// + [JsonPropertyName("bad_results")] + public bool? BadResults { get; set; } + + /// + /// Whether the query should use a fallback. + /// + [JsonPropertyName("should_fallback")] + public bool? ShouldFallback { get; set; } + + /// + /// The gathered postal code associated with the query. + /// + [JsonPropertyName("postal_code")] + public string? PostalCode { get; set; } + + /// + /// The gathered city associated with the query. + /// + [JsonPropertyName("city")] + public string? City { get; set; } + + /// + /// The gathered state associated with the query. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// The country for the request origination. + /// + [JsonPropertyName("header_country")] + public string? HeaderCountry { get; set; } + + /// + /// Whether more results are available for the given query. + /// + [JsonPropertyName("more_results_available")] + public bool? MoreResultsAvailable { get; set; } + + /// + /// Any reddit cluster associated with the query. + /// + [JsonPropertyName("reddit_cluster")] + public string? RedditCluster { get; set; } +} + +/// +/// A model representing a video result. +/// +/// +public sealed class BraveVideos +{ + /// + /// The type identifying the video result. + /// + /// Value is always 'video_result' + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// A list of video results + /// + [JsonPropertyName("results")] + public IList Results { get; set; } = []; + + /// + /// Whether the video results are changed by a Goggle. + /// + [JsonPropertyName("mutated_by_goggles")] + public bool? MutatedByGoggles { get; set; } +} + +/// +/// A model representing video results. +/// +public sealed class BraveVideo +{ + /// + /// A time string representing the duration of the video. + /// + /// The format can be HH:MM:SS or MM:SS. + [JsonPropertyName("duration")] + public string Duration { get; set; } = string.Empty; + + /// + /// The number of views of the video. + /// + [JsonPropertyName("views")] + public int? Views { get; set; } + + /// + /// The creator of the video. + /// + [JsonPropertyName("creator")] + public string? Creator { get; set; } + + /// + /// The publisher of the video. + /// + [JsonPropertyName("publisher")] + public string? Publisher { get; set; } + + /// + ///A thumbnail associated with the video. + /// + [JsonPropertyName("thumbnail")] + public Thumbnail? Thumbnail { get; set; } + + /// + ///A list of tags associated with the video. + /// + [JsonPropertyName("tags")] + public IList? Tags { get; set; } + + /// + ///Author of the video. + /// + [JsonPropertyName("author")] + public BraveProfile? AuthorProfile { get; set; } + + /// + /// Whether the video requires a subscription to watch. + /// + [JsonPropertyName("requires_subscription")] + public bool? RequireSubscription { get; set; } +} + +/// +/// A model representing a collection of web search results. +/// +public sealed class BraveWeb +{ + /// + /// A type identifying web search results. The value is always search. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// A list of search results. + /// + [JsonPropertyName("results")] + public IList Results { get; set; } = []; + + /// + /// Whether the results are family friendly. + /// + [JsonPropertyName("family_friendly")] + public bool? FamilyFriendly { get; set; } +} + +/// +/// A model representing news results. +/// +public sealed class BraveNews +{ + /// + /// The type representing the news. The value is always news. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// A list of news results. + /// + [JsonPropertyName("results")] + public IList Results { get; set; } = []; + + /// + /// Whether the news results are changed by a Goggle. False by default + /// + [JsonPropertyName("mutated_by_googles")] + public bool? MutatedByGoogles { get; set; } +} + +/// +/// A result which can be used as a button. +/// +public sealed class Button +{ + /// + /// A result which can be used as a button. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// The title of the result. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The url for the button result. + /// + [JsonPropertyName("url")] +#pragma warning disable CA1056 + public string? Url { get; set; } +#pragma warning restore CA1056 +} + +/// +/// Aggregated deep results from news, social, videos and images. +/// +public sealed class DeepResults +{ + /// + /// A list of buttoned results associated with the result. + /// + [JsonPropertyName("buttons")] + public List