diff --git a/dotnet/samples/Concepts/FunctionCalling/OpenAI_FunctionCalling.cs b/dotnet/samples/Concepts/FunctionCalling/OpenAI_FunctionCalling.cs index 1b817fbc60fe..3acf2bc61b95 100644 --- a/dotnet/samples/Concepts/FunctionCalling/OpenAI_FunctionCalling.cs +++ b/dotnet/samples/Concepts/FunctionCalling/OpenAI_FunctionCalling.cs @@ -233,7 +233,7 @@ public async Task RunNonStreamingPromptWithSimulatedFunctionAsync() } // Adding a simulated function call to the connector response message - FunctionCallContent simulatedFunctionCall = new("weather-alert", id: "call_123"); + FunctionCallContent simulatedFunctionCall = new("weather_alert", id: "call_123"); result.Items.Add(simulatedFunctionCall); // Adding a simulated function result to chat history diff --git a/dotnet/src/Connectors/Connectors.OpenAI/AzureSdk/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAI/AzureSdk/ClientCore.cs index 8059077d8bf4..18594daa58db 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/AzureSdk/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/AzureSdk/ClientCore.cs @@ -51,6 +51,26 @@ internal abstract class ClientCore /// private const int MaxInflightAutoInvokes = 128; + /// + /// The maximum number of function auto-invokes that can be made in a single user request. + /// + /// + /// After this number of iterations as part of a single user request is reached, auto-invocation + /// will be disabled. This is a safeguard against possible runaway execution if the model routinely re-requests + /// the same function over and over. + /// + private const int MaximumAutoInvokeAttempts = 128; + + /// + /// Number of requests that are part of a single user interaction that should include this functions in the request. + /// + /// + /// Once this limit is reached, the functions will no longer be included in subsequent requests that are part of the user operation, e.g. + /// if this is 1, the first request will include the functions, but the subsequent response sending back the functions' result + /// will not include the functions for further use. + /// + private const int MaximumUseAttempts = 1; + /// Singleton tool used when tool call count drops to 0 but we need to supply tools to keep the service happy. private static readonly ChatCompletionsFunctionToolDefinition s_nonInvocableFunctionTool = new() { Name = "NonInvocableTool" }; @@ -384,13 +404,16 @@ internal async Task> GetChatMessageContentsAsy // Convert the incoming execution settings to OpenAI settings. OpenAIPromptExecutionSettings chatExecutionSettings = OpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); - bool autoInvoke = kernel is not null && chatExecutionSettings.ToolCallBehavior?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; ValidateMaxTokens(chatExecutionSettings.MaxTokens); - ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); // Create the Azure SDK ChatCompletionOptions instance from all available information. var chatOptions = this.CreateChatCompletionsOptions(chatExecutionSettings, chat, kernel, this.DeploymentOrModelName); + var functionCallConfiguration = this.ConfigureFunctionCalling(requestIndex: 0, kernel, chatExecutionSettings, chatOptions); + + bool autoInvoke = kernel is not null && functionCallConfiguration?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; + ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); + for (int requestIndex = 1; ; requestIndex++) { // Make the request. @@ -490,7 +513,7 @@ internal async Task> GetChatMessageContentsAsy // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, // then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able // to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check. - if (chatExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && + if (functionCallConfiguration?.AllowAnyRequestedKernelFunction is not true && !IsRequestableTool(chatOptions, openAIFunctionToolCall)) { AddResponseMessage(chatOptions, chat, result: null, "Error: Function call request for a function that wasn't defined.", toolCall, this.Logger); @@ -564,42 +587,15 @@ internal async Task> GetChatMessageContentsAsy } // Update tool use information for the next go-around based on having completed another iteration. - Debug.Assert(chatExecutionSettings.ToolCallBehavior is not null); - - // Set the tool choice to none. If we end up wanting to use tools, we'll reset it to the desired value. - chatOptions.ToolChoice = ChatCompletionsToolChoice.None; - chatOptions.Tools.Clear(); - - if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts) - { - // Don't add any tools as we've reached the maximum attempts limit. - if (this.Logger.IsEnabled(LogLevel.Debug)) - { - this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the tool.", chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts); - } - } - else - { - // Regenerate the tool list as necessary. The invocation of the function(s) could have augmented - // what functions are available in the kernel. - chatExecutionSettings.ToolCallBehavior.ConfigureOptions(kernel, chatOptions); - } - - // Having already sent tools and with tool call information in history, the service can become unhappy ("[] is too short - 'tools'") - // if we don't send any tools in subsequent requests, even if we say not to use any. - if (chatOptions.ToolChoice == ChatCompletionsToolChoice.None) - { - Debug.Assert(chatOptions.Tools.Count == 0); - chatOptions.Tools.Add(s_nonInvocableFunctionTool); - } + functionCallConfiguration = this.ConfigureFunctionCalling(requestIndex, kernel, chatExecutionSettings, chatOptions); // Disable auto invocation if we've exceeded the allowed limit. - if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts) + if (requestIndex >= functionCallConfiguration?.MaximumAutoInvokeAttempts) { autoInvoke = false; if (this.Logger.IsEnabled(LogLevel.Debug)) { - this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts); + this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", functionCallConfiguration?.MaximumAutoInvokeAttempts); } } } @@ -614,14 +610,15 @@ internal async IAsyncEnumerable GetStreamingC Verify.NotNull(chat); OpenAIPromptExecutionSettings chatExecutionSettings = OpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); - ValidateMaxTokens(chatExecutionSettings.MaxTokens); - bool autoInvoke = kernel is not null && chatExecutionSettings.ToolCallBehavior?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; - ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); - var chatOptions = this.CreateChatCompletionsOptions(chatExecutionSettings, chat, kernel, this.DeploymentOrModelName); + var functionCallConfiguration = this.ConfigureFunctionCalling(requestIndex: 0, kernel, chatExecutionSettings, chatOptions); + + bool autoInvoke = kernel is not null && functionCallConfiguration?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; + ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); + StringBuilder? contentBuilder = null; Dictionary? toolCallIdsByIndex = null; Dictionary? functionNamesByIndex = null; @@ -777,7 +774,7 @@ internal async IAsyncEnumerable GetStreamingC // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, // then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able // to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check. - if (chatExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && + if (functionCallConfiguration?.AllowAnyRequestedKernelFunction is not true && !IsRequestableTool(chatOptions, openAIFunctionToolCall)) { AddResponseMessage(chatOptions, chat, result: null, "Error: Function call request for a function that wasn't defined.", toolCall, this.Logger); @@ -854,42 +851,15 @@ internal async IAsyncEnumerable GetStreamingC } // Update tool use information for the next go-around based on having completed another iteration. - Debug.Assert(chatExecutionSettings.ToolCallBehavior is not null); - - // Set the tool choice to none. If we end up wanting to use tools, we'll reset it to the desired value. - chatOptions.ToolChoice = ChatCompletionsToolChoice.None; - chatOptions.Tools.Clear(); - - if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts) - { - // Don't add any tools as we've reached the maximum attempts limit. - if (this.Logger.IsEnabled(LogLevel.Debug)) - { - this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the tool.", chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts); - } - } - else - { - // Regenerate the tool list as necessary. The invocation of the function(s) could have augmented - // what functions are available in the kernel. - chatExecutionSettings.ToolCallBehavior.ConfigureOptions(kernel, chatOptions); - } - - // Having already sent tools and with tool call information in history, the service can become unhappy ("[] is too short - 'tools'") - // if we don't send any tools in subsequent requests, even if we say not to use any. - if (chatOptions.ToolChoice == ChatCompletionsToolChoice.None) - { - Debug.Assert(chatOptions.Tools.Count == 0); - chatOptions.Tools.Add(s_nonInvocableFunctionTool); - } + functionCallConfiguration = this.ConfigureFunctionCalling(requestIndex, kernel, chatExecutionSettings, chatOptions); // Disable auto invocation if we've exceeded the allowed limit. - if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts) + if (requestIndex >= functionCallConfiguration?.MaximumAutoInvokeAttempts) { autoInvoke = false; if (this.Logger.IsEnabled(LogLevel.Debug)) { - this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts); + this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", functionCallConfiguration?.MaximumAutoInvokeAttempts); } } } @@ -1115,7 +1085,6 @@ private ChatCompletionsOptions CreateChatCompletionsOptions( break; } - executionSettings.ToolCallBehavior?.ConfigureOptions(kernel, options); if (executionSettings.TokenSelectionBiases is not null) { foreach (var keyValue in executionSettings.TokenSelectionBiases) @@ -1571,4 +1540,154 @@ await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync(context await functionCallCallback(context).ConfigureAwait(false); } } + + /// + /// Configures the function calling functionality based on the provided parameters. + /// + /// Request sequence index of automatic function invocation process. + /// The to be used for function calling. + /// Execution settings for the completion API. + /// The chat completion options from the Azure.AI.OpenAI package. + private (bool? AllowAnyRequestedKernelFunction, int? MaximumAutoInvokeAttempts)? ConfigureFunctionCalling(int requestIndex, Kernel? kernel, OpenAIPromptExecutionSettings executionSettings, ChatCompletionsOptions chatOptions) + { + (bool? AllowAnyRequestedKernelFunction, int? MaximumAutoInvokeAttempts)? result = null; + + // If neither behavior specified, we don't need to do anything. + if (executionSettings.FunctionChoiceBehavior is null && executionSettings.ToolCallBehavior is null) + { + return result; + } + + // If both behaviors are specified, we can't handle that. + if (executionSettings.FunctionChoiceBehavior is not null && executionSettings.ToolCallBehavior is not null) + { + throw new ArgumentException($"{nameof(executionSettings.ToolCallBehavior)} and {nameof(executionSettings.FunctionChoiceBehavior)} cannot be used together."); + } + + // Set the tool choice to none. If we end up wanting to use tools, we'll set it to the desired value. + chatOptions.ToolChoice = ChatCompletionsToolChoice.None; + chatOptions.Tools.Clear(); + + // Handling new tool behavior represented by `PromptExecutionSettings.FunctionChoiceBehavior` property. + if (executionSettings.FunctionChoiceBehavior is { } functionChoiceBehavior) + { + result = this.ConfigureFunctionCalling(requestIndex, kernel, chatOptions, functionChoiceBehavior); + } + // Handling old-style tool call behavior represented by `OpenAIPromptExecutionSettings.ToolCallBehavior` property. + else if (executionSettings.ToolCallBehavior is { } toolCallBehavior) + { + result = this.ConfigureFunctionCalling(requestIndex, kernel, chatOptions, toolCallBehavior); + } + + // Having already sent tools and with tool call information in history, the service can become unhappy "Invalid 'tools': empty array. Expected an array with minimum length 1, but got an empty array instead." + // if we don't send any tools in subsequent requests, even if we say not to use any. + // Similarly, if we say not to use any tool (ToolChoice = ChatCompletionsToolChoice.None) and dont provide any for the first request, + // the service fails with "'tool_choice' is only allowed when 'tools' are specified." + if (chatOptions.ToolChoice == ChatCompletionsToolChoice.None && chatOptions.Tools.Count == 0) + { + chatOptions.Tools.Add(s_nonInvocableFunctionTool); + } + + return result; + } + + private (bool? AllowAnyRequestedKernelFunction, int? MaximumAutoInvokeAttempts)? ConfigureFunctionCalling(int requestIndex, Kernel? kernel, ChatCompletionsOptions chatOptions, FunctionChoiceBehavior functionChoiceBehavior) + { + // Regenerate the tool list as necessary and getting other call behavior properties. The invocation of the function(s) could have augmented + // what functions are available in the kernel. + var config = functionChoiceBehavior.GetConfiguration(new() { Kernel = kernel }); + + (bool? AllowAnyRequestedKernelFunction, int? MaximumAutoInvokeAttempts) result = new() + { + AllowAnyRequestedKernelFunction = config.AllowAnyRequestedKernelFunction, + MaximumAutoInvokeAttempts = config.Options.AutoInvoke ? MaximumAutoInvokeAttempts : 0, + }; + + if (config.Choice == FunctionChoice.Required && requestIndex >= MaximumUseAttempts) + { + // Don't add any tools as we've reached the maximum use attempts limit. + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the functions.", MaximumUseAttempts); + } + + return result; + } + + if (config.Choice == FunctionChoice.Auto) + { + if (config.Functions is { Count: > 0 } functions) + { + chatOptions.ToolChoice = ChatCompletionsToolChoice.Auto; + + foreach (var function in functions) + { + var functionDefinition = function.Metadata.ToOpenAIFunction().ToFunctionDefinition(); + chatOptions.Tools.Add(new ChatCompletionsFunctionToolDefinition(functionDefinition)); + } + } + + return result; + } + + if (config.Choice == FunctionChoice.Required) + { + if (config.Functions is { Count: > 0 } functions) + { + if (functions.Count > 1) + { + throw new KernelException("Only one required function is allowed."); + } + + var functionDefinition = functions[0].Metadata.ToOpenAIFunction().ToFunctionDefinition(); + + chatOptions.ToolChoice = new ChatCompletionsToolChoice(functionDefinition); + chatOptions.Tools.Add(new ChatCompletionsFunctionToolDefinition(functionDefinition)); + } + + return result; + } + + if (config.Choice == FunctionChoice.None) + { + if (config.Functions is { Count: > 0 } functions) + { + chatOptions.ToolChoice = ChatCompletionsToolChoice.None; + + foreach (var function in functions) + { + var functionDefinition = function.Metadata.ToOpenAIFunction().ToFunctionDefinition(); + chatOptions.Tools.Add(new ChatCompletionsFunctionToolDefinition(functionDefinition)); + } + } + + return result; + } + + throw new NotSupportedException($"Unsupported function choice '{config.Choice}'."); + } + + private (bool? AllowAnyRequestedKernelFunction, int? MaximumAutoInvokeAttempts)? ConfigureFunctionCalling(int requestIndex, Kernel? kernel, ChatCompletionsOptions chatOptions, ToolCallBehavior toolCallBehavior) + { + if (requestIndex >= toolCallBehavior.MaximumUseAttempts) + { + // Don't add any tools as we've reached the maximum attempts limit. + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the tools.", toolCallBehavior.MaximumUseAttempts); + } + } + else + { + // Regenerate the tool list as necessary. The invocation of the function(s) could have augmented + // what functions are available in the kernel. + toolCallBehavior.ConfigureOptions(kernel, chatOptions); + } + + return new() + { + AllowAnyRequestedKernelFunction = toolCallBehavior.AllowAnyRequestedKernelFunction, + MaximumAutoInvokeAttempts = toolCallBehavior.MaximumAutoInvokeAttempts, + }; + } } diff --git a/dotnet/src/Connectors/Connectors.OpenAI/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/OpenAIPromptExecutionSettings.cs index 36796c62f7b9..8707adde442c 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/OpenAIPromptExecutionSettings.cs @@ -343,6 +343,7 @@ public override PromptExecutionSettings Clone() ResponseFormat = this.ResponseFormat, TokenSelectionBiases = this.TokenSelectionBiases is not null ? new Dictionary(this.TokenSelectionBiases) : null, ToolCallBehavior = this.ToolCallBehavior, + FunctionChoiceBehavior = this.FunctionChoiceBehavior, User = this.User, ChatSystemPrompt = this.ChatSystemPrompt, Logprobs = this.Logprobs, @@ -382,6 +383,8 @@ public static OpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutio var openAIExecutionSettings = JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive); if (openAIExecutionSettings is not null) { + // Restores the original function choice behavior that lost internal state(list of functions) during serialization/deserialization process. + openAIExecutionSettings.FunctionChoiceBehavior = executionSettings.FunctionChoiceBehavior; return openAIExecutionSettings; } diff --git a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs index 22be8458c2cc..625b9f30f228 100644 --- a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs @@ -936,6 +936,105 @@ public async Task FunctionResultsCanBeProvidedToLLMAsManyResultsInOneChatMessage Assert.Equal("2", assistantMessage2.GetProperty("tool_call_id").GetString()); } + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingAutoFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("TimePlugin", [ + KernelFunctionFactory.CreateFromMethod(() => { }, "Date"), + KernelFunctionFactory.CreateFromMethod(() => { }, "Now") + ]); + + var chatCompletion = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(OpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(2, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Now", optionsJson.GetProperty("tools")[1].GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("auto", optionsJson.GetProperty("tool_choice").ToString()); + } + + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingRequiredFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("TimePlugin", [ + KernelFunctionFactory.CreateFromMethod(() => { }, "Date"), + ]); + + var chatCompletion = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(OpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Required() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(1, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tool_choice").GetProperty("function").GetProperty("name").ToString()); + } + + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingNoneFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("TimePlugin", [ + KernelFunctionFactory.CreateFromMethod(() => { }, "Date"), + KernelFunctionFactory.CreateFromMethod(() => { }, "Now") + ]); + + var chatCompletion = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(OpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.None() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(2, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Now", optionsJson.GetProperty("tools")[1].GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("none", optionsJson.GetProperty("tool_choice").ToString()); + } + public void Dispose() { this._httpClient.Dispose(); diff --git a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/OpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/OpenAIChatCompletionServiceTests.cs index 7d1c47388f91..3aa34d54af45 100644 --- a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/OpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/ChatCompletion/OpenAIChatCompletionServiceTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -27,7 +28,8 @@ public sealed class OpenAIChatCompletionServiceTests : IDisposable { private readonly HttpMessageHandlerStub _messageHandlerStub; private readonly HttpClient _httpClient; - private readonly OpenAIFunction _timepluginDate, _timepluginNow; + private readonly KernelPlugin _plugin; + private readonly KernelFunction _timepluginDate, _timepluginNow; private readonly OpenAIPromptExecutionSettings _executionSettings; private readonly Mock _mockLoggerFactory; @@ -37,18 +39,21 @@ public OpenAIChatCompletionServiceTests() this._httpClient = new HttpClient(this._messageHandlerStub, false); this._mockLoggerFactory = new Mock(); - IList functions = KernelPluginFactory.CreateFromFunctions("TimePlugin", new[] + this._plugin = KernelPluginFactory.CreateFromFunctions("TimePlugin", new[] { KernelFunctionFactory.CreateFromMethod((string? format = null) => DateTime.Now.Date.ToString(format, CultureInfo.InvariantCulture), "Date", "TimePlugin.Date"), KernelFunctionFactory.CreateFromMethod((string? format = null) => DateTime.Now.ToString(format, CultureInfo.InvariantCulture), "Now", "TimePlugin.Now"), - }).GetFunctionsMetadata(); + }); - this._timepluginDate = functions[0].ToOpenAIFunction(); - this._timepluginNow = functions[1].ToOpenAIFunction(); + this._timepluginDate = this._plugin.ElementAt(0); + this._timepluginNow = this._plugin.ElementAt(1); this._executionSettings = new() { - ToolCallBehavior = ToolCallBehavior.EnableFunctions([this._timepluginDate, this._timepluginNow]) + ToolCallBehavior = ToolCallBehavior.EnableFunctions([ + this._timepluginDate.Metadata.ToOpenAIFunction(), + this._timepluginNow.Metadata.ToOpenAIFunction() + ]) }; } @@ -161,7 +166,7 @@ public async Task ItCreatesCorrectFunctionToolCallsWhenUsingNowAsync() var chatCompletion = new OpenAIChatCompletionService(modelId: "gpt-3.5-turbo", apiKey: "NOKEY", httpClient: this._httpClient); this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(ChatCompletionResponse) }; - this._executionSettings.ToolCallBehavior = ToolCallBehavior.RequireFunction(this._timepluginNow); + this._executionSettings.ToolCallBehavior = ToolCallBehavior.RequireFunction(this._timepluginNow.Metadata.ToOpenAIFunction()); // Act await chatCompletion.GetChatMessageContentsAsync([], this._executionSettings); @@ -587,6 +592,100 @@ public async Task FunctionResultsCanBeProvidedToLLMAsManyResultsInOneChatMessage Assert.Equal("2", assistantMessage2.GetProperty("tool_call_id").GetString()); } + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingAutoFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.Add(this._plugin); + + var chatCompletion = new OpenAIChatCompletionService(modelId: "gpt-3.5-turbo", apiKey: "NOKEY", httpClient: this._httpClient); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse) + }; + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(2, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Now", optionsJson.GetProperty("tools")[1].GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("auto", optionsJson.GetProperty("tool_choice").ToString()); + } + + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingRequiredFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("TimePlugin", [this._timepluginDate]); + + var chatCompletion = new OpenAIChatCompletionService(modelId: "gpt-3.5-turbo", apiKey: "NOKEY", httpClient: this._httpClient); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse) + }; + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Required() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(1, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tool_choice").GetProperty("function").GetProperty("name").ToString()); + } + + [Fact] + public async Task ItCreatesCorrectFunctionToolCallsWhenUsingNoneFunctionChoiceBehaviorAsync() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("TimePlugin", [ + KernelFunctionFactory.CreateFromMethod(() => { }, "Date"), + KernelFunctionFactory.CreateFromMethod(() => { }, "Now") + ]); + + var chatCompletion = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(ChatCompletionResponse) + }; + + var executionSettings = new OpenAIPromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.None() }; + + // Act + await chatCompletion.GetChatMessageContentsAsync([], executionSettings, kernel); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + Assert.Equal(2, optionsJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("TimePlugin-Date", optionsJson.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString()); + Assert.Equal("TimePlugin-Now", optionsJson.GetProperty("tools")[1].GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("none", optionsJson.GetProperty("tool_choice").ToString()); + } + public void Dispose() { this._httpClient.Dispose(); diff --git a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/OpenAIPromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/OpenAIPromptExecutionSettingsTests.cs index b64649230d96..c29d060b5117 100644 --- a/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/OpenAIPromptExecutionSettingsTests.cs +++ b/dotnet/src/Connectors/Connectors.UnitTests/OpenAI/OpenAIPromptExecutionSettingsTests.cs @@ -256,6 +256,22 @@ public void FromExecutionSettingsWithDataDoesNotIncludeEmptyStopSequences() Assert.Null(executionSettingsWithData.StopSequences); } + [Fact] + public void ItRestoresOriginalFunctionChoiceBehavior() + { + // Arrange + var functionChoiceBehavior = FunctionChoiceBehavior.None(); + + var originalExecutionSettings = new PromptExecutionSettings(); + originalExecutionSettings.FunctionChoiceBehavior = functionChoiceBehavior; + + // Act + var result = OpenAIPromptExecutionSettings.FromExecutionSettings(originalExecutionSettings); + + // Assert + Assert.Equal(functionChoiceBehavior, result.FunctionChoiceBehavior); + } + private static void AssertExecutionSettings(OpenAIPromptExecutionSettings executionSettings) { Assert.NotNull(executionSettings); diff --git a/dotnet/src/Functions/Functions.UnitTests/Markdown/Functions/KernelFunctionMarkdownTests.cs b/dotnet/src/Functions/Functions.UnitTests/Markdown/Functions/KernelFunctionMarkdownTests.cs index a277284f3ccc..221752578bf6 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Markdown/Functions/KernelFunctionMarkdownTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Markdown/Functions/KernelFunctionMarkdownTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using Microsoft.SemanticKernel; using Xunit; @@ -18,9 +19,62 @@ public void ItShouldCreatePromptFunctionConfigFromMarkdown() Assert.NotNull(model); Assert.Equal("TellMeAbout", model.Name); Assert.Equal("Hello AI, tell me about {{$input}}", model.Template); - Assert.Equal(2, model.ExecutionSettings.Count); + Assert.Equal(3, model.ExecutionSettings.Count); Assert.Equal("gpt4", model.ExecutionSettings["service1"].ModelId); Assert.Equal("gpt3.5", model.ExecutionSettings["service2"].ModelId); + Assert.Equal("gpt3.5-turbo", model.ExecutionSettings["service3"].ModelId); + } + + [Fact] + public void ItShouldInitializeFunctionChoiceBehaviorsFromMarkdown() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("p1", [KernelFunctionFactory.CreateFromMethod(() => { }, "f1")]); + kernel.Plugins.AddFromFunctions("p2", [KernelFunctionFactory.CreateFromMethod(() => { }, "f2")]); + kernel.Plugins.AddFromFunctions("p3", [KernelFunctionFactory.CreateFromMethod(() => { }, "f3")]); + + // Act + var function = KernelFunctionMarkdown.CreateFromPromptMarkdown(Markdown, "TellMeAbout"); + + // Assert + Assert.NotNull(function); + Assert.NotEmpty(function.ExecutionSettings); + + Assert.Equal(3, function.ExecutionSettings.Count); + + // AutoFunctionCallChoice for service1 + var service1ExecutionSettings = function.ExecutionSettings["service1"]; + Assert.NotNull(service1ExecutionSettings?.FunctionChoiceBehavior); + + var autoConfig = service1ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(autoConfig); + Assert.Equal(FunctionChoice.Auto, autoConfig.Choice); + Assert.NotNull(autoConfig.Functions); + Assert.Equal("p1", autoConfig.Functions.Single().PluginName); + Assert.Equal("f1", autoConfig.Functions.Single().Name); + + // RequiredFunctionCallChoice for service2 + var service2ExecutionSettings = function.ExecutionSettings["service2"]; + Assert.NotNull(service2ExecutionSettings?.FunctionChoiceBehavior); + + var requiredConfig = service2ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(requiredConfig); + Assert.Equal(FunctionChoice.Required, requiredConfig.Choice); + Assert.NotNull(requiredConfig.Functions); + Assert.Equal("p2", requiredConfig.Functions.Single().PluginName); + Assert.Equal("f2", requiredConfig.Functions.Single().Name); + + // NoneFunctionCallChoice for service3 + var service3ExecutionSettings = function.ExecutionSettings["service3"]; + Assert.NotNull(service3ExecutionSettings?.FunctionChoiceBehavior); + + var noneConfig = service3ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(noneConfig); + Assert.Equal(FunctionChoice.None, noneConfig.Choice); + Assert.NotNull(noneConfig.Functions); + Assert.Equal("p3", noneConfig.Functions.Single().PluginName); + Assert.Equal("f3", noneConfig.Functions.Single().Name); } [Fact] @@ -47,7 +101,11 @@ These are AI execution settings { "service1" : { "model_id": "gpt4", - "temperature": 0.7 + "temperature": 0.7, + "function_choice_behavior": { + "type": "auto", + "functions": ["p1.f1"] + } } } ``` @@ -56,7 +114,24 @@ These are more AI execution settings { "service2" : { "model_id": "gpt3.5", - "temperature": 0.8 + "temperature": 0.8, + "function_choice_behavior": { + "type": "required", + "functions": ["p2.f2"] + } + } + } + ``` + These are AI execution settings as well + ```sk.execution_settings + { + "service3" : { + "model_id": "gpt3.5-turbo", + "temperature": 0.8, + "function_choice_behavior": { + "type": "none", + "functions": ["p3.f3"] + } } } ``` diff --git a/dotnet/src/Functions/Functions.UnitTests/Yaml/Functions/KernelFunctionYamlTests.cs b/dotnet/src/Functions/Functions.UnitTests/Yaml/Functions/KernelFunctionYamlTests.cs index 30bce2a3fac2..d898822893ca 100644 --- a/dotnet/src/Functions/Functions.UnitTests/Yaml/Functions/KernelFunctionYamlTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/Yaml/Functions/KernelFunctionYamlTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; using Xunit; @@ -68,7 +69,7 @@ public void ItShouldSupportCreatingOpenAIExecutionSettings() // Arrange var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) - .WithNodeDeserializer(new PromptExecutionSettingsNodeDeserializer()) + .WithTypeConverter(new PromptExecutionSettingsTypeConverter()) .Build(); var promptFunctionModel = deserializer.Deserialize(this._yaml); @@ -82,6 +83,55 @@ public void ItShouldSupportCreatingOpenAIExecutionSettings() Assert.Equal(0.0, executionSettings.TopP); } + [Fact] + public void ItShouldDeserializeFunctionChoiceBehaviors() + { + // Act + var promptTemplateConfig = KernelFunctionYaml.ToPromptTemplateConfig(this._yaml); + + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("p1", [KernelFunctionFactory.CreateFromMethod(() => { }, "f1")]); + kernel.Plugins.AddFromFunctions("p2", [KernelFunctionFactory.CreateFromMethod(() => { }, "f2")]); + kernel.Plugins.AddFromFunctions("p3", [KernelFunctionFactory.CreateFromMethod(() => { }, "f3")]); + + // Assert + Assert.NotNull(promptTemplateConfig?.ExecutionSettings); + Assert.Equal(3, promptTemplateConfig.ExecutionSettings.Count); + + // Service with auto function choice behavior + var service1ExecutionSettings = promptTemplateConfig.ExecutionSettings["service1"]; + Assert.NotNull(service1ExecutionSettings?.FunctionChoiceBehavior); + + var autoConfig = service1ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(autoConfig); + Assert.Equal(FunctionChoice.Auto, autoConfig.Choice); + Assert.NotNull(autoConfig.Functions); + Assert.Equal("p1", autoConfig.Functions.Single().PluginName); + Assert.Equal("f1", autoConfig.Functions.Single().Name); + + // Service with required function choice behavior + var service2ExecutionSettings = promptTemplateConfig.ExecutionSettings["service2"]; + Assert.NotNull(service2ExecutionSettings?.FunctionChoiceBehavior); + + var requiredConfig = service2ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(requiredConfig); + Assert.Equal(FunctionChoice.Required, requiredConfig.Choice); + Assert.NotNull(requiredConfig.Functions); + Assert.Equal("p2", requiredConfig.Functions.Single().PluginName); + Assert.Equal("f2", requiredConfig.Functions.Single().Name); + + // Service with none function choice behavior + var service3ExecutionSettings = promptTemplateConfig.ExecutionSettings["service3"]; + Assert.NotNull(service3ExecutionSettings?.FunctionChoiceBehavior); + + var noneConfig = service3ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(noneConfig); + Assert.Equal(FunctionChoice.None, noneConfig.Choice); + Assert.NotNull(noneConfig.Functions); + Assert.Equal("p3", noneConfig.Functions.Single().PluginName); + Assert.Equal("f3", noneConfig.Functions.Single().Name); + } + [Fact] public void ItShouldCreateFunctionWithDefaultValueOfStringType() { @@ -157,6 +207,10 @@ string CreateYaml(object defaultValue) frequency_penalty: 0.0 max_tokens: 256 stop_sequences: [] + function_choice_behavior: + type: auto + functions: + - p1.f1 service2: model_id: gpt-3.5 temperature: 1.0 @@ -165,6 +219,22 @@ string CreateYaml(object defaultValue) frequency_penalty: 0.0 max_tokens: 256 stop_sequences: [ "foo", "bar", "baz" ] + function_choice_behavior: + type: required + functions: + - p2.f2 + service3: + model_id: gpt-3.5 + temperature: 1.0 + top_p: 0.0 + presence_penalty: 0.0 + frequency_penalty: 0.0 + max_tokens: 256 + stop_sequences: [ "foo", "bar", "baz" ] + function_choice_behavior: + type: none + functions: + - p3.f3 """; private readonly string _yamlWithCustomSettings = """ diff --git a/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsNodeDeserializerTests.cs b/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsNodeDeserializerTests.cs deleted file mode 100644 index 140de66fdaa8..000000000000 --- a/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsNodeDeserializerTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.SemanticKernel; -using Xunit; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace SemanticKernel.Functions.UnitTests.Yaml; - -/// -/// Tests for . -/// -public sealed class PromptExecutionSettingsNodeDeserializerTests -{ - [Fact] - public void ItShouldCreatePromptFunctionFromYamlWithCustomModelSettings() - { - // Arrange - var deserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .WithNodeDeserializer(new PromptExecutionSettingsNodeDeserializer()) - .Build(); - - // Act - var semanticFunctionConfig = deserializer.Deserialize(this._yaml); - - // Assert - Assert.NotNull(semanticFunctionConfig); - Assert.Equal("SayHello", semanticFunctionConfig.Name); - Assert.Equal("Say hello to the specified person using the specified language", semanticFunctionConfig.Description); - Assert.Equal(2, semanticFunctionConfig.InputVariables.Count); - Assert.Equal("language", semanticFunctionConfig.InputVariables[1].Name); - Assert.Equal(2, semanticFunctionConfig.ExecutionSettings.Count); - Assert.Equal("gpt-4", semanticFunctionConfig.ExecutionSettings["service1"].ModelId); - Assert.Equal("gpt-3.5", semanticFunctionConfig.ExecutionSettings["service2"].ModelId); - } - - private readonly string _yaml = """ - template_format: semantic-kernel - template: Say hello world to {{$name}} in {{$language}} - description: Say hello to the specified person using the specified language - name: SayHello - input_variables: - - name: name - description: The name of the person to greet - default: John - - name: language - description: The language to generate the greeting in - default: English - execution_settings: - service1: - model_id: gpt-4 - temperature: 1.0 - top_p: 0.0 - presence_penalty: 0.0 - frequency_penalty: 0.0 - max_tokens: 256 - stop_sequences: [] - service2: - model_id: gpt-3.5 - temperature: 1.0 - top_p: 0.0 - presence_penalty: 0.0 - frequency_penalty: 0.0 - max_tokens: 256 - stop_sequences: [ "foo", "bar", "baz" ] - """; -} diff --git a/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsTypeConverterTests.cs b/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsTypeConverterTests.cs new file mode 100644 index 000000000000..2ef79ef0e850 --- /dev/null +++ b/dotnet/src/Functions/Functions.UnitTests/Yaml/PromptExecutionSettingsTypeConverterTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.SemanticKernel; +using Xunit; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace SemanticKernel.Functions.UnitTests.Yaml; + +/// +/// Tests for . +/// +public sealed class PromptExecutionSettingsTypeConverterTests +{ + [Fact] + public void ItShouldCreatePromptFunctionFromYamlWithCustomModelSettings() + { + // Arrange + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .WithTypeConverter(new PromptExecutionSettingsTypeConverter()) + .Build(); + + // Act + var semanticFunctionConfig = deserializer.Deserialize(this._yaml); + + // Assert + Assert.NotNull(semanticFunctionConfig); + Assert.Equal("SayHello", semanticFunctionConfig.Name); + Assert.Equal("Say hello to the specified person using the specified language", semanticFunctionConfig.Description); + Assert.Equal(2, semanticFunctionConfig.InputVariables.Count); + Assert.Equal("language", semanticFunctionConfig.InputVariables[1].Name); + Assert.Equal(3, semanticFunctionConfig.ExecutionSettings.Count); + Assert.Equal("gpt-4", semanticFunctionConfig.ExecutionSettings["service1"].ModelId); + Assert.Equal("gpt-3.5", semanticFunctionConfig.ExecutionSettings["service2"].ModelId); + Assert.Equal("gpt-3.5-turbo", semanticFunctionConfig.ExecutionSettings["service3"].ModelId); + } + + [Fact] + public void ItShouldDeserializeFunctionChoiceBehaviors() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromFunctions("p1", [KernelFunctionFactory.CreateFromMethod(() => { }, "f1")]); + kernel.Plugins.AddFromFunctions("p2", [KernelFunctionFactory.CreateFromMethod(() => { }, "f2")]); + kernel.Plugins.AddFromFunctions("p3", [KernelFunctionFactory.CreateFromMethod(() => { }, "f3")]); + + // Act + var promptTemplateConfig = KernelFunctionYaml.ToPromptTemplateConfig(this._yaml); + + // Assert + Assert.NotNull(promptTemplateConfig?.ExecutionSettings); + Assert.Equal(3, promptTemplateConfig.ExecutionSettings.Count); + + // Service with auto function choice behavior + var service1ExecutionSettings = promptTemplateConfig.ExecutionSettings["service1"]; + Assert.NotNull(service1ExecutionSettings?.FunctionChoiceBehavior); + + var autoConfig = service1ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(autoConfig); + Assert.Equal(FunctionChoice.Auto, autoConfig.Choice); + Assert.NotNull(autoConfig.Functions); + Assert.Equal("p1", autoConfig.Functions.Single().PluginName); + Assert.Equal("f1", autoConfig.Functions.Single().Name); + + // Service with required function choice behavior + var service2ExecutionSettings = promptTemplateConfig.ExecutionSettings["service2"]; + Assert.NotNull(service2ExecutionSettings?.FunctionChoiceBehavior); + + var requiredConfig = service2ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(requiredConfig); + Assert.Equal(FunctionChoice.Required, requiredConfig.Choice); + Assert.NotNull(requiredConfig.Functions); + Assert.Equal("p2", requiredConfig.Functions.Single().PluginName); + Assert.Equal("f2", requiredConfig.Functions.Single().Name); + + // Service with none function choice behavior + var service3ExecutionSettings = promptTemplateConfig.ExecutionSettings["service3"]; + Assert.NotNull(service3ExecutionSettings?.FunctionChoiceBehavior); + + var noneConfig = service3ExecutionSettings.FunctionChoiceBehavior.GetConfiguration(new FunctionChoiceBehaviorContext() { Kernel = kernel }); + Assert.NotNull(noneConfig); + Assert.Equal(FunctionChoice.None, noneConfig.Choice); + Assert.NotNull(noneConfig.Functions); + Assert.Equal("p3", noneConfig.Functions.Single().PluginName); + Assert.Equal("f3", noneConfig.Functions.Single().Name); + } + + private readonly string _yaml = """ + template_format: semantic-kernel + template: Say hello world to {{$name}} in {{$language}} + description: Say hello to the specified person using the specified language + name: SayHello + input_variables: + - name: name + description: The name of the person to greet + default: John + - name: language + description: The language to generate the greeting in + default: English + execution_settings: + service1: + model_id: gpt-4 + temperature: 1.0 + top_p: 0.0 + presence_penalty: 0.0 + frequency_penalty: 0.0 + max_tokens: 256 + stop_sequences: [] + function_choice_behavior: + type: auto + functions: + - p1.f1 + service2: + model_id: gpt-3.5 + temperature: 1.0 + top_p: 0.0 + presence_penalty: 0.0 + frequency_penalty: 0.0 + max_tokens: 256 + stop_sequences: [ "foo", "bar", "baz" ] + function_choice_behavior: + type: required + functions: + - p2.f2 + service3: + model_id: gpt-3.5-turbo + temperature: 1.0 + top_p: 0.0 + presence_penalty: 0.0 + frequency_penalty: 0.0 + max_tokens: 256 + stop_sequences: [ "foo", "bar", "baz" ] + function_choice_behavior: + type: none + functions: + - p3.f3 + """; +} diff --git a/dotnet/src/Functions/Functions.Yaml/KernelFunctionYaml.cs b/dotnet/src/Functions/Functions.Yaml/KernelFunctionYaml.cs index ec2a26fc2b61..863d991bb207 100644 --- a/dotnet/src/Functions/Functions.Yaml/KernelFunctionYaml.cs +++ b/dotnet/src/Functions/Functions.Yaml/KernelFunctionYaml.cs @@ -57,7 +57,7 @@ public static PromptTemplateConfig ToPromptTemplateConfig(string text) { var deserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) - .WithNodeDeserializer(new PromptExecutionSettingsNodeDeserializer()) + .WithTypeConverter(new PromptExecutionSettingsTypeConverter()) .Build(); return deserializer.Deserialize(text); diff --git a/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsNodeDeserializer.cs b/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsNodeDeserializer.cs deleted file mode 100644 index 5bd7b839b068..000000000000 --- a/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsNodeDeserializer.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using YamlDotNet.Core; -using YamlDotNet.Serialization; - -namespace Microsoft.SemanticKernel; - -/// -/// Deserializer for . -/// -internal sealed class PromptExecutionSettingsNodeDeserializer : INodeDeserializer -{ - /// - public bool Deserialize(IParser reader, Type expectedType, Func nestedObjectDeserializer, out object? value) - { - if (expectedType != typeof(PromptExecutionSettings)) - { - value = null; - return false; - } - - var dictionary = nestedObjectDeserializer.Invoke(reader, typeof(Dictionary)); - var modelSettings = new PromptExecutionSettings(); - foreach (var kv in (Dictionary)dictionary!) - { - switch (kv.Key) - { - case "model_id": - modelSettings.ModelId = (string)kv.Value; - break; - - default: - (modelSettings.ExtensionData ??= new Dictionary()).Add(kv.Key, kv.Value); - break; - } - } - - value = modelSettings; - return true; - } -} diff --git a/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsTypeConverter.cs b/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsTypeConverter.cs new file mode 100644 index 000000000000..3f128806c145 --- /dev/null +++ b/dotnet/src/Functions/Functions.Yaml/PromptExecutionSettingsTypeConverter.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.BufferedDeserialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Microsoft.SemanticKernel; + +/// +/// Allows custom deserialization for from YAML prompts. +/// +internal sealed class PromptExecutionSettingsTypeConverter : IYamlTypeConverter +{ + private static IDeserializer? s_deserializer; + + /// + public bool Accepts(Type type) + { + return type == typeof(PromptExecutionSettings); + } + + /// + public object? ReadYaml(IParser parser, Type type) + { + s_deserializer ??= new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() // Required to ignore the 'type' property used as type discrimination. Otherwise, the "Property 'type' not found on type '{type.FullName}'" exception is thrown. + .WithTypeDiscriminatingNodeDeserializer(ConfigureTypeDiscriminatingNodeDeserializer) + .Build(); + + parser.MoveNext(); // Move to the first property + + var executionSettings = new PromptExecutionSettings(); + while (parser.Current is not MappingEnd) + { + var propertyName = parser.Consume().Value; + switch (propertyName) + { + case "model_id": + executionSettings.ModelId = s_deserializer.Deserialize(parser); + break; + case "function_choice_behavior": +#pragma warning disable SKEXP0001 + executionSettings.FunctionChoiceBehavior = s_deserializer.Deserialize(parser); +#pragma warning restore SKEXP0010 + break; + default: + (executionSettings.ExtensionData ??= new Dictionary()).Add(propertyName, s_deserializer.Deserialize(parser)); + break; + } + } + parser.MoveNext(); // Move past the MappingEnd event + return executionSettings; + } + + /// + public void WriteYaml(IEmitter emitter, object? value, Type type) + { + throw new NotImplementedException(); + } + + private static void ConfigureTypeDiscriminatingNodeDeserializer(ITypeDiscriminatingNodeDeserializerOptions options) + { +#pragma warning disable SKEXP0001 + var attributes = typeof(FunctionChoiceBehavior).GetCustomAttributes(false); + + // Getting the type discriminator property name - "type" from the JsonPolymorphicAttribute. + var discriminatorKey = attributes.OfType().Single().TypeDiscriminatorPropertyName; + if (string.IsNullOrEmpty(discriminatorKey)) + { + throw new InvalidOperationException("Type discriminator property name is not specified."); + } + + var discriminatorTypeMapping = new Dictionary(); + + // Getting FunctionChoiceBehavior subtypes and their type discriminators registered for polymorphic deserialization. + var derivedTypeAttributes = attributes.OfType(); + foreach (var derivedTypeAttribute in derivedTypeAttributes) + { + var discriminator = derivedTypeAttribute.TypeDiscriminator?.ToString(); + if (string.IsNullOrEmpty(discriminator)) + { + throw new InvalidOperationException($"Type discriminator is not specified for the {derivedTypeAttribute.DerivedType} type."); + } + + discriminatorTypeMapping.Add(discriminator!, derivedTypeAttribute.DerivedType); + } + + options.AddKeyValueTypeDiscriminator(discriminatorKey!, discriminatorTypeMapping); +#pragma warning restore SKEXP0010 + } +} diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAutoFunctionChoiceBehaviorTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAutoFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..cdc84f54ed9e --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIAutoFunctionChoiceBehaviorTests.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using SemanticKernel.IntegrationTests.Planners.Stepwise; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAIAutoFunctionChoiceBehaviorTests : BaseIntegrationTest +{ + private readonly Kernel _kernel; + private readonly FakeFunctionFilter _autoFunctionInvocationFilter; + + public OpenAIAutoFunctionChoiceBehaviorTests() + { + this._autoFunctionInvocationFilter = new FakeFunctionFilter(); + + this._kernel = this.InitializeKernel(); + this._kernel.AutoFunctionInvocationFilters.Add(this._autoFunctionInvocationFilter); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionAutomaticallyAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = true }) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorToInvokeKernelFunctionAutomaticallyAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: auto + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + // Act + var result = await this._kernel.InvokeAsync(promptFunction); + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionManuallyAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = false }) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + + var responseContent = result.GetValue(); + Assert.NotNull(responseContent); + + var functionCalls = FunctionCallContent.GetFunctionCalls(responseContent); + Assert.NotNull(functionCalls); + Assert.Single(functionCalls); + + var functionCall = functionCalls.First(); + Assert.Equal("DateTimeUtils", functionCall.PluginName); + Assert.Equal("GetCurrentDate", functionCall.FunctionName); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionAutomaticallyForStreamingAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = true }) }; + + string result = ""; + + // Act + await foreach (string c in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorToInvokeKernelFunctionAutomaticallyForStreamingAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: auto + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + string result = ""; + + // Act + await foreach (string c in promptFunction.InvokeStreamingAsync(this._kernel)) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionManuallyForStreamingAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var functionsForManualInvocation = new List(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = false }) }; + + // Act + await foreach (var content in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + if (content.ToolCallUpdate is StreamingFunctionToolCallUpdate functionUpdate && !string.IsNullOrEmpty(functionUpdate.Name)) + { + functionsForManualInvocation.Add(functionUpdate.Name); + } + } + + // Assert + Assert.Single(functionsForManualInvocation); + Assert.Contains("DateTimeUtils-GetCurrentDate", functionsForManualInvocation); + + Assert.Empty(invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeNonKernelFunctionManuallyAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); // Creating plugin without importing it to the kernel. + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto([plugin.ElementAt(1)], autoInvoke: false) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + + var responseContent = result.GetValue(); + Assert.NotNull(responseContent); + + var functionCalls = FunctionCallContent.GetFunctionCalls(responseContent); + Assert.NotNull(functionCalls); + Assert.Single(functionCalls); + + var functionCall = functionCalls.First(); + Assert.Equal("DateTimeUtils", functionCall.PluginName); + Assert.Equal("GetCurrentDate", functionCall.FunctionName); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeNonKernelFunctionManuallyForStreamingAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); // Creating plugin without importing it to the kernel. + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var functionsForManualInvocation = new List(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto([plugin.ElementAt(1)], autoInvoke: false) }; + + // Act + await foreach (var content in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + if (content.ToolCallUpdate is StreamingFunctionToolCallUpdate functionUpdate && !string.IsNullOrEmpty(functionUpdate.Name)) + { + functionsForManualInvocation.Add(functionUpdate.Name); + } + } + + // Assert + Assert.Single(functionsForManualInvocation); + Assert.Contains("DateTimeUtils-GetCurrentDate", functionsForManualInvocation); + + Assert.Empty(invokedFunctions); + } + + private Kernel InitializeKernel() + { + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("Planners:OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + + IKernelBuilder builder = this.CreateKernelBuilder() + .AddOpenAIChatCompletion( + modelId: openAIConfiguration.ModelId, + apiKey: openAIConfiguration.ApiKey); + + return builder.Build(); + } + + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + /// + /// A plugin that returns the current time. + /// + public class DateTimeUtils + { + [KernelFunction] + [Description("Retrieves the current time in UTC.")] + public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R"); + + [KernelFunction] + [Description("Retrieves the current date.")] + public string GetCurrentDate() => DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture); + } + + #region private + + private sealed class FakeFunctionFilter : IAutoFunctionInvocationFilter + { + private Func, Task>? _onFunctionInvocation; + + public void RegisterFunctionInvocationHandler(Func, Task> onFunctionInvocation) + { + this._onFunctionInvocation = onFunctionInvocation; + } + + public Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + if (this._onFunctionInvocation is null) + { + return next(context); + } + + return this._onFunctionInvocation?.Invoke(context, next) ?? Task.CompletedTask; + } + } + + #endregion +} diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAINoneFunctionChoiceBehaviorTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAINoneFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..557072f69ba8 --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAINoneFunctionChoiceBehaviorTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using SemanticKernel.IntegrationTests.Planners.Stepwise; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAINoneFunctionChoiceBehaviorTests : BaseIntegrationTest +{ + private readonly Kernel _kernel; + private readonly FakeFunctionFilter _autoFunctionInvocationFilter; + + public OpenAINoneFunctionChoiceBehaviorTests() + { + this._autoFunctionInvocationFilter = new FakeFunctionFilter(); + + this._kernel = this.InitializeKernel(); + this._kernel.AutoFunctionInvocationFilters.Add(this._autoFunctionInvocationFilter); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorNotToInvokeKernelFunctionAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.None() }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorNotToInvokeKernelFunctionAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: none + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + // Act + var result = await this._kernel.InvokeAsync(promptFunction); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorNotToInvokeKernelFunctionForStreamingAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.None() }; + + string result = ""; + + // Act + await foreach (string c in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorNotToInvokeKernelFunctionForStreamingAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: none + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + string result = ""; + + // Act + await foreach (string c in promptFunction.InvokeStreamingAsync(this._kernel)) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + } + + private Kernel InitializeKernel() + { + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("Planners:OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + + IKernelBuilder builder = this.CreateKernelBuilder() + .AddOpenAIChatCompletion( + modelId: openAIConfiguration.ModelId, + apiKey: openAIConfiguration.ApiKey); + + return builder.Build(); + } + + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + /// + /// A plugin that returns the current time. + /// + public class DateTimeUtils + { + [KernelFunction] + [Description("Retrieves the current time in UTC.")] + public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R"); + + [KernelFunction] + [Description("Retrieves the current date.")] + public string GetCurrentDate() => DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture); + } + + #region private + + private sealed class FakeFunctionFilter : IAutoFunctionInvocationFilter + { + private Func, Task>? _onFunctionInvocation; + + public void RegisterFunctionInvocationHandler(Func, Task> onFunctionInvocation) + { + this._onFunctionInvocation = onFunctionInvocation; + } + + public Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + if (this._onFunctionInvocation is null) + { + return next(context); + } + + return this._onFunctionInvocation?.Invoke(context, next) ?? Task.CompletedTask; + } + } + + #endregion +} diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIRequiredFunctionChoiceBehaviorTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIRequiredFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..efc8e4fa68fd --- /dev/null +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIRequiredFunctionChoiceBehaviorTests.cs @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using SemanticKernel.IntegrationTests.Planners.Stepwise; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Connectors.OpenAI; + +public sealed class OpenAIRequiredFunctionChoiceBehaviorTests : BaseIntegrationTest +{ + private readonly Kernel _kernel; + private readonly FakeFunctionFilter _autoFunctionInvocationFilter; + + public OpenAIRequiredFunctionChoiceBehaviorTests() + { + this._autoFunctionInvocationFilter = new FakeFunctionFilter(); + + this._kernel = this.InitializeKernel(); + this._kernel.AutoFunctionInvocationFilters.Add(this._autoFunctionInvocationFilter); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionAutomaticallyAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: true) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorToInvokeKernelFunctionAutomaticallyAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: required + functions: + - DateTimeUtils.GetCurrentDate + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + // Act + var result = await this._kernel.InvokeAsync(promptFunction); + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionManuallyAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: false) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + + var responseContent = result.GetValue(); + Assert.NotNull(responseContent); + + var functionCalls = FunctionCallContent.GetFunctionCalls(responseContent); + Assert.NotNull(functionCalls); + Assert.Single(functionCalls); + + var functionCall = functionCalls.First(); + Assert.Equal("DateTimeUtils", functionCall.PluginName); + Assert.Equal("GetCurrentDate", functionCall.FunctionName); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionAutomaticallyForStreamingAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: true) }; + + string result = ""; + + // Act + await foreach (string c in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInPromptInstructsConnectorToInvokeKernelFunctionAutomaticallyForStreamingAsync() + { + // Arrange + this._kernel.ImportPluginFromType(); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var promptTemplate = """" + template_format: semantic-kernel + template: How many days until Christmas? + execution_settings: + default: + temperature: 0.1 + function_choice_behavior: + type: required + functions: + - DateTimeUtils.GetCurrentDate + """"; + + var promptFunction = KernelFunctionYaml.FromPromptYaml(promptTemplate); + + string result = ""; + + // Act + await foreach (string c in promptFunction.InvokeStreamingAsync(this._kernel)) + { + result += c; + } + + // Assert + Assert.NotNull(result); + + Assert.Single(invokedFunctions); + Assert.Contains("GetCurrentDate", invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeKernelFunctionManuallyForStreamingAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); + this._kernel.Plugins.Add(plugin); + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var functionsForManualInvocation = new List(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: false) }; + + // Act + await foreach (var content in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + if (content.ToolCallUpdate is StreamingFunctionToolCallUpdate functionUpdate && !string.IsNullOrEmpty(functionUpdate.Name)) + { + functionsForManualInvocation.Add(functionUpdate.Name); + } + } + + // Assert + Assert.Single(functionsForManualInvocation); + Assert.Contains("DateTimeUtils-GetCurrentDate", functionsForManualInvocation); + + Assert.Empty(invokedFunctions); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeNonKernelFunctionManuallyAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); // Creating plugin without importing it to the kernel. + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + // Act + var settings = new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: false) }; + + var result = await this._kernel.InvokePromptAsync("How many days until Christmas?", new(settings)); + + // Assert + Assert.NotNull(result); + + Assert.Empty(invokedFunctions); + + var responseContent = result.GetValue(); + Assert.NotNull(responseContent); + + var functionCalls = FunctionCallContent.GetFunctionCalls(responseContent); + Assert.NotNull(functionCalls); + Assert.Single(functionCalls); + + var functionCall = functionCalls.First(); + Assert.Equal("DateTimeUtils", functionCall.PluginName); + Assert.Equal("GetCurrentDate", functionCall.FunctionName); + } + + [Fact] + public async Task SpecifiedInCodeInstructsConnectorToInvokeNonKernelFunctionManuallyForStreamingAsync() + { + // Arrange + var plugin = this._kernel.CreatePluginFromType(); // Creating plugin without importing it to the kernel. + + var invokedFunctions = new List(); + + this._autoFunctionInvocationFilter.RegisterFunctionInvocationHandler(async (context, next) => + { + invokedFunctions.Add(context.Function.Name); + await next(context); + }); + + var functionsForManualInvocation = new List(); + + var settings = new PromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Required([plugin.ElementAt(1)], autoInvoke: false) }; + + // Act + await foreach (var content in this._kernel.InvokePromptStreamingAsync("How many days until Christmas?", new(settings))) + { + if (content.ToolCallUpdate is StreamingFunctionToolCallUpdate functionUpdate && !string.IsNullOrEmpty(functionUpdate.Name)) + { + functionsForManualInvocation.Add(functionUpdate.Name); + } + } + + // Assert + Assert.Single(functionsForManualInvocation); + Assert.Contains("DateTimeUtils-GetCurrentDate", functionsForManualInvocation); + + Assert.Empty(invokedFunctions); + } + + private Kernel InitializeKernel() + { + OpenAIConfiguration? openAIConfiguration = this._configuration.GetSection("Planners:OpenAI").Get(); + Assert.NotNull(openAIConfiguration); + + IKernelBuilder builder = this.CreateKernelBuilder() + .AddOpenAIChatCompletion( + modelId: openAIConfiguration.ModelId, + apiKey: openAIConfiguration.ApiKey); + + return builder.Build(); + } + + private readonly IConfigurationRoot _configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets() + .Build(); + + /// + /// A plugin that returns the current time. + /// + public class DateTimeUtils + { + [KernelFunction] + [Description("Retrieves the current time in UTC.")] + public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R"); + + [KernelFunction] + [Description("Retrieves the current date.")] + public string GetCurrentDate() => DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture); + } + + #region private + + private sealed class FakeFunctionFilter : IAutoFunctionInvocationFilter + { + private Func, Task>? _onFunctionInvocation; + + public void RegisterFunctionInvocationHandler(Func, Task> onFunctionInvocation) + { + this._onFunctionInvocation = onFunctionInvocation; + } + + public Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) + { + if (this._onFunctionInvocation is null) + { + return next(context); + } + + return this._onFunctionInvocation?.Invoke(context, next) ?? Task.CompletedTask; + } + } + + #endregion +} diff --git a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIToolsTests.cs b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIToolsTests.cs index 049287fbbc14..c75c198b6001 100644 --- a/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIToolsTests.cs +++ b/dotnet/src/IntegrationTests/Connectors/OpenAI/OpenAIToolsTests.cs @@ -357,7 +357,7 @@ public async Task ConnectorAgnosticFunctionCallingModelClassesSupportSimulatedFu } // Adding a simulated function call to the connector response message - var simulatedFunctionCall = new FunctionCallContent("weather-alert", id: "call_123"); + var simulatedFunctionCall = new FunctionCallContent("weather_alert", id: "call_123"); messageContent.Items.Add(simulatedFunctionCall); // Adding a simulated function result to chat history @@ -420,44 +420,92 @@ public async Task ConnectorAgnosticFunctionCallingModelClassesCanBeUsedForAutoFu Assert.Equal(AuthorRole.User, userMessage.Role); // LLM requested the current time. - var getCurrentTimeFunctionCallRequestMessage = chatHistory[1]; - Assert.Equal(AuthorRole.Assistant, getCurrentTimeFunctionCallRequestMessage.Role); + var getCurrentTimeFunctionCallMessage = chatHistory[1]; + Assert.Equal(AuthorRole.Assistant, getCurrentTimeFunctionCallMessage.Role); - var getCurrentTimeFunctionCallRequest = getCurrentTimeFunctionCallRequestMessage.Items.OfType().Single(); - Assert.Equal("GetCurrentUtcTime", getCurrentTimeFunctionCallRequest.FunctionName); - Assert.Equal("HelperFunctions", getCurrentTimeFunctionCallRequest.PluginName); - Assert.NotNull(getCurrentTimeFunctionCallRequest.Id); + var getCurrentTimeFunctionCall = getCurrentTimeFunctionCallMessage.Items.OfType().Single(); + Assert.Equal("GetCurrentUtcTime", getCurrentTimeFunctionCall.FunctionName); + Assert.Equal("HelperFunctions", getCurrentTimeFunctionCall.PluginName); + Assert.NotNull(getCurrentTimeFunctionCall.Id); // Connector invoked the GetCurrentUtcTime function and added result to chat history. - var getCurrentTimeFunctionCallResultMessage = chatHistory[2]; - Assert.Equal(AuthorRole.Tool, getCurrentTimeFunctionCallResultMessage.Role); - Assert.Single(getCurrentTimeFunctionCallResultMessage.Items.OfType()); // Current function calling model adds TextContent item representing the result of the function call. + var getCurrentTimeFunctionResultMessage = chatHistory[2]; + Assert.Equal(AuthorRole.Tool, getCurrentTimeFunctionResultMessage.Role); + Assert.Single(getCurrentTimeFunctionResultMessage.Items.OfType()); // Current function calling model adds TextContent item representing the result of the function call. - var getCurrentTimeFunctionCallResult = getCurrentTimeFunctionCallResultMessage.Items.OfType().Single(); - Assert.Equal("GetCurrentUtcTime", getCurrentTimeFunctionCallResult.FunctionName); - Assert.Equal("HelperFunctions", getCurrentTimeFunctionCallResult.PluginName); - Assert.Equal(getCurrentTimeFunctionCallRequest.Id, getCurrentTimeFunctionCallResult.CallId); - Assert.NotNull(getCurrentTimeFunctionCallResult.Result); + var getCurrentTimeFunctionResult = getCurrentTimeFunctionResultMessage.Items.OfType().Single(); + Assert.Equal("GetCurrentUtcTime", getCurrentTimeFunctionResult.FunctionName); + Assert.Equal("HelperFunctions", getCurrentTimeFunctionResult.PluginName); + Assert.Equal(getCurrentTimeFunctionCall.Id, getCurrentTimeFunctionResult.CallId); + Assert.NotNull(getCurrentTimeFunctionResult.Result); // LLM requested the weather for Boston. - var getWeatherForCityFunctionCallRequestMessage = chatHistory[3]; - Assert.Equal(AuthorRole.Assistant, getWeatherForCityFunctionCallRequestMessage.Role); + var getWeatherForCityFunctionCallMessage = chatHistory[3]; + Assert.Equal(AuthorRole.Assistant, getWeatherForCityFunctionCallMessage.Role); - var getWeatherForCityFunctionCallRequest = getWeatherForCityFunctionCallRequestMessage.Items.OfType().Single(); - Assert.Equal("Get_Weather_For_City", getWeatherForCityFunctionCallRequest.FunctionName); - Assert.Equal("HelperFunctions", getWeatherForCityFunctionCallRequest.PluginName); - Assert.NotNull(getWeatherForCityFunctionCallRequest.Id); + var getWeatherForCityFunctionCall = getWeatherForCityFunctionCallMessage.Items.OfType().Single(); + Assert.Equal("Get_Weather_For_City", getWeatherForCityFunctionCall.FunctionName); + Assert.Equal("HelperFunctions", getWeatherForCityFunctionCall.PluginName); + Assert.NotNull(getWeatherForCityFunctionCall.Id); // Connector invoked the Get_Weather_For_City function and added result to chat history. - var getWeatherForCityFunctionCallResultMessage = chatHistory[4]; - Assert.Equal(AuthorRole.Tool, getWeatherForCityFunctionCallResultMessage.Role); - Assert.Single(getWeatherForCityFunctionCallResultMessage.Items.OfType()); // Current function calling model adds TextContent item representing the result of the function call. + var getWeatherForCityFunctionResultMessage = chatHistory[4]; + Assert.Equal(AuthorRole.Tool, getWeatherForCityFunctionResultMessage.Role); + Assert.Single(getWeatherForCityFunctionResultMessage.Items.OfType()); // Current function calling model adds TextContent item representing the result of the function call. + + var getWeatherForCityFunctionResult = getWeatherForCityFunctionResultMessage.Items.OfType().Single(); + Assert.Equal("Get_Weather_For_City", getWeatherForCityFunctionResult.FunctionName); + Assert.Equal("HelperFunctions", getWeatherForCityFunctionResult.PluginName); + Assert.Equal(getWeatherForCityFunctionCall.Id, getWeatherForCityFunctionResult.CallId); + Assert.NotNull(getWeatherForCityFunctionResult.Result); + } - var getWeatherForCityFunctionCallResult = getWeatherForCityFunctionCallResultMessage.Items.OfType().Single(); - Assert.Equal("Get_Weather_For_City", getWeatherForCityFunctionCallResult.FunctionName); - Assert.Equal("HelperFunctions", getWeatherForCityFunctionCallResult.PluginName); - Assert.Equal(getWeatherForCityFunctionCallRequest.Id, getWeatherForCityFunctionCallResult.CallId); - Assert.NotNull(getWeatherForCityFunctionCallResult.Result); + [Fact] + public async Task SubsetOfFunctionsCanBeUsedForFunctionCallingAsync() + { + // Arrange + var kernel = this.InitializeKernel(importHelperPlugin: false); + + var function = kernel.CreateFunctionFromMethod(() => DayOfWeek.Friday, "GetDayOfWeek", "Retrieves the current day of the week."); + kernel.ImportPluginFromFunctions("HelperFunctions", [function]); + + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("What day is today?"); + + var settings = new OpenAIPromptExecutionSettings() { ToolCallBehavior = ToolCallBehavior.EnableFunctions([function.Metadata.ToOpenAIFunction()], true) }; + + var sut = kernel.GetRequiredService(); + + // Act + var result = await sut.GetChatMessageContentAsync(chatHistory, settings, kernel); + + // Assert + Assert.NotNull(result); + Assert.Contains("Friday", result.Content, StringComparison.InvariantCulture); + } + + [Fact] + public async Task RequiredFunctionShouldBeCalledAsync() + { + // Arrange + var kernel = this.InitializeKernel(importHelperPlugin: false); + + var function = kernel.CreateFunctionFromMethod(() => DayOfWeek.Friday, "GetDayOfWeek", "Retrieves the current day of the week."); + kernel.ImportPluginFromFunctions("HelperFunctions", [function]); + + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("What day is today?"); + + var settings = new OpenAIPromptExecutionSettings() { ToolCallBehavior = ToolCallBehavior.RequireFunction(function.Metadata.ToOpenAIFunction(), true) }; + + var sut = kernel.GetRequiredService(); + + // Act + var result = await sut.GetChatMessageContentAsync(chatHistory, settings, kernel); + + // Assert + Assert.NotNull(result); + Assert.Contains("Friday", result.Content, StringComparison.InvariantCulture); } [Fact] @@ -689,7 +737,7 @@ public async Task ConnectorAgnosticFunctionCallingModelClassesSupportSimulatedFu } // Adding a simulated function call to the connector response message - var simulatedFunctionCall = new FunctionCallContent("weather-alert", id: "call_123"); + var simulatedFunctionCall = new FunctionCallContent("weather_alert", id: "call_123"); fcContent.Items.Add(simulatedFunctionCall); // Adding a simulated function result to chat history diff --git a/dotnet/src/InternalUtilities/src/Functions/FunctionName.cs b/dotnet/src/InternalUtilities/src/Functions/FunctionName.cs index 76f54de92a56..02d7e67f56d9 100644 --- a/dotnet/src/InternalUtilities/src/Functions/FunctionName.cs +++ b/dotnet/src/InternalUtilities/src/Functions/FunctionName.cs @@ -28,7 +28,8 @@ internal sealed class FunctionName /// The plugin name. public FunctionName(string name, string? pluginName = null) { - Verify.NotNull(name); + Verify.ValidFunctionName(name); + if (pluginName is not null) { Verify.ValidPluginName(pluginName); } this.Name = name; this.PluginName = pluginName; @@ -43,6 +44,9 @@ public FunctionName(string name, string? pluginName = null) /// Fully-qualified name of the function. public static string ToFullyQualifiedName(string functionName, string? pluginName = null, string functionNameSeparator = "-") { + Verify.ValidFunctionName(functionName); + if (pluginName is not null) { Verify.ValidPluginName(pluginName); } + return string.IsNullOrEmpty(pluginName) ? functionName : $"{pluginName}{functionNameSeparator}{functionName}"; } diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehavior.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehavior.cs new file mode 100644 index 000000000000..2eca7d9dc715 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehavior.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents that provides either all of the 's plugins' function information to the model or a specified subset. +/// This behavior allows the model to decide whether to call the functions and, if so, which ones to call. +/// +internal sealed class AutoFunctionChoiceBehavior : FunctionChoiceBehavior +{ + /// + /// List of the functions that the model can choose from. + /// + private readonly IEnumerable? _functions; + + /// + /// The behavior options. + /// + private readonly FunctionChoiceBehaviorOptions _options; + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public AutoFunctionChoiceBehavior() + { + this._options = new FunctionChoiceBehaviorOptions(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// The behavior options. + public AutoFunctionChoiceBehavior(IEnumerable? functions = null, FunctionChoiceBehaviorOptions? options = null) + { + this._functions = functions; + this.Functions = functions?.Select(f => FunctionName.ToFullyQualifiedName(f.Name, f.PluginName, FunctionNameSeparator)).ToList(); + this._options = options ?? new FunctionChoiceBehaviorOptions(); + } + + /// + /// Fully qualified names of subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// + [JsonPropertyName("functions")] + public IList? Functions { get; set; } + + /// + public override FunctionChoiceBehaviorConfiguration GetConfiguration(FunctionChoiceBehaviorContext context) + { + (IReadOnlyList? functions, bool allowAnyRequestedKernelFunction) = base.GetFunctions(this.Functions, this._functions, context.Kernel, this._options.AutoInvoke); + + return new FunctionChoiceBehaviorConfiguration(this._options) + { + Choice = FunctionChoice.Auto, + Functions = functions, + AllowAnyRequestedKernelFunction = allowAnyRequestedKernelFunction + }; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoice.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoice.cs new file mode 100644 index 000000000000..59eb0e1e5eba --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoice.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents an AI model's decision-making strategy for calling functions, offering predefined choices: Auto, Required, and None. +/// Auto allows the model to decide if and which functions to call, Required enforces calling one or more functions, and None prevents any function calls, generating only a user-facing message. +/// +public readonly struct FunctionChoice : IEquatable +{ + /// + /// This choice instructs the model to decide whether to call the functions or not and, if so, which ones to call. + /// + public static FunctionChoice Auto { get; } = new("auto"); + + /// + /// This choice forces the model to always call one or more functions. The model will then select which function(s) to call. + /// + public static FunctionChoice Required { get; } = new("required"); + + /// + /// This behavior forces the model to not call any functions and only generate a user-facing message. + /// + public static FunctionChoice None { get; } = new("none"); + + /// + /// Gets the label associated with this FunctionChoice. + /// + public string Label { get; } + + /// + /// Creates a new FunctionChoice instance with the provided label. + /// + /// The label to associate with this FunctionChoice. + public FunctionChoice(string label) + { + Verify.NotNullOrWhiteSpace(label, nameof(label)); + this.Label = label!; + } + + /// + /// Returns a value indicating whether two FunctionChoice instances are equivalent, as determined by a + /// case-insensitive comparison of their labels. + /// + /// the first FunctionChoice instance to compare + /// the second FunctionChoice instance to compare + /// true if left and right are both null or have equivalent labels; false otherwise + public static bool operator ==(FunctionChoice left, FunctionChoice right) + => left.Equals(right); + + /// + /// Returns a value indicating whether two FunctionChoice instances are not equivalent, as determined by a + /// case-insensitive comparison of their labels. + /// + /// the first FunctionChoice instance to compare + /// the second FunctionChoice instance to compare + /// false if left and right are both null or have equivalent labels; true otherwise + public static bool operator !=(FunctionChoice left, FunctionChoice right) + => !(left == right); + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is FunctionChoice other && this == other; + + /// + public bool Equals(FunctionChoice other) + => string.Equals(this.Label, other.Label, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() + => StringComparer.OrdinalIgnoreCase.GetHashCode(this.Label); + + /// + public override string ToString() => this.Label; +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehavior.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehavior.cs new file mode 100644 index 000000000000..419c02bfbca5 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehavior.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents the base class for different function choice behaviors. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(AutoFunctionChoiceBehavior), typeDiscriminator: "auto")] +[JsonDerivedType(typeof(RequiredFunctionChoiceBehavior), typeDiscriminator: "required")] +[JsonDerivedType(typeof(NoneFunctionChoiceBehavior), typeDiscriminator: "none")] +public abstract class FunctionChoiceBehavior +{ + /// The separator used to separate plugin name and function name. + protected const string FunctionNameSeparator = "."; + + /// + /// Creates a new instance of the class. + /// + internal FunctionChoiceBehavior() + { + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior allows the model to decide whether to call the functions and, if so, which ones to call. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// Indicates whether the functions should be automatically invoked by the AI service/connector. + /// An instance of one of the . + public static FunctionChoiceBehavior Auto(IEnumerable? functions, bool autoInvoke) + { + return new AutoFunctionChoiceBehavior(functions, new FunctionChoiceBehaviorOptions() { AutoInvoke = autoInvoke }); + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior allows the model to decide whether to call the functions and, if so, which ones to call. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// The options for the behavior. + /// An instance of one of the . + public static FunctionChoiceBehavior Auto(IEnumerable? functions = null, FunctionChoiceBehaviorOptions? options = null) + { + return new AutoFunctionChoiceBehavior(functions, options); + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior forces the model to always call one or more functions. The model will then select which function(s) to call. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// Indicates whether the functions should be automatically invoked by the AI service/connector. + /// An instance of one of the . + public static FunctionChoiceBehavior Required(IEnumerable? functions, bool autoInvoke) + { + return new RequiredFunctionChoiceBehavior(functions, new FunctionChoiceBehaviorOptions { AutoInvoke = autoInvoke }); + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior forces the model to always call one or more functions. The model will then select which function(s) to call. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// The options for the behavior. + /// An instance of one of the . + public static FunctionChoiceBehavior Required(IEnumerable? functions = null, FunctionChoiceBehaviorOptions? options = null) + { + return new RequiredFunctionChoiceBehavior(functions, options); + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior forces the model to not call any functions and only generate a user-facing message. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// Indicates whether the functions should be automatically invoked by the AI service/connector. + /// An instance of one of the . + /// + /// Although this behavior prevents the model from calling any functions, the model can use the provided function information + /// to describe how it would complete the prompt if it had the ability to call the functions. + /// + public static FunctionChoiceBehavior None(IEnumerable? functions, bool autoInvoke) + { + return new NoneFunctionChoiceBehavior(functions, new FunctionChoiceBehaviorOptions { AutoInvoke = autoInvoke }); + } + + /// + /// Gets an instance of the that provides either all of the 's plugins' function information to the model or a specified subset. + /// This behavior forces the model to not call any functions and only generate a user-facing message. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// The options for the behavior. + /// An instance of one of the . + /// + /// Although this behavior prevents the model from calling any functions, the model can use the provided function information + /// to describe how it would complete the prompt if it had the ability to call the functions. + /// + public static FunctionChoiceBehavior None(IEnumerable? functions = null, FunctionChoiceBehaviorOptions? options = null) + { + return new NoneFunctionChoiceBehavior(functions, options); + } + + /// Returns the configuration specified by the . + /// The function choice caller context. + /// The configuration. + public abstract FunctionChoiceBehaviorConfiguration GetConfiguration(FunctionChoiceBehaviorContext context); + + /// + /// Returns the functions that the model can choose from. + /// + /// Functions provided as fully qualified names. + /// Functions provided as instances of . + /// /// The to be used for function calling. + /// Indicates whether the functions should be automatically invoked by the AI service/connector. + /// The functions that the model can choose from and a flag indicating whether any requested kernel function is allowed to be called. + private protected (IReadOnlyList? Functions, bool AllowAnyRequestedKernelFunction) GetFunctions(IList? functionFQNs, IEnumerable? functions, Kernel? kernel, bool autoInvoke) + { + // If auto-invocation is specified, we need a kernel to be able to invoke the functions. + // Lack of a kernel is fatal: we don't want to tell the model we can handle the functions + // and then fail to do so, so we fail before we get to that point. This is an error + // on the consumers behalf: if they specify auto-invocation with any functions, they must + // specify the kernel and the kernel must contain those functions. + if (autoInvoke && kernel is null) + { + throw new KernelException("Auto-invocation is not supported when no kernel is provided."); + } + + List? availableFunctions = null; + bool allowAnyRequestedKernelFunction = false; + + if (functionFQNs is { Count: > 0 }) + { + availableFunctions = new List(functionFQNs.Count); + + foreach (var functionFQN in functionFQNs) + { + var nameParts = FunctionName.Parse(functionFQN, FunctionNameSeparator); + + // Look up the function in the kernel. + if (kernel is not null && kernel.Plugins.TryGetFunction(nameParts.PluginName, nameParts.Name, out var function)) + { + availableFunctions.Add(function); + continue; + } + + // If auto-invocation is requested and no function is found in the kernel, fail early. + if (autoInvoke) + { + throw new KernelException($"The specified function {functionFQN} is not available in the kernel."); + } + + // Look up the function in the list of functions provided as instances of KernelFunction. + function = functions?.FirstOrDefault(f => f.Name == nameParts.Name && f.PluginName == nameParts.PluginName); + if (function is not null) + { + availableFunctions.Add(function); + continue; + } + + throw new KernelException($"The specified function {functionFQN} was not found."); + } + } + // Provide all functions from the kernel. + else if (kernel is not null) + { + allowAnyRequestedKernelFunction = true; + + foreach (var plugin in kernel.Plugins) + { + (availableFunctions ??= new List(kernel.Plugins.Count)).AddRange(plugin); + } + } + + return new(availableFunctions, allowAnyRequestedKernelFunction); + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorConfiguration.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorConfiguration.cs new file mode 100644 index 000000000000..cdf8fbb4a0d6 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorConfiguration.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents function choice behavior configuration produced by a . +/// +public sealed class FunctionChoiceBehaviorConfiguration +{ + /// + /// Creates a new instance of the class. + /// The options for the behavior." + /// + internal FunctionChoiceBehaviorConfiguration(FunctionChoiceBehaviorOptions options) + { + this.Options = options; + } + + /// + /// Represents an AI model's decision-making strategy for calling functions. + /// + public FunctionChoice Choice { get; internal set; } + + /// + /// The functions available for AI model. + /// + public IReadOnlyList? Functions { get; internal set; } + + /// + /// The behavior options. + /// + public FunctionChoiceBehaviorOptions Options { get; } + + /// + /// Specifies whether validation against a specified list of functions is required before allowing the model to request a function from the kernel. + /// + public bool? AllowAnyRequestedKernelFunction { get; internal set; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorContext.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorContext.cs new file mode 100644 index 000000000000..49c2ce1eb6c9 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorContext.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel; + +/// +/// The context to be provided by the choice behavior consumer in order to obtain the choice behavior configuration. +/// +public sealed class FunctionChoiceBehaviorContext +{ + /// + /// The to be used for function calling. + /// + public Kernel? Kernel { get; init; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorOptions.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorOptions.cs new file mode 100644 index 000000000000..d46096b6e963 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel; + +/// +/// Represents the options for a function choice behavior. +/// +public sealed class FunctionChoiceBehaviorOptions +{ + /// + /// Indicates whether the functions should be automatically invoked by the AI service/connector. + /// + public bool AutoInvoke { get; set; } = true; +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehavior.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehavior.cs new file mode 100644 index 000000000000..1def71f30b2a --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehavior.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents that provides either all of the 's plugins' function information to the model or a specified subset. +/// This behavior forces the model to not call any functions and only generate a user-facing message. +/// +/// +/// Although this behavior prevents the model from calling any functions, the model can use the provided function information +/// to describe how it would complete the prompt if it had the ability to call the functions. +/// +internal sealed class NoneFunctionChoiceBehavior : FunctionChoiceBehavior +{ + /// + /// List of the functions that the model can choose from. + /// + private readonly IEnumerable? _functions; + + /// + /// The behavior options. + /// + private readonly FunctionChoiceBehaviorOptions _options; + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public NoneFunctionChoiceBehavior() + { + this._options = new FunctionChoiceBehaviorOptions(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// The behavior options. + /// If null or empty, all 's plugins' functions are provided to the model. + public NoneFunctionChoiceBehavior(IEnumerable? functions, FunctionChoiceBehaviorOptions? options = null) + { + this._functions = functions; + this.Functions = functions?.Select(f => FunctionName.ToFullyQualifiedName(f.Name, f.PluginName, FunctionNameSeparator)).ToList(); + this._options = options ?? new FunctionChoiceBehaviorOptions(); + } + + /// + /// Fully qualified names of subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// + [JsonPropertyName("functions")] + public IList? Functions { get; set; } + + /// + public override FunctionChoiceBehaviorConfiguration GetConfiguration(FunctionChoiceBehaviorContext context) + { + (IReadOnlyList? functions, _) = base.GetFunctions(this.Functions, this._functions, context.Kernel, autoInvoke: false); + + return new FunctionChoiceBehaviorConfiguration(this._options) + { + Choice = FunctionChoice.None, + Functions = functions, + }; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehavior.cs b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehavior.cs new file mode 100644 index 000000000000..ecb767eccbeb --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehavior.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel; + +/// +/// Represents that provides either all of the 's plugins' function information to the model or a specified subset. +/// This behavior forces the model to always call one or more functions. The model will then select which function(s) to call. +/// +internal sealed class RequiredFunctionChoiceBehavior : FunctionChoiceBehavior +{ + /// + /// List of the functions that the model can choose from. + /// + private readonly IEnumerable? _functions; + + /// + /// The behavior options. + /// + private readonly FunctionChoiceBehaviorOptions _options; + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public RequiredFunctionChoiceBehavior() + { + this._options = new FunctionChoiceBehaviorOptions(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The subset of the 's plugins' functions to provide to the model. + /// The behavior options."" + /// If null or empty, all 's plugins' functions are provided to the model. + public RequiredFunctionChoiceBehavior(IEnumerable? functions = null, FunctionChoiceBehaviorOptions? options = null) + { + this._functions = functions; + this.Functions = functions?.Select(f => FunctionName.ToFullyQualifiedName(f.Name, f.PluginName, FunctionNameSeparator)).ToList(); + this._options = options ?? new FunctionChoiceBehaviorOptions(); + } + + /// + /// Fully qualified names of subset of the 's plugins' functions to provide to the model. + /// If null or empty, all 's plugins' functions are provided to the model. + /// + [JsonPropertyName("functions")] + public IList? Functions { get; set; } + + /// + public override FunctionChoiceBehaviorConfiguration GetConfiguration(FunctionChoiceBehaviorContext context) + { + (IReadOnlyList? functions, bool allowAnyRequestedKernelFunction) = base.GetFunctions(this.Functions, this._functions, context.Kernel, this._options.AutoInvoke); + + return new FunctionChoiceBehaviorConfiguration(this._options) + { + Choice = FunctionChoice.Required, + Functions = functions, + AllowAnyRequestedKernelFunction = allowAnyRequestedKernelFunction + }; + } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs index f10ccaa3ff39..f439ee0f4977 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/PromptExecutionSettings.cs @@ -65,6 +65,47 @@ public string? ModelId } } + /// + /// Gets or sets the behavior for how functions are chosen by the model and how their calls are handled. + /// + /// + /// + /// To disable function calling, and have the model only generate a user-facing message, set the property to null (the default). + /// + /// To allow the model to decide whether to call the functions and, if so, which ones to call, set the property to an instance returned + /// from method. By default, all functions in the will be available. + /// To limit the functions available, pass a list of the functions when calling the method. + /// + /// + /// To force the model to always call one or more functions, set the property to an instance returned + /// from method. By default, all functions in the will be available. + /// To limit the functions available, pass a list of the functions when calling the method. + /// + /// + /// To force the model to not call any functions and only generate a user-facing message, set the property to an instance returned + /// from property. By default, all functions in the will be available. + /// To limit the functions available, pass a list of the functions when calling the method. + /// + /// + /// For all the behaviors that presume the model to call functions, auto-invoke behavior may be selected. If the service + /// sends a request for a function call, if auto-invoke has been requested, the client will attempt to + /// resolve that function from the functions available, and if found, rather + /// than returning the response back to the caller, it will handle the request automatically, invoking + /// the function, and sending back the result. The intermediate messages will be retained in the provided . + /// + [JsonPropertyName("function_choice_behavior")] + [Experimental("SKEXP0001")] + public FunctionChoiceBehavior? FunctionChoiceBehavior + { + get => this._functionChoiceBehavior; + + set + { + this.ThrowIfFrozen(); + this._functionChoiceBehavior = value; + } + } + /// /// Extra properties that may be included in the serialized execution settings. /// @@ -116,6 +157,7 @@ public virtual PromptExecutionSettings Clone() { ModelId = this.ModelId, ServiceId = this.ServiceId, + FunctionChoiceBehavior = this.FunctionChoiceBehavior, ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null }; } @@ -137,6 +179,7 @@ protected void ThrowIfFrozen() private string? _modelId; private IDictionary? _extensionData; private string? _serviceId; + private FunctionChoiceBehavior? _functionChoiceBehavior; #endregion } diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehaviorTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..c43810b3c406 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/AutoFunctionChoiceBehaviorTests.cs @@ -0,0 +1,280 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI.FunctionChoiceBehaviors; + +/// +/// Unit tests for +/// +public sealed class AutoFunctionChoiceBehaviorTests +{ + private readonly Kernel _kernel; + + public AutoFunctionChoiceBehaviorTests() + { + this._kernel = new Kernel(); + } + + [Fact] + public void ItShouldAdvertiseAllKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedViaConstructor() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedInFunctionsProperty() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior() + { + Functions = ["MyPlugin.Function1", "MyPlugin.Function2"] + }; + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedViaConstructorForManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior([plugin.ElementAt(0), plugin.ElementAt(1)], new FunctionChoiceBehaviorOptions() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseAllKernelFunctionsForManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void ItShouldAllowAutoInvocationByDefault() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.Options.AutoInvoke); + } + + [Fact] + public void ItShouldAllowManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.Options.AutoInvoke); + } + + [Fact] + public void ItShouldInitializeFunctionPropertyByFunctionsPassedViaConstructor() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + // Assert + Assert.NotNull(choiceBehavior.Functions); + Assert.Equal(2, choiceBehavior.Functions.Count); + + Assert.Equal("MyPlugin.Function1", choiceBehavior.Functions.ElementAt(0)); + Assert.Equal("MyPlugin.Function2", choiceBehavior.Functions.ElementAt(1)); + } + + [Fact] + public void ItShouldThrowExceptionIfAutoInvocationRequestedButNoKernelIsProvided() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + var choiceBehavior = new AutoFunctionChoiceBehavior(); + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = null }); + }); + + Assert.Equal("Auto-invocation is not supported when no kernel is provided.", exception.Message); + } + + [Fact] + public void ItShouldThrowExceptionIfAutoInvocationRequestedAndFunctionIsNotRegisteredInKernel() + { + // Arrange + var plugin = GetTestPlugin(); + + var choiceBehavior = new AutoFunctionChoiceBehavior(functions: [plugin.ElementAt(0)]); + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + }); + + Assert.Equal("The specified function MyPlugin.Function1 is not available in the kernel.", exception.Message); + } + + [Fact] + public void ItShouldThrowExceptionIfNoFunctionFoundAndManualInvocationIsRequested() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + var choiceBehavior = new AutoFunctionChoiceBehavior(options: new() { AutoInvoke = false }) + { + Functions = ["MyPlugin.NonKernelFunction"] + }; + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + }); + + Assert.Equal("The specified function MyPlugin.NonKernelFunction was not found.", exception.Message); + } + + [Fact] + public void ItShouldAllowInvocationOfAnyRequestedKernelFunction() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.AllowAnyRequestedKernelFunction); + } + + [Fact] + public void ItShouldNotAllowInvocationOfAnyRequestedKernelFunctionIfSubsetOfFunctionsSpecified() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new AutoFunctionChoiceBehavior(functions: [plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.AllowAnyRequestedKernelFunction); + } + + private static KernelPlugin GetTestPlugin() + { + var function1 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function2"); + var function3 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function3"); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2, function3]); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorDeserializationTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorDeserializationTests.cs new file mode 100644 index 000000000000..ce3ba01e15c0 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorDeserializationTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI.FunctionChoiceBehaviors; +public class FunctionChoiceBehaviorDeserializationTests +{ + [Fact] + public void ItShouldDeserializeAutoFunctionChoiceBehavior() + { + // Arrange + var json = """ + { + "type": "auto", + "functions": ["p1.f1"] + } + """; + + // Act + var behavior = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(behavior?.Functions); + Assert.Single(behavior.Functions); + Assert.Equal("p1.f1", behavior.Functions.Single()); + } + + [Fact] + public void ItShouldDeserializeRequiredFunctionChoiceBehavior() + { + // Arrange + var json = """ + { + "type": "required", + "functions": ["p1.f1"] + } + """; + + // Act + var behavior = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(behavior?.Functions); + Assert.Single(behavior.Functions); + Assert.Equal("p1.f1", behavior.Functions.Single()); + } + + [Fact] + public void ItShouldDeserializeNoneFunctionChoiceBehavior() + { + // Arrange + var json = """ + { + "type": "none", + "functions": ["p1.f1"] + } + """; + + // Act + var behavior = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(behavior?.Functions); + Assert.Single(behavior.Functions); + Assert.Equal("p1.f1", behavior.Functions.Single()); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceTests.cs new file mode 100644 index 000000000000..359ea11ce4de --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/FunctionChoiceTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI.FunctionChoiceBehaviors; + +public class FunctionChoiceTests +{ + [Fact] + public void ItShouldInitializeLabelForAutoFunctionChoice() + { + // Act + var choice = FunctionChoice.Auto; + + // Assert + Assert.Equal("auto", choice.Label); + } + + [Fact] + public void ItShouldInitializeLabelForRequiredFunctionChoice() + { + // Act + var choice = FunctionChoice.Required; + + // Assert + Assert.Equal("required", choice.Label); + } + + [Fact] + public void ItShouldInitializeLabelForNoneFunctionChoice() + { + // Act + var choice = FunctionChoice.None; + + // Assert + Assert.Equal("none", choice.Label); + } + + [Fact] + public void ItShouldCheckTwoChoicesAreEqual() + { + // Arrange + var choice1 = FunctionChoice.Auto; + var choice2 = FunctionChoice.Auto; + + // Act & Assert + Assert.True(choice1 == choice2); + } + + [Fact] + public void ItShouldCheckTwoChoicesAreNotEqual() + { + // Arrange + var choice1 = FunctionChoice.Auto; + var choice2 = FunctionChoice.Required; + + // Act & Assert + Assert.False(choice1 == choice2); + } + + [Fact] + public void ItShouldCheckChoiceIsEqualToItself() + { + // Arrange + var choice = FunctionChoice.Auto; + + // Act & Assert +#pragma warning disable CS1718 // Comparison made to same variable + Assert.True(choice == choice); +#pragma warning restore CS1718 // Comparison made to same variable + } + + [Fact] + public void ItShouldCheckChoiceIsNotEqualToDifferentType() + { + // Arrange + var choice = FunctionChoice.Auto; + + // Act & Assert + Assert.False(choice.Equals("auto")); + } + + [Fact] + public void ItShouldCheckChoiceIsNotEqualToNull() + { + // Arrange + var choice = FunctionChoice.Auto; + + // Act & Assert + Assert.False(choice.Equals(null)); + } + + [Fact] + public void ToStringShouldReturnLabel() + { + // Arrange + var choice = FunctionChoice.Auto; + + // Act + var result = choice.ToString(); + + // Assert + Assert.Equal("auto", result); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehaviorTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..cc30e869b8b0 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/NoneFunctionChoiceBehaviorTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI.FunctionChoiceBehaviors; + +/// +/// Unit tests for +/// +public sealed class NoneFunctionChoiceBehaviorTests +{ + private readonly Kernel _kernel; + + public NoneFunctionChoiceBehaviorTests() + { + this._kernel = new Kernel(); + } + + [Fact] + public void ItShouldAdvertiseKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new NoneFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void ItShouldAdvertiseFunctionsIfSpecified() + { + // Arrange + var plugin = GetTestPlugin(); + + // Act + var choiceBehavior = new NoneFunctionChoiceBehavior([plugin.ElementAt(0), plugin.ElementAt(2)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + private static KernelPlugin GetTestPlugin() + { + var function1 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function2"); + var function3 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function3"); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2, function3]); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehaviorTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..cc74809398b2 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/FunctionChoiceBehaviors/RequiredFunctionChoiceBehaviorTests.cs @@ -0,0 +1,280 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI.FunctionChoiceBehaviors; + +/// +/// Unit tests for +/// +public sealed class RequiredFunctionChoiceBehaviorTests +{ + private readonly Kernel _kernel; + + public RequiredFunctionChoiceBehaviorTests() + { + this._kernel = new Kernel(); + } + + [Fact] + public void ItShouldAdvertiseAllKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedViaConstructor() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedInFunctionsProperty() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior() + { + Functions = ["MyPlugin.Function1", "MyPlugin.Function2"] + }; + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseOnlyFunctionsSuppliedViaConstructorForManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior([plugin.ElementAt(0), plugin.ElementAt(1)], new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void ItShouldAdvertiseAllKernelFunctionsForManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void ItShouldAllowAutoInvocationByDefault() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.Options.AutoInvoke); + } + + [Fact] + public void ItShouldAllowManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.Options.AutoInvoke); + } + + [Fact] + public void ItShouldInitializeFunctionPropertyByFunctionsPassedViaConstructor() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + // Assert + Assert.NotNull(choiceBehavior.Functions); + Assert.Equal(2, choiceBehavior.Functions.Count); + + Assert.Equal("MyPlugin.Function1", choiceBehavior.Functions.ElementAt(0)); + Assert.Equal("MyPlugin.Function2", choiceBehavior.Functions.ElementAt(1)); + } + + [Fact] + public void ItShouldThrowExceptionIfAutoInvocationRequestedButNoKernelIsProvided() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + var choiceBehavior = new RequiredFunctionChoiceBehavior(); + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = null }); + }); + + Assert.Equal("Auto-invocation is not supported when no kernel is provided.", exception.Message); + } + + [Fact] + public void ItShouldThrowExceptionIfAutoInvocationRequestedAndFunctionIsNotRegisteredInKernel() + { + // Arrange + var plugin = GetTestPlugin(); + + var choiceBehavior = new RequiredFunctionChoiceBehavior([plugin.ElementAt(0)], options: new() { AutoInvoke = true }); + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + }); + + Assert.Equal("The specified function MyPlugin.Function1 is not available in the kernel.", exception.Message); + } + + [Fact] + public void ItShouldThrowExceptionIfNoFunctionFoundAndManualInvocationIsRequested() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + var choiceBehavior = new RequiredFunctionChoiceBehavior(options: new() { AutoInvoke = false }) + { + Functions = ["MyPlugin.NonKernelFunction"] + }; + + // Act + var exception = Assert.Throws(() => + { + choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + }); + + Assert.Equal("The specified function MyPlugin.NonKernelFunction was not found.", exception.Message); + } + + [Fact] + public void ItShouldAllowInvocationOfAnyRequestedKernelFunction() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.AllowAnyRequestedKernelFunction); + } + + [Fact] + public void ItShouldNotAllowInvocationOfAnyRequestedKernelFunctionIfSubsetOfFunctionsSpecified() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = new RequiredFunctionChoiceBehavior(functions: [plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.AllowAnyRequestedKernelFunction); + } + + private static KernelPlugin GetTestPlugin() + { + var function1 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function2"); + var function3 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function3"); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2, function3]); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/PromptExecutionSettingsTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/PromptExecutionSettingsTests.cs index dd822a091175..e9c85a601c90 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/PromptExecutionSettingsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/PromptExecutionSettingsTests.cs @@ -20,7 +20,11 @@ public void PromptExecutionSettingsCloneWorksAsExpected() "temperature": 0.5, "top_p": 0.0, "presence_penalty": 0.0, - "frequency_penalty": 0.0 + "frequency_penalty": 0.0, + "function_choice_behavior": { + "type": "auto", + "functions": ["p1.f1"] + } } """; var executionSettings = JsonSerializer.Deserialize(configPayload); @@ -32,6 +36,7 @@ public void PromptExecutionSettingsCloneWorksAsExpected() Assert.NotNull(clone); Assert.Equal(executionSettings.ModelId, clone.ModelId); Assert.Equivalent(executionSettings.ExtensionData, clone.ExtensionData); + Assert.Equivalent(executionSettings.FunctionChoiceBehavior, clone.FunctionChoiceBehavior); Assert.Equal(executionSettings.ServiceId, clone.ServiceId); } @@ -88,6 +93,7 @@ public void PromptExecutionSettingsFreezeWorksAsExpected() Assert.NotNull(executionSettings.ExtensionData); Assert.Throws(() => executionSettings.ExtensionData.Add("results_per_prompt", 2)); Assert.Throws(() => executionSettings.ExtensionData["temperature"] = 1); + Assert.Throws(() => executionSettings.FunctionChoiceBehavior = FunctionChoiceBehavior.None()); executionSettings!.Freeze(); // idempotent Assert.True(executionSettings.IsFrozen); diff --git a/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallContentTests.cs b/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallContentTests.cs index 8ceac9ab6bcb..95c814eda7a1 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallContentTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallContentTests.cs @@ -59,7 +59,7 @@ public async Task ItShouldFindKernelFunctionAndInvokeItAsync() } [Fact] - public async Task ItShouldHandleFunctionCallRequestExceptionAsync() + public async Task ItShouldHandleFunctionCallExceptionAsync() { // Arrange var kernel = new Kernel(); @@ -78,7 +78,7 @@ public async Task ItShouldHandleFunctionCallRequestExceptionAsync() } [Fact] - public void ItShouldReturnListOfFunctionCallRequests() + public void ItShouldReturnListOfFunctionCalls() { // Arrange var functionCallContents = new ChatMessageContentItemCollection diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionChoiceBehaviorTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionChoiceBehaviorTests.cs new file mode 100644 index 000000000000..97326a23d44f --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/FunctionChoiceBehaviorTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.Functions; + +/// +/// Unit tests for +/// +public sealed class FunctionChoiceBehaviorTests +{ + private readonly Kernel _kernel; + + public FunctionChoiceBehaviorTests() + { + this._kernel = new Kernel(); + } + + [Fact] + public void AutoFunctionChoiceShouldBeUsed() + { + // Act + var choiceBehavior = FunctionChoiceBehavior.Auto(); + + // Assert + Assert.IsType(choiceBehavior); + } + + [Fact] + public void RequiredFunctionChoiceShouldBeUsed() + { + // Act + var choiceBehavior = FunctionChoiceBehavior.Required(); + + // Assert + Assert.IsType(choiceBehavior); + } + + [Fact] + public void NoneFunctionChoiceShouldBeUsed() + { + // Act + var choiceBehavior = FunctionChoiceBehavior.None(); + + // Assert + Assert.IsType(choiceBehavior); + } + + [Fact] + public void AutoFunctionChoiceShouldAdvertiseKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Auto(functions: null); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void AutoFunctionChoiceShouldAdvertiseProvidedFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Auto(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void AutoFunctionChoiceShouldAllowAutoInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = true }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.Options.AutoInvoke); + } + + [Fact] + public void AutoFunctionChoiceShouldAllowManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Auto(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.Options.AutoInvoke); + } + + [Fact] + public void RequiredFunctionChoiceShouldAdvertiseKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Required(functions: null); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void RequiredFunctionChoiceShouldAdvertiseProvidedFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Required(functions: [plugin.ElementAt(0), plugin.ElementAt(1)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + } + + [Fact] + public void RequiredFunctionChoiceShouldAllowAutoInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Required(options: new() { AutoInvoke = true }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.True(config.Options.AutoInvoke); + } + + [Fact] + public void RequiredFunctionChoiceShouldAllowManualInvocation() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.Required(options: new() { AutoInvoke = false }); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + Assert.False(config.Options.AutoInvoke); + } + + [Fact] + public void NoneFunctionChoiceShouldAdvertiseProvidedFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + + // Act + var choiceBehavior = FunctionChoiceBehavior.None([plugin.ElementAt(0), plugin.ElementAt(2)]); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(2, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + [Fact] + public void NoneFunctionChoiceShouldAdvertiseAllKernelFunctions() + { + // Arrange + var plugin = GetTestPlugin(); + this._kernel.Plugins.Add(plugin); + + // Act + var choiceBehavior = FunctionChoiceBehavior.None(); + + var config = choiceBehavior.GetConfiguration(new() { Kernel = this._kernel }); + + // Assert + Assert.NotNull(config); + + Assert.NotNull(config.Functions); + Assert.Equal(3, config.Functions.Count); + Assert.Contains(config.Functions, f => f.Name == "Function1"); + Assert.Contains(config.Functions, f => f.Name == "Function2"); + Assert.Contains(config.Functions, f => f.Name == "Function3"); + } + + private static KernelPlugin GetTestPlugin() + { + var function1 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function2"); + var function3 = KernelFunctionFactory.CreateFromMethod(() => { }, "Function3"); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2, function3]); + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/PromptTemplate/PromptTemplateConfigTests.cs b/dotnet/src/SemanticKernel.UnitTests/PromptTemplate/PromptTemplateConfigTests.cs index 5fecdf71b8c3..b3bb439b5ac6 100644 --- a/dotnet/src/SemanticKernel.UnitTests/PromptTemplate/PromptTemplateConfigTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/PromptTemplate/PromptTemplateConfigTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using System.Text.Json; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Connectors.OpenAI; @@ -376,6 +377,108 @@ public void DeserializingExpectCompletion() Assert.Equal("gpt-4", promptTemplateConfig.DefaultExecutionSettings?.ModelId); } + [Fact] + public void DeserializingAutoFunctionCallingChoice() + { + // Arrange + string configPayload = """ + { + "schema": 1, + "execution_settings": { + "default": { + "model_id": "gpt-4", + "function_choice_behavior": { + "type": "auto", + "functions":["p1.f1"] + } + } + } + } + """; + + // Act + var promptTemplateConfig = PromptTemplateConfig.FromJson(configPayload); + + // Assert + Assert.NotNull(promptTemplateConfig); + Assert.Single(promptTemplateConfig.ExecutionSettings); + + var executionSettings = promptTemplateConfig.ExecutionSettings.Single().Value; + + var autoFunctionCallChoice = executionSettings.FunctionChoiceBehavior as AutoFunctionChoiceBehavior; + Assert.NotNull(autoFunctionCallChoice); + + Assert.NotNull(autoFunctionCallChoice.Functions); + Assert.Equal("p1.f1", autoFunctionCallChoice.Functions.Single()); + } + + [Fact] + public void DeserializingRequiredFunctionCallingChoice() + { + // Arrange + string configPayload = """ + { + "schema": 1, + "execution_settings": { + "default": { + "model_id": "gpt-4", + "function_choice_behavior": { + "type": "required", + "functions":["p1.f1"] + } + } + } + } + """; + + // Act + var promptTemplateConfig = PromptTemplateConfig.FromJson(configPayload); + + // Assert + Assert.NotNull(promptTemplateConfig); + Assert.Single(promptTemplateConfig.ExecutionSettings); + + var executionSettings = promptTemplateConfig.ExecutionSettings.Single().Value; + Assert.NotNull(executionSettings); + + var requiredFunctionCallChoice = executionSettings.FunctionChoiceBehavior as RequiredFunctionChoiceBehavior; + Assert.NotNull(requiredFunctionCallChoice); + + Assert.NotNull(requiredFunctionCallChoice.Functions); + Assert.Equal("p1.f1", requiredFunctionCallChoice.Functions.Single()); + } + + [Fact] + public void DeserializingNoneFunctionCallingChoice() + { + // Arrange + string configPayload = """ + { + "schema": 1, + "execution_settings": { + "default": { + "model_id": "gpt-4", + "function_choice_behavior": { + "type": "none" + } + } + } + } + """; + + // Act + var promptTemplateConfig = PromptTemplateConfig.FromJson(configPayload); + + // Assert + Assert.NotNull(promptTemplateConfig); + Assert.Single(promptTemplateConfig.ExecutionSettings); + + var executionSettings = promptTemplateConfig.ExecutionSettings.Single().Value; + + var noneFunctionCallChoice = executionSettings.FunctionChoiceBehavior as NoneFunctionChoiceBehavior; + Assert.NotNull(noneFunctionCallChoice); + } + [Fact] public void DeserializingExpectInputVariables() {