diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs index ae6fe703bc5d..2e1983951f71 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs @@ -159,6 +159,75 @@ public async Task AsChatClientConvertsServiceToIChatClientAsync() Assert.IsAssignableFrom(chatClient); } + [Fact] + public async Task AutoInvokeDoesNotInvokeFunctionThatWasNotAdvertisedByFunctionChoiceBehaviorAsync() + { + // Arrange - register two functions in the same kernel but only advertise one of them via the + // FunctionChoiceBehavior. The model is then made to request the function that was not advertised. + bool unadvertisedFunctionInvoked = false; + var plugin = KernelPluginFactory.CreateFromFunctions("TestPlugin", new[] + { + KernelFunctionFactory.CreateFromMethod(() => "allowed-result", "AllowedFunction"), + KernelFunctionFactory.CreateFromMethod(() => { unadvertisedFunctionInvoked = true; return "secret"; }, "UnadvertisedFunction"), + }); + var kernel = new Kernel(); + kernel.Plugins.Add(plugin); + + var allowedFunction = kernel.Plugins.GetFunction("TestPlugin", "AllowedFunction"); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [allowedFunction], autoInvoke: true) + }; + + // The model requests the function that was not advertised, then produces a final text answer. + string functionCallResponse = $$""" + { + "candidates": [ + { + "content": { + "parts": [ { "functionCall": { "name": "TestPlugin{{GeminiFunction.NameSeparator}}UnadvertisedFunction", "args": {} } } ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2 } + } + """; + string finalTextResponse = """ + { + "candidates": [ + { + "content": { "parts": [ { "text": "done" } ], "role": "model" }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2 } + } + """; + using var functionCallHttpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(functionCallResponse) }; + this._messageHandlerStub.ResponseQueue.Enqueue(functionCallHttpResponse); + + using var finalTextHttpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(finalTextResponse) }; + this._messageHandlerStub.ResponseQueue.Enqueue(finalTextHttpResponse); + + var service = this.CreateChatCompletionService(); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Do something"); + + // Act + var result = await service.GetChatMessageContentsAsync(chatHistory, settings, kernel); + + // Assert - the unadvertised function must never be invoked, even though it exists in the kernel. + Assert.False(unadvertisedFunctionInvoked); + // The requested tool call was processed and rejected (not silently ignored): both responses were + // consumed and the model's final text answer was returned. + Assert.Empty(this._messageHandlerStub.ResponseQueue); + Assert.Contains(result, m => m.Content is not null && m.Content.Contains("done", StringComparison.Ordinal)); + } + private GoogleAIGeminiChatCompletionService CreateChatCompletionService(HttpClient? httpClient = null) { return new GoogleAIGeminiChatCompletionService( diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index 4833fac1a4cd..bf5c509eb0b6 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -195,78 +195,210 @@ public void KernelFunctionsCloneReturnsCorrectClone() } [Fact] - public void FunctionChoiceBehaviorAutoConvertsToAutoInvokeKernelFunctions() + public void FunctionChoiceBehaviorAutoConvertsToEnabledFunctionsWithAllKernelFunctions() { // Arrange + var kernel = CreateKernelWithFunctions("FunctionA", "FunctionB"); var settings = new GeminiPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; // Act - var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); // Assert Assert.NotNull(converted.ToolCallBehavior); - Assert.IsType(converted.ToolCallBehavior); + var enabledFunctions = Assert.IsType(converted.ToolCallBehavior); Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); + // The provided set is validated against the requested functions. + Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior)); + // Auto continues to provide the functions across follow-up requests. + Assert.Equal(int.MaxValue, converted.ToolCallBehavior.MaximumUseAttempts); + Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionA", $"TestPlugin{GeminiFunction.NameSeparator}FunctionB" }, GetAdvertisedFunctionNames(enabledFunctions, kernel)); } [Fact] - public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnableKernelFunctions() + public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnabledFunctionsWithoutAutoInvoke() { // Arrange + var kernel = CreateKernelWithFunctions("FunctionA"); var settings = new GeminiPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false) }; // Act - var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); // Assert Assert.NotNull(converted.ToolCallBehavior); - Assert.IsType(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); } [Fact] - public void FunctionChoiceBehaviorRequiredConvertsToAutoInvokeKernelFunctions() + public void FunctionChoiceBehaviorRequiredConvertsToEnabledFunctions() { // Arrange + var kernel = CreateKernelWithFunctions("FunctionA"); var settings = new GeminiPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Required() }; // Act - var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); // Assert Assert.NotNull(converted.ToolCallBehavior); - Assert.IsType(converted.ToolCallBehavior); - Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); + Assert.IsType(converted.ToolCallBehavior); + Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior)); + // Required stops providing the functions after the first request so the model is not + // repeatedly forced to call them on follow-up iterations. Auto-invocation is capped to the same + // value so it does not outlast the advertised tools (MaximumUseAttempts >= MaximumAutoInvokeAttempts). + Assert.Equal(1, converted.ToolCallBehavior.MaximumUseAttempts); + Assert.Equal(1, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); + Assert.True(converted.ToolCallBehavior.MaximumUseAttempts >= converted.ToolCallBehavior.MaximumAutoInvokeAttempts); } [Fact] - public void FunctionChoiceBehaviorNoneConvertsToEnableKernelFunctions() + public void FunctionChoiceBehaviorNoneConvertsToEnabledFunctionsWithoutAutoInvoke() { // Arrange + var kernel = CreateKernelWithFunctions("FunctionA"); var settings = new GeminiPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.None() }; // Act - var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings); + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); // Assert Assert.NotNull(converted.ToolCallBehavior); - Assert.IsType(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); // None behavior doesn't auto-invoke Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts); } + [Fact] + public void FunctionChoiceBehaviorAutoWithEmptyFunctionListDisablesFunctionCalling() + { + // Arrange + // An empty function list is documented as being equivalent to disabling function calling. + var kernel = CreateKernelWithFunctions("FunctionA", "FunctionB"); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [], autoInvoke: true) + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); + + // Assert + Assert.Null(converted.ToolCallBehavior); + } + + [Fact] + public void FunctionChoiceBehaviorAutoWithSubsetProvidesOnlyThatSubset() + { + // Arrange + var kernel = CreateKernelWithFunctions("First", "Second"); + var first = kernel.Plugins.GetFunction("TestPlugin", "First"); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [first], autoInvoke: true) + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + var enabledFunctions = Assert.IsType(converted.ToolCallBehavior); + Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior)); + // Only the function specified in the behavior is provided to the model. + Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}First" }, GetAdvertisedFunctionNames(enabledFunctions, kernel)); + } + + [Fact] + public void FunctionChoiceBehaviorConfigurationErrorsArePropagated() + { + // Arrange - auto-invocation requires the declared function to exist in the kernel; when it does + // not, the behavior resolution throws and the error must surface rather than be swallowed. + var kernelWithFunction = CreateKernelWithFunctions("First"); + var first = kernelWithFunction.Plugins.GetFunction("TestPlugin", "First"); + var emptyKernel = Kernel.CreateBuilder().Build(); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [first], autoInvoke: true) + }; + + // Act & Assert + Assert.Throws(() => GeminiPromptExecutionSettings.FromExecutionSettings(settings, emptyKernel)); + } + + [Fact] + public void FromExecutionSettingsDoesNotMutateCallerSettingsWhenConvertingFunctionChoiceBehavior() + { + // Arrange + var kernel = CreateKernelWithFunctions("FunctionA"); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // Act + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); + + // Assert - the caller's original settings object must remain untouched. + Assert.NotSame(settings, converted); + Assert.Null(settings.ToolCallBehavior); + Assert.NotNull(converted.ToolCallBehavior); + } + + [Fact] + public void FromExecutionSettingsWithFrozenSettingsDoesNotThrowWhenConvertingFunctionChoiceBehavior() + { + // Arrange + var kernel = CreateKernelWithFunctions("FunctionA"); + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + settings.Freeze(); + + // Act - converting a frozen settings object must not attempt to mutate it. + var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel); + + // Assert + Assert.NotNull(converted.ToolCallBehavior); + Assert.IsType(converted.ToolCallBehavior); + } + + [Fact] + public void FromExecutionSettingsReResolvesFunctionChoiceBehaviorPerKernel() + { + // Arrange - a single reused settings object must reflect each request's own kernel. + var settings = new GeminiPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var kernelA = CreateKernelWithFunctions("FunctionA"); + var kernelB = CreateKernelWithFunctions("FunctionB"); + + // Act + var convertedA = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernelA); + var convertedB = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernelB); + + // Assert - each request reflects its own kernel. + var enabledA = Assert.IsType(convertedA.ToolCallBehavior); + var enabledB = Assert.IsType(convertedB.ToolCallBehavior); + Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionA" }, GetAdvertisedFunctionNames(enabledA, kernelA)); + Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionB" }, GetAdvertisedFunctionNames(enabledB, kernelB)); + } + [Fact] public void GeminiPromptExecutionSettingsWithNoFunctionChoiceBehaviorDoesNotSetToolCallBehavior() { @@ -311,6 +443,32 @@ private static KernelPlugin GetTestPlugin() return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); } + private static Kernel CreateKernelWithFunctions(params string[] functionNames) + { + var functions = functionNames + .Select(name => KernelFunctionFactory.CreateFromMethod(() => "result", name)) + .ToArray(); + + var kernel = Kernel.CreateBuilder().Build(); + kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("TestPlugin", functions)); + return kernel; + } + + private static bool GetAllowAnyRequestedKernelFunction(GeminiToolCallBehavior behavior) + => behavior.AllowAnyRequestedKernelFunction; + + private static string[] GetAdvertisedFunctionNames(GeminiToolCallBehavior behavior, Kernel kernel) + { + var request = new GeminiRequest(); + behavior.ConfigureGeminiRequest(kernel, request); + if (request.Tools is null) + { + return []; + } + + return request.Tools[0].Functions.Select(f => f.Name).ToArray(); + } + private static void AssertFunctions(GeminiRequest request) { Assert.NotNull(request.Tools); diff --git a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs index e0138a8e9ce3..fd5580093224 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs @@ -323,7 +323,7 @@ private ChatCompletionState ValidateInputAndCreateChatCompletionState( { ValidateChatHistory(chatHistory); - var geminiExecutionSettings = GeminiPromptExecutionSettings.FromExecutionSettings(executionSettings); + var geminiExecutionSettings = GeminiPromptExecutionSettings.FromExecutionSettings(executionSettings, kernel); ValidateMaxTokens(geminiExecutionSettings.MaxTokens); if (this.Logger.IsEnabled(LogLevel.Trace)) diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs index a9729f518899..51f7b4e0d276 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs @@ -355,16 +355,36 @@ public override PromptExecutionSettings Clone() /// is null, an is thrown. /// public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings) + => FromExecutionSettings(executionSettings, kernel: null); + + /// + /// Create a new settings object with the values from another settings object, resolving any + /// against the provided . + /// + /// The prompt execution settings to convert. + /// + /// The used to resolve the functions declared by a . + /// This must be the kernel that will actually be used for the request so that the advertised function set + /// matches the caller's intent. + /// + /// The converted . + public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings, Kernel? kernel) { switch (executionSettings) { case null: return new GeminiPromptExecutionSettings(); case GeminiPromptExecutionSettings geminiSettings: - // If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it + // If FunctionChoiceBehavior is set and ToolCallBehavior is not, resolve it against the + // request's kernel. This is done on a clone so we neither mutate the caller's (possibly + // shared or frozen) settings instance nor cache a kernel-specific result that could go + // stale when the same settings object is reused with a different kernel. if (geminiSettings.FunctionChoiceBehavior is not null && geminiSettings.ToolCallBehavior is null) { - geminiSettings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(geminiSettings.FunctionChoiceBehavior); + var resolved = (GeminiPromptExecutionSettings)geminiSettings.Clone(); + resolved.FunctionChoiceBehavior = geminiSettings.FunctionChoiceBehavior; + resolved.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(geminiSettings.FunctionChoiceBehavior, kernel); + return resolved; } return geminiSettings; } @@ -375,56 +395,62 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio // If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it if (executionSettings.FunctionChoiceBehavior is not null && settings.ToolCallBehavior is null) { - settings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(executionSettings.FunctionChoiceBehavior); + settings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(executionSettings.FunctionChoiceBehavior, kernel); } return settings; } - /// - /// Shared empty kernel instance used for FunctionChoiceBehavior conversion. - /// - private static readonly Kernel s_emptyKernel = new(); - /// /// Converts a to a . /// /// The to convert. + /// The used to resolve the behavior's declared functions. /// The converted . - internal static GeminiToolCallBehavior? ConvertFunctionChoiceBehaviorToToolCallBehavior(FunctionChoiceBehavior? functionChoiceBehavior) + /// + /// The conversion honors the exact function set expressed by the . + /// The resolved list is the set of functions + /// provided to the model, matching the behavior of the other connectors. A null or empty function set + /// (e.g. FunctionChoiceBehavior.Auto([]), which is documented as being equivalent to disabling + /// function calling) results in no functions being provided. Any configuration errors thrown while + /// resolving the behavior (for example, requesting auto-invocation for a function that is not present in + /// the kernel) are surfaced to the caller rather than being swallowed, matching the other connectors. + /// + internal static GeminiToolCallBehavior? ConvertFunctionChoiceBehaviorToToolCallBehavior(FunctionChoiceBehavior? functionChoiceBehavior, Kernel? kernel) { if (functionChoiceBehavior is null) { return null; } - // Check the type and determine auto-invoke by reflection or known behavior types - // All FunctionChoiceBehavior types (Auto, Required, None) support auto-invoke - // We use a simple approach: get the configuration with minimal context to check AutoInvoke - try + // Resolve the behavior against the kernel so the provided function set reflects + // exactly what the caller requested (all functions, an explicit subset, or none). + var context = new FunctionChoiceBehaviorConfigurationContext(new ChatHistory()) { - var context = new FunctionChoiceBehaviorConfigurationContext(new ChatHistory()) - { - Kernel = s_emptyKernel, // Provide an empty kernel for the configuration - RequestSequenceIndex = 0 - }; - var config = functionChoiceBehavior.GetConfiguration(context); - - // Return appropriate GeminiToolCallBehavior based on AutoInvoke setting - if (config.AutoInvoke) - { - return GeminiToolCallBehavior.AutoInvokeKernelFunctions; - } - - return GeminiToolCallBehavior.EnableKernelFunctions; - } -#pragma warning disable CA1031 // Do not catch general exception types - catch -#pragma warning restore CA1031 + Kernel = kernel, + RequestSequenceIndex = 0 + }; + var config = functionChoiceBehavior.GetConfiguration(context); + + // A null or empty resolved function list means no functions should be provided to the model, + // which preserves the documented "empty list disables function calling" behavior. + if (config.Functions is not { Count: > 0 } functions) { - // If we can't get configuration (e.g., due to missing dependencies or unexpected state), - // default to EnableKernelFunctions as the safer option that doesn't auto-invoke - return GeminiToolCallBehavior.EnableKernelFunctions; + return null; } + + // For the Required choice, functions should only be provided on the first request to avoid + // repeatedly forcing the model to call them on follow-up iterations, matching the shared + // FunctionChoiceBehavior semantics. Limiting the use attempts to 1 stops the connector from + // re-advertising the tools after the initial request; the auto-invoke attempts are capped to the + // same value (see GeminiToolCallBehavior) so a single forced call is executed and the model's + // final answer is returned. + int maximumUseAttempts = config.Choice == FunctionChoice.Required ? 1 : int.MaxValue; + + // Provide exactly the resolved functions. + return new GeminiToolCallBehavior.EnabledFunctions( + functions.Select(f => f.Metadata.ToGeminiFunction()), + autoInvoke: config.AutoInvoke, + maximumUseAttempts: maximumUseAttempts); } } diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs b/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs index 4597a18e1bd7..497852054427 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs @@ -68,9 +68,15 @@ public static GeminiToolCallBehavior EnableFunctions(IEnumerable } /// Initializes the instance; prevents external instantiation. - private GeminiToolCallBehavior(bool autoInvoke) + private GeminiToolCallBehavior(bool autoInvoke, int maximumUseAttempts = int.MaxValue) { - this.MaximumAutoInvokeAttempts = autoInvoke ? DefaultMaximumAutoInvokeAttempts : 0; + this.MaximumUseAttempts = maximumUseAttempts; + + // Auto-invocation can never outlast the number of requests that still advertise the tools, otherwise + // the model could request a tool after it has been removed from the request. Cap the auto-invoke + // attempts by the use attempts to preserve the documented invariant (MaximumUseAttempts >= MaximumAutoInvokeAttempts). + var autoInvokeAttempts = autoInvoke ? DefaultMaximumAutoInvokeAttempts : 0; + this.MaximumAutoInvokeAttempts = System.Math.Min(autoInvokeAttempts, maximumUseAttempts); } /// Gets how many requests are part of a single interaction should include this tool in the request. @@ -80,7 +86,7 @@ private GeminiToolCallBehavior(bool autoInvoke) /// if this is 1, the first request will include the tools, but the subsequent response sending back the tool's result /// will not include the tools for further use. /// - public int MaximumUseAttempts { get; } = int.MaxValue; + public int MaximumUseAttempts { get; } /// Gets how many tool call request/response roundtrips are supported with auto-invocation. /// @@ -137,9 +143,15 @@ internal override void ConfigureGeminiRequest(Kernel? kernel, GeminiRequest requ /// /// Represents a that provides a specified list of functions to the model. /// - internal sealed class EnabledFunctions(IEnumerable functions, bool autoInvoke) : GeminiToolCallBehavior(autoInvoke) + internal sealed class EnabledFunctions : GeminiToolCallBehavior { - private readonly GeminiFunction[] _functions = functions.ToArray(); + private readonly GeminiFunction[] _functions; + + internal EnabledFunctions(IEnumerable functions, bool autoInvoke, int maximumUseAttempts = int.MaxValue) + : base(autoInvoke, maximumUseAttempts) + { + this._functions = functions.ToArray(); + } public override string ToString() => $"{nameof(EnabledFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0}): " +