Skip to content

Commit 4a09cbd

Browse files
Copilotstephentoubrogerbarreto
authored
.Net: Add FunctionChoiceBehavior support to Google Gemini connector (#13256)
- [x] Understand the current Google connector implementation and identify what needs to be changed - [x] Add FunctionChoiceBehavior support to GeminiPromptExecutionSettings - [x] Update GeminiChatCompletionClient to handle FunctionChoiceBehavior - [x] Map FunctionChoiceBehavior to GeminiToolCallBehavior internally - [x] Populate Items collection with FunctionCallContent and FunctionResultContent - [x] Build and test the changes (all 326 unit tests passing) - [x] Address code review comments (optimize Kernel creation, improve exception handling) - [x] Replace integration tests with unit tests following existing patterns - [x] Update tests to use only IChatClient methods - [x] Use static readonly field for shared Kernel instance - [x] Remove unnecessary using directives - [x] Request final code review ## Summary This PR enables the standard `FunctionChoiceBehavior` API to work with Google Gemini models, fixing the hallucination issue reported in the original issue. Users can now use: ```csharp var settings = new GeminiPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; ``` Instead of the Gemini-specific: ```csharp var settings = new GeminiPromptExecutionSettings { ToolCallBehavior = GeminiToolCallBehavior.AutoInvokeKernelFunctions }; ``` The connector automatically converts `FunctionChoiceBehavior` to the appropriate `GeminiToolCallBehavior` internally, while maintaining full backward compatibility with existing code. ## Tests Added comprehensive unit tests in `GeminiChatClientFunctionCallingTests.cs` that use **only IChatClient methods** to verify: - Conversion from IChatCompletionService to IChatClient using AsChatClient() - Receiving function calls in responses via IChatClient - Streaming responses via IChatClient - Basic message sending and receiving via IChatClient All 326 unit tests passing (322 existing + 4 new IChatClient tests). <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>.Net: SK not working correctly with gemini</issue_title> > <issue_description>I am trying SK for the first time and as Gemini is free, so I am trying gemini models. > Below is the code that I am trying. > > ```csharp > using System.ComponentModel; > using Microsoft.SemanticKernel; > using Microsoft.SemanticKernel.Agents; > > const string api_key = "__API_KEY__"; > const string model = "gemini-2.5-pro"; > > var builder = Kernel.CreateBuilder() > .AddGoogleAIGeminiChatCompletion(model, api_key); > > var kernel = builder.Build(); > > kernel.Plugins.Add(KernelPluginFactory.CreateFromType<LightsController>()); > > ChatCompletionAgent agent = new() > { > Kernel = kernel, > Name = "Lights Agent", > Description = "An agent that can provide information about available lights.", > Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()}) > }; > > await foreach (var response in agent.InvokeAsync("Get a list of available lights")) > { > Console.WriteLine(response.Message); > } > > public class LightsController > { > private readonly Dictionary<int,string> _lights = new() > { > { 1, "Table Lamp" }, > { 2, "Floor Lamp" }, > { 3, "Ceiling Light" } > }; > > [KernelFunction] > [Description("Get a list of available lights")] > public string GetLights() > { > return string.Join(", ", _lights.Select(kv => $"{kv.Key}: {kv.Value}")); > } > } > ``` > > And I am getting the output below. > Of course. Here is a list of your available lights and their current status: > > ``` > ### **Living Room** > * **Living Room Lamp** > * **Status:** On > * **Brightness:** 70% > * **Ceiling Fan Light** > * **Status:** Off > > ### **Kitchen** > * **Kitchen Downlights** (Group) > * **Status:** On > * **Brightness:** 100% > * **Under Cabinet Lights** > * **Status:** On > * **Brightness:** 80% > > ### **Bedroom** > * **Bedroom Mood Light** > * **Status:** On > * **Brightness:** 40% > * **Color:** Warm White > * **Bedside Lamp** > * **Status:** Off > > ### **Office** > * **Office Desk Lamp** > * **Status:** Off > > You can control these by name, such as *"Turn off the Living Room Lamp"* or *"Set the Bedroom Mood Light to blue."* > > ``` > > If you see output, it's not matching with the lights that I have in my code. Looks like hallucination. > > > </issue_description> > > <agent_instructions>There are multiple related issues here, including: > #12702 > #12998 > #12998 > > The gemini / google connector needs to be updated to support the function calling abstraction, including ToolChoiceBehavior, FunctionCallContent, FunctionResultContent, etc. </agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > <comment_new><author>@rogerbarreto</author><body> > Function calling is not yet fully supported (multiple function calls) in the Google Connector. > > For best experience of function calling as of now as @futugyou rightly mentioned is using their compatible OpenAI endpoints with our `OpenAI` Connector. > > More info here: > > https://ai.google.dev/gemini-api/docs/openai > > https://cloud.google.com/vertex-ai/generative-ai/docs/migrate/openai/overview</body></comment_new> > </comments> > </details> Fixes #12702 <!-- START COPILOT CODING AGENT TIPS --> --- 💬 Share your feedback on Copilot coding agent for the chance to win a $200 gift card! Click [here](https://survey3.medallia.com/?EAHeSx-AP01bZqG0Ld9QLQ) to start the survey. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
1 parent 0a9ee40 commit 4a09cbd

4 files changed

Lines changed: 392 additions & 20 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Net.Http;
9+
using System.Threading.Tasks;
10+
using Microsoft.Extensions.AI;
11+
using Microsoft.SemanticKernel;
12+
using Microsoft.SemanticKernel.ChatCompletion;
13+
using Microsoft.SemanticKernel.Connectors.Google;
14+
using Xunit;
15+
16+
namespace SemanticKernel.Connectors.Google.UnitTests.Core.Gemini.Clients;
17+
18+
/// <summary>
19+
/// Unit tests for IChatClient-based function calling with Gemini using FunctionChoiceBehavior.
20+
/// </summary>
21+
public sealed class GeminiChatClientFunctionCallingTests : IDisposable
22+
{
23+
private readonly HttpClient _httpClient;
24+
private readonly string _responseContent;
25+
private readonly string _responseContentWithFunction;
26+
private readonly HttpMessageHandlerStub _messageHandlerStub;
27+
private readonly GeminiFunction _timePluginDate, _timePluginNow;
28+
private readonly Kernel _kernelWithFunctions;
29+
private const string ChatTestDataFilePath = "./TestData/chat_one_response.json";
30+
private const string ChatTestDataWithFunctionFilePath = "./TestData/chat_one_function_response.json";
31+
32+
public GeminiChatClientFunctionCallingTests()
33+
{
34+
this._responseContent = File.ReadAllText(ChatTestDataFilePath);
35+
this._responseContentWithFunction = File.ReadAllText(ChatTestDataWithFunctionFilePath)
36+
.Replace("%nameSeparator%", GeminiFunction.NameSeparator, StringComparison.Ordinal);
37+
this._messageHandlerStub = new HttpMessageHandlerStub();
38+
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(
39+
this._responseContent);
40+
41+
this._httpClient = new HttpClient(this._messageHandlerStub, false);
42+
43+
var kernelPlugin = KernelPluginFactory.CreateFromFunctions("TimePlugin", new[]
44+
{
45+
KernelFunctionFactory.CreateFromMethod((string? format = null)
46+
=> DateTime.Now.Date.ToString(format, CultureInfo.InvariantCulture), "Date", "TimePlugin.Date"),
47+
KernelFunctionFactory.CreateFromMethod(()
48+
=> DateTime.Now.ToString("", CultureInfo.InvariantCulture), "Now", "TimePlugin.Now",
49+
parameters: [new KernelParameterMetadata("param1") { ParameterType = typeof(string), Description = "desc", IsRequired = false }]),
50+
});
51+
IList<KernelFunctionMetadata> functions = kernelPlugin.GetFunctionsMetadata();
52+
53+
this._timePluginDate = functions[0].ToGeminiFunction();
54+
this._timePluginNow = functions[1].ToGeminiFunction();
55+
56+
this._kernelWithFunctions = new Kernel();
57+
this._kernelWithFunctions.Plugins.Add(kernelPlugin);
58+
}
59+
60+
[Fact]
61+
public async Task ChatClientShouldConvertToIChatClientSuccessfullyAsync()
62+
{
63+
// Arrange
64+
var chatCompletionService = this.CreateChatCompletionService();
65+
66+
// Act
67+
var chatClient = chatCompletionService.AsChatClient();
68+
69+
// Assert - Verify conversion works
70+
Assert.NotNull(chatClient);
71+
Assert.IsAssignableFrom<IChatClient>(chatClient);
72+
73+
// Verify we can make a basic call through IChatClient
74+
var messages = new List<ChatMessage>
75+
{
76+
new(ChatRole.User, "What time is it?")
77+
};
78+
79+
var response = await chatClient.GetResponseAsync(messages);
80+
81+
Assert.NotNull(response);
82+
Assert.NotEmpty(response.Messages);
83+
}
84+
85+
[Fact]
86+
public async Task ChatClientShouldReceiveFunctionCallsInResponseAsync()
87+
{
88+
// Arrange
89+
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(this._responseContentWithFunction);
90+
var chatCompletionService = this.CreateChatCompletionService();
91+
var chatClient = chatCompletionService.AsChatClient();
92+
93+
var settings = new GeminiPromptExecutionSettings
94+
{
95+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
96+
};
97+
var chatOptions = settings.ToChatOptions(this._kernelWithFunctions);
98+
99+
var messages = new List<ChatMessage>
100+
{
101+
new(ChatRole.User, "What time is it?")
102+
};
103+
104+
// Act
105+
var response = await chatClient.GetResponseAsync(messages, chatOptions);
106+
107+
// Assert - Verify that FunctionCallContent is returned in the response
108+
Assert.NotNull(response);
109+
var functionCalls = response.Messages
110+
.SelectMany(m => m.Contents)
111+
.OfType<Microsoft.Extensions.AI.FunctionCallContent>()
112+
.ToList();
113+
114+
Assert.NotEmpty(functionCalls);
115+
var functionCall = functionCalls.First();
116+
Assert.Contains(this._timePluginNow.FunctionName, functionCall.Name, StringComparison.OrdinalIgnoreCase);
117+
}
118+
119+
[Fact]
120+
public async Task ChatClientShouldStreamResponsesAsync()
121+
{
122+
// Arrange
123+
var chatCompletionService = this.CreateChatCompletionService();
124+
var chatClient = chatCompletionService.AsChatClient();
125+
126+
var settings = new GeminiPromptExecutionSettings
127+
{
128+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
129+
};
130+
var chatOptions = settings.ToChatOptions(this._kernelWithFunctions);
131+
132+
var messages = new List<ChatMessage>
133+
{
134+
new(ChatRole.User, "What time is it?")
135+
};
136+
137+
// Act
138+
var updates = new List<ChatResponseUpdate>();
139+
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, chatOptions))
140+
{
141+
updates.Add(update);
142+
}
143+
144+
// Assert - Verify that streaming works and returns updates
145+
Assert.NotEmpty(updates);
146+
}
147+
148+
[Fact]
149+
public async Task AsChatClientConvertsServiceToIChatClientAsync()
150+
{
151+
// Arrange
152+
var chatCompletionService = this.CreateChatCompletionService();
153+
154+
// Act
155+
var chatClient = chatCompletionService.AsChatClient();
156+
157+
// Assert
158+
Assert.NotNull(chatClient);
159+
Assert.IsAssignableFrom<IChatClient>(chatClient);
160+
}
161+
162+
private GoogleAIGeminiChatCompletionService CreateChatCompletionService(HttpClient? httpClient = null)
163+
{
164+
return new GoogleAIGeminiChatCompletionService(
165+
modelId: "fake-model",
166+
apiKey: "fake-key",
167+
apiVersion: GoogleAIVersion.V1,
168+
httpClient: httpClient ?? this._httpClient);
169+
}
170+
171+
public void Dispose()
172+
{
173+
this._httpClient.Dispose();
174+
this._messageHandlerStub.Dispose();
175+
}
176+
}

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,111 @@ public void KernelFunctionsCloneReturnsCorrectClone()
194194
Assert.Equivalent(toolcallbehavior, clone, strict: true);
195195
}
196196

197+
[Fact]
198+
public void FunctionChoiceBehaviorAutoConvertsToAutoInvokeKernelFunctions()
199+
{
200+
// Arrange
201+
var settings = new GeminiPromptExecutionSettings
202+
{
203+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
204+
};
205+
206+
// Act
207+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
208+
209+
// Assert
210+
Assert.NotNull(converted.ToolCallBehavior);
211+
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
212+
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
213+
}
214+
215+
[Fact]
216+
public void FunctionChoiceBehaviorAutoWithNoAutoInvokeConvertsToEnableKernelFunctions()
217+
{
218+
// Arrange
219+
var settings = new GeminiPromptExecutionSettings
220+
{
221+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false)
222+
};
223+
224+
// Act
225+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
226+
227+
// Assert
228+
Assert.NotNull(converted.ToolCallBehavior);
229+
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
230+
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
231+
}
232+
233+
[Fact]
234+
public void FunctionChoiceBehaviorRequiredConvertsToAutoInvokeKernelFunctions()
235+
{
236+
// Arrange
237+
var settings = new GeminiPromptExecutionSettings
238+
{
239+
FunctionChoiceBehavior = FunctionChoiceBehavior.Required()
240+
};
241+
242+
// Act
243+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
244+
245+
// Assert
246+
Assert.NotNull(converted.ToolCallBehavior);
247+
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
248+
Assert.True(converted.ToolCallBehavior.MaximumAutoInvokeAttempts > 0);
249+
}
250+
251+
[Fact]
252+
public void FunctionChoiceBehaviorNoneConvertsToEnableKernelFunctions()
253+
{
254+
// Arrange
255+
var settings = new GeminiPromptExecutionSettings
256+
{
257+
FunctionChoiceBehavior = FunctionChoiceBehavior.None()
258+
};
259+
260+
// Act
261+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
262+
263+
// Assert
264+
Assert.NotNull(converted.ToolCallBehavior);
265+
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
266+
// None behavior doesn't auto-invoke
267+
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
268+
}
269+
270+
[Fact]
271+
public void GeminiPromptExecutionSettingsWithNoFunctionChoiceBehaviorDoesNotSetToolCallBehavior()
272+
{
273+
// Arrange
274+
var settings = new GeminiPromptExecutionSettings();
275+
276+
// Act
277+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
278+
279+
// Assert
280+
Assert.Null(converted.ToolCallBehavior);
281+
}
282+
283+
[Fact]
284+
public void GeminiPromptExecutionSettingsPreservesExistingToolCallBehavior()
285+
{
286+
// Arrange
287+
var settings = new GeminiPromptExecutionSettings
288+
{
289+
ToolCallBehavior = GeminiToolCallBehavior.EnableKernelFunctions,
290+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
291+
};
292+
293+
// Act
294+
var converted = GeminiPromptExecutionSettings.FromExecutionSettings(settings);
295+
296+
// Assert - ToolCallBehavior should be preserved when already set
297+
Assert.NotNull(converted.ToolCallBehavior);
298+
Assert.IsType<GeminiToolCallBehavior.KernelFunctions>(converted.ToolCallBehavior);
299+
Assert.Equal(0, converted.ToolCallBehavior.MaximumAutoInvokeAttempts);
300+
}
301+
197302
private static KernelPlugin GetTestPlugin()
198303
{
199304
var function = KernelFunctionFactory.CreateFromMethod(

dotnet/src/Connectors/Connectors.Google/GeminiPromptExecutionSettings.cs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ public IDictionary<string, string>? Labels
188188
/// the function, and sending back the result. The intermediate messages will be retained in the
189189
/// <see cref="ChatHistory"/> if an instance was provided.
190190
/// </remarks>
191+
/// <remarks>
192+
/// This property is deprecated. Use <see cref="PromptExecutionSettings.FunctionChoiceBehavior"/> instead.
193+
/// </remarks>
191194
public GeminiToolCallBehavior? ToolCallBehavior
192195
{
193196
get => this._toolCallBehavior;
@@ -357,11 +360,71 @@ public static GeminiPromptExecutionSettings FromExecutionSettings(PromptExecutio
357360
{
358361
case null:
359362
return new GeminiPromptExecutionSettings();
360-
case GeminiPromptExecutionSettings settings:
361-
return settings;
363+
case GeminiPromptExecutionSettings geminiSettings:
364+
// If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it
365+
if (geminiSettings.FunctionChoiceBehavior is not null && geminiSettings.ToolCallBehavior is null)
366+
{
367+
geminiSettings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(geminiSettings.FunctionChoiceBehavior);
368+
}
369+
return geminiSettings;
362370
}
363371

364372
var json = JsonSerializer.Serialize(executionSettings);
365-
return JsonSerializer.Deserialize<GeminiPromptExecutionSettings>(json, JsonOptionsCache.ReadPermissive)!;
373+
var settings = JsonSerializer.Deserialize<GeminiPromptExecutionSettings>(json, JsonOptionsCache.ReadPermissive)!;
374+
375+
// If FunctionChoiceBehavior is set and ToolCallBehavior is not, convert it
376+
if (executionSettings.FunctionChoiceBehavior is not null && settings.ToolCallBehavior is null)
377+
{
378+
settings.ToolCallBehavior = ConvertFunctionChoiceBehaviorToToolCallBehavior(executionSettings.FunctionChoiceBehavior);
379+
}
380+
381+
return settings;
382+
}
383+
384+
/// <summary>
385+
/// Shared empty kernel instance used for FunctionChoiceBehavior conversion.
386+
/// </summary>
387+
private static readonly Kernel s_emptyKernel = new();
388+
389+
/// <summary>
390+
/// Converts a <see cref="FunctionChoiceBehavior"/> to a <see cref="GeminiToolCallBehavior"/>.
391+
/// </summary>
392+
/// <param name="functionChoiceBehavior">The <see cref="FunctionChoiceBehavior"/> to convert.</param>
393+
/// <returns>The converted <see cref="GeminiToolCallBehavior"/>.</returns>
394+
internal static GeminiToolCallBehavior? ConvertFunctionChoiceBehaviorToToolCallBehavior(FunctionChoiceBehavior? functionChoiceBehavior)
395+
{
396+
if (functionChoiceBehavior is null)
397+
{
398+
return null;
399+
}
400+
401+
// Check the type and determine auto-invoke by reflection or known behavior types
402+
// All FunctionChoiceBehavior types (Auto, Required, None) support auto-invoke
403+
// We use a simple approach: get the configuration with minimal context to check AutoInvoke
404+
try
405+
{
406+
var context = new FunctionChoiceBehaviorConfigurationContext(new ChatHistory())
407+
{
408+
Kernel = s_emptyKernel, // Provide an empty kernel for the configuration
409+
RequestSequenceIndex = 0
410+
};
411+
var config = functionChoiceBehavior.GetConfiguration(context);
412+
413+
// Return appropriate GeminiToolCallBehavior based on AutoInvoke setting
414+
if (config.AutoInvoke)
415+
{
416+
return GeminiToolCallBehavior.AutoInvokeKernelFunctions;
417+
}
418+
419+
return GeminiToolCallBehavior.EnableKernelFunctions;
420+
}
421+
#pragma warning disable CA1031 // Do not catch general exception types
422+
catch
423+
#pragma warning restore CA1031
424+
{
425+
// If we can't get configuration (e.g., due to missing dependencies or unexpected state),
426+
// default to EnableKernelFunctions as the safer option that doesn't auto-invoke
427+
return GeminiToolCallBehavior.EnableKernelFunctions;
428+
}
366429
}
367430
}

0 commit comments

Comments
 (0)