Skip to content

Commit c028a0c

Browse files
giles17Copilot
andauthored
.Net: Honor FunctionChoiceBehavior function list in Gemini connector (#14183)
## Motivation and Context The Gemini connector converts a `FunctionChoiceBehavior` (FCB) into the connector-internal `GeminiToolCallBehavior` in `GeminiPromptExecutionSettings`. That conversion only inspected the `AutoInvoke` flag and evaluated the behavior against an empty kernel, so the configured function list and choice were dropped: - `FunctionChoiceBehavior.Auto()` / `Auto([subset])` always provided **all** kernel functions to the model, regardless of the list supplied. - `FunctionChoiceBehavior.Auto([])`, which is documented as equivalent to disabling function calling, still provided all kernel functions. - Explicit function lists failed to convert because the behavior was evaluated against an empty kernel. This diverges from the other connectors, which resolve the behavior against the request kernel and provide exactly the functions it specifies. ## Description - Resolve the `FunctionChoiceBehavior` against the request's kernel and provide exactly the functions it declares (all, an explicit subset, or none), matching the shared function-calling semantics used by the other connectors. - An empty function list now correctly disables function calling. - Perform the conversion on a clone so shared or frozen settings instances are not mutated and the resolved result is not cached across different kernels. - Add a kernel-aware `FromExecutionSettings(settings, kernel)` overload and use it from `GeminiChatCompletionClient`. ## Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c0e4edcd-4d36-486f-950b-9c0c36fa68e8
1 parent 1d336b3 commit c028a0c

5 files changed

Lines changed: 318 additions & 53 deletions

File tree

‎dotnet/src/Connectors/Connectors.Google.UnitTests/Core/Gemini/Clients/GeminiChatClientFunctionCallingTests.cs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,75 @@ public async Task AsChatClientConvertsServiceToIChatClientAsync()
159159
Assert.IsAssignableFrom<IChatClient>(chatClient);
160160
}
161161

162+
[Fact]
163+
public async Task AutoInvokeDoesNotInvokeFunctionThatWasNotAdvertisedByFunctionChoiceBehaviorAsync()
164+
{
165+
// Arrange - register two functions in the same kernel but only advertise one of them via the
166+
// FunctionChoiceBehavior. The model is then made to request the function that was not advertised.
167+
bool unadvertisedFunctionInvoked = false;
168+
var plugin = KernelPluginFactory.CreateFromFunctions("TestPlugin", new[]
169+
{
170+
KernelFunctionFactory.CreateFromMethod(() => "allowed-result", "AllowedFunction"),
171+
KernelFunctionFactory.CreateFromMethod(() => { unadvertisedFunctionInvoked = true; return "secret"; }, "UnadvertisedFunction"),
172+
});
173+
var kernel = new Kernel();
174+
kernel.Plugins.Add(plugin);
175+
176+
var allowedFunction = kernel.Plugins.GetFunction("TestPlugin", "AllowedFunction");
177+
var settings = new GeminiPromptExecutionSettings
178+
{
179+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [allowedFunction], autoInvoke: true)
180+
};
181+
182+
// The model requests the function that was not advertised, then produces a final text answer.
183+
string functionCallResponse = $$"""
184+
{
185+
"candidates": [
186+
{
187+
"content": {
188+
"parts": [ { "functionCall": { "name": "TestPlugin{{GeminiFunction.NameSeparator}}UnadvertisedFunction", "args": {} } } ],
189+
"role": "model"
190+
},
191+
"finishReason": "STOP",
192+
"index": 0
193+
}
194+
],
195+
"usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2 }
196+
}
197+
""";
198+
string finalTextResponse = """
199+
{
200+
"candidates": [
201+
{
202+
"content": { "parts": [ { "text": "done" } ], "role": "model" },
203+
"finishReason": "STOP",
204+
"index": 0
205+
}
206+
],
207+
"usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2 }
208+
}
209+
""";
210+
using var functionCallHttpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(functionCallResponse) };
211+
this._messageHandlerStub.ResponseQueue.Enqueue(functionCallHttpResponse);
212+
213+
using var finalTextHttpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(finalTextResponse) };
214+
this._messageHandlerStub.ResponseQueue.Enqueue(finalTextHttpResponse);
215+
216+
var service = this.CreateChatCompletionService();
217+
var chatHistory = new ChatHistory();
218+
chatHistory.AddUserMessage("Do something");
219+
220+
// Act
221+
var result = await service.GetChatMessageContentsAsync(chatHistory, settings, kernel);
222+
223+
// Assert - the unadvertised function must never be invoked, even though it exists in the kernel.
224+
Assert.False(unadvertisedFunctionInvoked);
225+
// The requested tool call was processed and rejected (not silently ignored): both responses were
226+
// consumed and the model's final text answer was returned.
227+
Assert.Empty(this._messageHandlerStub.ResponseQueue);
228+
Assert.Contains(result, m => m.Content is not null && m.Content.Contains("done", StringComparison.Ordinal));
229+
}
230+
162231
private GoogleAIGeminiChatCompletionService CreateChatCompletionService(HttpClient? httpClient = null)
163232
{
164233
return new GoogleAIGeminiChatCompletionService(

‎dotnet/src/Connectors/Connectors.Google.UnitTests/GeminiToolCallBehaviorTests.cs‎

Lines changed: 171 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,78 +195,210 @@ public void KernelFunctionsCloneReturnsCorrectClone()
195195
}
196196

197197
[Fact]
198-
public void FunctionChoiceBehaviorAutoConvertsToAutoInvokeKernelFunctions()
198+
public void FunctionChoiceBehaviorAutoConvertsToEnabledFunctionsWithAllKernelFunctions()
199199
{
200200
// Arrange
201+
var kernel = CreateKernelWithFunctions("FunctionA", "FunctionB");
201202
var settings = new GeminiPromptExecutionSettings
202203
{
203204
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
204205
};
205206

206207
// Act
207-
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
208+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
208209

209210
// Assert
210211
Assert.NotNull(converted.ToolCallBehavior);
211-
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
212+
var enabledFunctions = Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
212213
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
214+
// The provided set is validated against the requested functions.
215+
Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior));
216+
// Auto continues to provide the functions across follow-up requests.
217+
Assert.Equal(int.MaxValue, converted.ToolCallBehavior.MaximumUseAttempts);
218+
Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionA", $"TestPlugin{GeminiFunction.NameSeparator}FunctionB" }, GetAdvertisedFunctionNames(enabledFunctions, kernel));
213219
}
214220

215221
[Fact]
216-
public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnableKernelFunctions()
222+
public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnabledFunctionsWithoutAutoInvoke()
217223
{
218224
// Arrange
225+
var kernel = CreateKernelWithFunctions("FunctionA");
219226
var settings = new GeminiPromptExecutionSettings
220227
{
221228
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
222229
};
223230

224231
// Act
225-
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
232+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
226233

227234
// Assert
228235
Assert.NotNull(converted.ToolCallBehavior);
229-
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
236+
Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
230237
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
231238
}
232239

233240
[Fact]
234-
public void FunctionChoiceBehaviorRequiredConvertsToAutoInvokeKernelFunctions()
241+
public void FunctionChoiceBehaviorRequiredConvertsToEnabledFunctions()
235242
{
236243
// Arrange
244+
var kernel = CreateKernelWithFunctions("FunctionA");
237245
var settings = new GeminiPromptExecutionSettings
238246
{
239247
FunctionChoiceBehavior = FunctionChoiceBehavior.Required()
240248
};
241249

242250
// Act
243-
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
251+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
244252

245253
// Assert
246254
Assert.NotNull(converted.ToolCallBehavior);
247-
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
248-
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
255+
Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
256+
Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior));
257+
// Required stops providing the functions after the first request so the model is not
258+
// repeatedly forced to call them on follow-up iterations. Auto-invocation is capped to the same
259+
// value so it does not outlast the advertised tools (MaximumUseAttempts >= MaximumAutoInvokeAttempts).
260+
Assert.Equal(1, converted.ToolCallBehavior.MaximumUseAttempts);
261+
Assert.Equal(1, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
262+
Assert.True(converted.ToolCallBehavior.MaximumUseAttempts >= converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
249263
}
250264

251265
[Fact]
252-
public void FunctionChoiceBehaviorNoneConvertsToEnableKernelFunctions()
266+
public void FunctionChoiceBehaviorNoneConvertsToEnabledFunctionsWithoutAutoInvoke()
253267
{
254268
// Arrange
269+
var kernel = CreateKernelWithFunctions("FunctionA");
255270
var settings = new GeminiPromptExecutionSettings
256271
{
257272
FunctionChoiceBehavior = FunctionChoiceBehavior.None()
258273
};
259274

260275
// Act
261-
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
276+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
262277

263278
// Assert
264279
Assert.NotNull(converted.ToolCallBehavior);
265-
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
280+
Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
266281
// None behavior doesn't auto-invoke
267282
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
268283
}
269284

285+
[Fact]
286+
public void FunctionChoiceBehaviorAutoWithEmptyFunctionListDisablesFunctionCalling()
287+
{
288+
// Arrange
289+
// An empty function list is documented as being equivalent to disabling function calling.
290+
var kernel = CreateKernelWithFunctions("FunctionA", "FunctionB");
291+
var settings = new GeminiPromptExecutionSettings
292+
{
293+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [], autoInvoke: true)
294+
};
295+
296+
// Act
297+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
298+
299+
// Assert
300+
Assert.Null(converted.ToolCallBehavior);
301+
}
302+
303+
[Fact]
304+
public void FunctionChoiceBehaviorAutoWithSubsetProvidesOnlyThatSubset()
305+
{
306+
// Arrange
307+
var kernel = CreateKernelWithFunctions("First", "Second");
308+
var first = kernel.Plugins.GetFunction("TestPlugin", "First");
309+
var settings = new GeminiPromptExecutionSettings
310+
{
311+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [first], autoInvoke: true)
312+
};
313+
314+
// Act
315+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
316+
317+
// Assert
318+
Assert.NotNull(converted.ToolCallBehavior);
319+
var enabledFunctions = Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
320+
Assert.False(GetAllowAnyRequestedKernelFunction(converted.ToolCallBehavior));
321+
// Only the function specified in the behavior is provided to the model.
322+
Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}First" }, GetAdvertisedFunctionNames(enabledFunctions, kernel));
323+
}
324+
325+
[Fact]
326+
public void FunctionChoiceBehaviorConfigurationErrorsArePropagated()
327+
{
328+
// Arrange - auto-invocation requires the declared function to exist in the kernel; when it does
329+
// not, the behavior resolution throws and the error must surface rather than be swallowed.
330+
var kernelWithFunction = CreateKernelWithFunctions("First");
331+
var first = kernelWithFunction.Plugins.GetFunction("TestPlugin", "First");
332+
var emptyKernel = Kernel.CreateBuilder().Build();
333+
var settings = new GeminiPromptExecutionSettings
334+
{
335+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: [first], autoInvoke: true)
336+
};
337+
338+
// Act & Assert
339+
Assert.Throws<KernelException>(() => GeminiPromptExecutionSettings.FromExecutionSettings(settings, emptyKernel));
340+
}
341+
342+
[Fact]
343+
public void FromExecutionSettingsDoesNotMutateCallerSettingsWhenConvertingFunctionChoiceBehavior()
344+
{
345+
// Arrange
346+
var kernel = CreateKernelWithFunctions("FunctionA");
347+
var settings = new GeminiPromptExecutionSettings
348+
{
349+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
350+
};
351+
352+
// Act
353+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
354+
355+
// Assert - the caller's original settings object must remain untouched.
356+
Assert.NotSame(settings, converted);
357+
Assert.Null(settings.ToolCallBehavior);
358+
Assert.NotNull(converted.ToolCallBehavior);
359+
}
360+
361+
[Fact]
362+
public void FromExecutionSettingsWithFrozenSettingsDoesNotThrowWhenConvertingFunctionChoiceBehavior()
363+
{
364+
// Arrange
365+
var kernel = CreateKernelWithFunctions("FunctionA");
366+
var settings = new GeminiPromptExecutionSettings
367+
{
368+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
369+
};
370+
settings.Freeze();
371+
372+
// Act - converting a frozen settings object must not attempt to mutate it.
373+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernel);
374+
375+
// Assert
376+
Assert.NotNull(converted.ToolCallBehavior);
377+
Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(converted.ToolCallBehavior);
378+
}
379+
380+
[Fact]
381+
public void FromExecutionSettingsReResolvesFunctionChoiceBehaviorPerKernel()
382+
{
383+
// Arrange - a single reused settings object must reflect each request's own kernel.
384+
var settings = new GeminiPromptExecutionSettings
385+
{
386+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
387+
};
388+
var kernelA = CreateKernelWithFunctions("FunctionA");
389+
var kernelB = CreateKernelWithFunctions("FunctionB");
390+
391+
// Act
392+
var convertedA = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernelA);
393+
var convertedB = GeminiPromptExecutionSettings.FromExecutionSettings(settings, kernelB);
394+
395+
// Assert - each request reflects its own kernel.
396+
var enabledA = Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(convertedA.ToolCallBehavior);
397+
var enabledB = Assert.IsType<GeminiToolCallBehavior.EnabledFunctions>(convertedB.ToolCallBehavior);
398+
Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionA" }, GetAdvertisedFunctionNames(enabledA, kernelA));
399+
Assert.Equal(new[] { $"TestPlugin{GeminiFunction.NameSeparator}FunctionB" }, GetAdvertisedFunctionNames(enabledB, kernelB));
400+
}
401+
270402
[Fact]
271403
public void GeminiPromptExecutionSettingsWithNoFunctionChoiceBehaviorDoesNotSetToolCallBehavior()
272404
{
@@ -311,6 +443,32 @@ private static KernelPlugin GetTestPlugin()
311443
return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]);
312444
}
313445

446+
private static Kernel CreateKernelWithFunctions(params string[] functionNames)
447+
{
448+
var functions = functionNames
449+
.Select(name => KernelFunctionFactory.CreateFromMethod(() => "result", name))
450+
.ToArray();
451+
452+
var kernel = Kernel.CreateBuilder().Build();
453+
kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("TestPlugin", functions));
454+
return kernel;
455+
}
456+
457+
private static bool GetAllowAnyRequestedKernelFunction(GeminiToolCallBehavior behavior)
458+
=> behavior.AllowAnyRequestedKernelFunction;
459+
460+
private static string[] GetAdvertisedFunctionNames(GeminiToolCallBehavior behavior, Kernel kernel)
461+
{
462+
var request = new GeminiRequest();
463+
behavior.ConfigureGeminiRequest(kernel, request);
464+
if (request.Tools is null)
465+
{
466+
return [];
467+
}
468+
469+
return request.Tools[0].Functions.Select(f => f.Name).ToArray();
470+
}
471+
314472
private static void AssertFunctions(GeminiRequest request)
315473
{
316474
Assert.NotNull(request.Tools);

‎dotnet/src/Connectors/Connectors.Google/Core/Gemini/Clients/GeminiChatCompletionClient.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ private ChatCompletionState ValidateInputAndCreateChatCompletionState(
323323
{
324324
ValidateChatHistory(chatHistory);
325325

326-
var geminiExecutionSettings = GeminiPromptExecutionSettings.FromExecutionSettings(executionSettings);
326+
var geminiExecutionSettings = GeminiPromptExecutionSettings.FromExecutionSettings(executionSettings, kernel);
327327
ValidateMaxTokens(geminiExecutionSettings.MaxTokens);
328328

329329
if (this.Logger.IsEnabled(LogLevel.Trace))

0 commit comments

Comments
 (0)