From 79fe2e68741089bb868d5f312aff86799d3c9674 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 17:48:03 -0700 Subject: [PATCH 1/5] .Net: Honor FunctionChoiceBehavior function list in Gemini connector The Gemini connector converted a FunctionChoiceBehavior into a GeminiToolCallBehavior by inspecting only the AutoInvoke flag and evaluating the behavior against an empty kernel. As a result the configured function list and choice were ignored: every conversion provided all kernel functions to the model, and an explicit function list produced an incorrect result. Resolve the behavior against the request's kernel and provide exactly the functions it specifies, consistent with the other connectors. An empty function list now correctly disables function calling. The conversion is performed on a clone so shared or frozen settings are not mutated and the result is not cached across different kernels. Also add a kernel-aware FromExecutionSettings overload and update the chat completion client to use it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8 --- .../GeminiToolCallBehaviorTests.cs | 160 ++++++++++++++++-- .../Clients/GeminiChatCompletionClient.cs | 2 +- .../GeminiPromptExecutionSettings.cs | 66 +++++--- 3 files changed, 194 insertions(+), 34 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index 4833fac1a4cd..d2040f95e467 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; @@ -195,78 +195,186 @@ 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)); + 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.IsType(converted.ToolCallBehavior); Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); + Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior)); } [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 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 +419,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..53b6099d1411 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. + /// + 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()) { - Kernel = s_emptyKernel, // Provide an empty kernel for the configuration + Kernel = kernel, RequestSequenceIndex = 0 }; var config = functionChoiceBehavior.GetConfiguration(context); - // Return appropriate GeminiToolCallBehavior based on AutoInvoke setting - if (config.AutoInvoke) + // 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) { - return GeminiToolCallBehavior.AutoInvokeKernelFunctions; + return null; } - return GeminiToolCallBehavior.EnableKernelFunctions; + // Provide exactly the resolved functions. + return GeminiToolCallBehavior.EnableFunctions( + functions.Select(f => f.Metadata.ToGeminiFunction()), + autoInvoke: config.AutoInvoke); } #pragma warning disable CA1031 // Do not catch general exception types catch #pragma warning restore CA1031 { - // If we can't get configuration (e.g., due to missing dependencies or unexpected state), - // default to EnableKernelFunctions as the safer option that doesn't auto-invoke - return GeminiToolCallBehavior.EnableKernelFunctions; + // If the behavior cannot be resolved (e.g. an auto-invoke function is missing from the kernel), + // provide no functions rather than defaulting to the full set of kernel functions. + return null; } } } From f76472693c0a594810a36f3910ccdf992bb82e13 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 17:54:01 -0700 Subject: [PATCH 2/5] Fix file encoding to UTF-8 with BOM Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8 --- .../Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index d2040f95e467..021f00a101ba 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; From e5ac3de62f2da659a357419e6af27a55964c77d4 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Jul 2026 18:07:05 -0700 Subject: [PATCH 3/5] Address review feedback on FunctionChoiceBehavior conversion - Stop swallowing exceptions during behavior resolution. Configuration errors thrown by FunctionChoiceBehavior (e.g. auto-invocation requested for a function not present in the kernel) now surface to the caller instead of silently disabling function calling, matching the other connectors' fail-fast behavior. - Honor the Required choice's request-sequence semantics: functions are now only provided on the first request (MaximumUseAttempts = 1) so the model is not repeatedly forced to call them on follow-up iterations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8 --- .../GeminiToolCallBehaviorTests.cs | 22 ++++++++ .../GeminiPromptExecutionSettings.cs | 55 +++++++++---------- .../GeminiToolCallBehavior.cs | 15 +++-- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index 021f00a101ba..02e04bc4d30e 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -213,6 +213,8 @@ public void FunctionChoiceBehaviorAutoConvertsToEnabledFunctionsWithAllKernelFun 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)); } @@ -253,6 +255,9 @@ public void FunctionChoiceBehaviorRequiredConvertsToEnabledFunctions() Assert.IsType(converted.ToolCallBehavior); Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); 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. + Assert.Equal(1, converted.ToolCallBehavior.MaximumUseAttempts); } [Fact] @@ -315,6 +320,23 @@ public void FunctionChoiceBehaviorAutoWithSubsetProvidesOnlyThatSubset() 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() { diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs index 53b6099d1411..058a0b18f386 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs @@ -412,7 +412,9 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio /// 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. + /// 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) { @@ -421,36 +423,33 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio return null; } - 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()) { - // 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()) - { - 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) - { - return null; - } - - // Provide exactly the resolved functions. - return GeminiToolCallBehavior.EnableFunctions( - functions.Select(f => f.Metadata.ToGeminiFunction()), - autoInvoke: config.AutoInvoke); - } -#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 the behavior cannot be resolved (e.g. an auto-invoke function is missing from the kernel), - // provide no functions rather than defaulting to the full set of kernel functions. 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, since the converted behavior is reused + // across auto-invocation iterations. + 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..9ef1f12b4f65 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs @@ -68,9 +68,10 @@ 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; } /// Gets how many requests are part of a single interaction should include this tool in the request. @@ -80,7 +81,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 +138,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}): " + From 05e18a6dda6117b57352955a62ada001a3a2f92b Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Fri, 24 Jul 2026 10:54:28 -0700 Subject: [PATCH 4/5] Cap auto-invoke attempts by use attempts in Gemini tool call behavior For FunctionChoiceBehavior.Required(), MaximumUseAttempts was set to 1 while MaximumAutoInvokeAttempts remained at the default, violating the documented MaximumUseAttempts >= MaximumAutoInvokeAttempts invariant. Because auto-invocation outlasted the requests that advertised the tools, an unexpected follow-up tool call could dereference a null tool list in the chat completion client. Cap MaximumAutoInvokeAttempts by MaximumUseAttempts in the tool call behavior constructor so auto-invocation never outlasts the advertised tools. For Required this results in a single forced tool call followed by the model's final text answer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8 --- .../GeminiToolCallBehaviorTests.cs | 6 ++++-- .../Connectors.Google/GeminiPromptExecutionSettings.cs | 5 +++-- .../Connectors/Connectors.Google/GeminiToolCallBehavior.cs | 7 ++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs index 02e04bc4d30e..bf5c509eb0b6 100644 --- a/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs @@ -253,11 +253,13 @@ public void FunctionChoiceBehaviorRequiredConvertsToEnabledFunctions() // Assert Assert.NotNull(converted.ToolCallBehavior); Assert.IsType(converted.ToolCallBehavior); - Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0); 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. + // 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] diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs index 058a0b18f386..51f7b4e0d276 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs @@ -442,8 +442,9 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio // 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, since the converted behavior is reused - // across auto-invocation iterations. + // 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. diff --git a/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs b/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs index 9ef1f12b4f65..497852054427 100644 --- a/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs +++ b/dotnet/src/Connectors/Connectors.Google/GeminiToolCallBehavior.cs @@ -70,8 +70,13 @@ public static GeminiToolCallBehavior EnableFunctions(IEnumerable /// Initializes the instance; prevents external instantiation. 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. From 6439adf33ce7f6025a2310ba5238730be04157e1 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Fri, 24 Jul 2026 11:24:29 -0700 Subject: [PATCH 5/5] Add end-to-end test for restricted FunctionChoiceBehavior auto-invocation Verifies that when a FunctionChoiceBehavior advertises only a subset of the kernel's functions, an auto-invoked tool call for a registered-but-not-advertised function is rejected rather than executed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8 --- .../GeminiChatClientFunctionCallingTests.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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(