Skip to content

Commit 38c0f6c

Browse files
.Net: Function parameters/arguments type handling fixes (#11211)
### Motivation, Context and Description This PR fixes the issue where the schema of parameters of `AIFunction` was not mapped to the schema of parameters of `KernelFunction`. As a result, the types of parameters of kernel functions were not advertised to the LLM, leading to the LLM calling the function with arguments of incorrect types. For example, the LLM called the list_commits function and provided the string value "4" as an argument for the `perPage` parameter of the number type. This PR also adds the `RetainArgumentTypes` function choice behavior option, which allows preserving the type of function arguments provided by the LLM for a function call, as opposed to converting the arguments to strings and losing the type information. Without this option, the `AIFunctionKernelFunction` invokes `AIFunction` with string arguments, and as a result, the underlying MCP call fails with the _ModelContextProtocol.Client.McpClientException: 'Request failed (server side): Invalid input: [{"code":"invalid_type","expected":"number","received":"string","path":["perPage"],"message":"Expected number, received string"}]'_ error, indicating a parameter type mismatch.
1 parent 858644a commit 38c0f6c

File tree

5 files changed

+85
-8
lines changed

5 files changed

+85
-8
lines changed

dotnet/samples/Demos/ModelContextProtocolPlugin/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
OpenAIPromptExecutionSettings executionSettings = new()
5656
{
5757
Temperature = 0,
58-
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
58+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true })
5959
};
6060

6161
// Test using GitHub tools

dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Core/ClientCoreTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,69 @@ public async Task ItShouldReplaceDisallowedCharactersInFunctionName(ChatMessageC
289289
}
290290
}
291291

292+
[Theory]
293+
[InlineData(true)]
294+
[InlineData(false)]
295+
public async Task FunctionArgumentTypesShouldBeRetainedIfSpecifiedAsync(bool retain)
296+
{
297+
// Arrange
298+
using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
299+
{
300+
Content = new StringContent(File.ReadAllText("TestData/chat_completion_multiple_function_calls_test_response.json"))
301+
};
302+
303+
using HttpMessageHandlerStub handler = new();
304+
handler.ResponseToReturn = responseMessage;
305+
using HttpClient client = new(handler);
306+
307+
var clientCore = new ClientCore("modelId", "apikey", httpClient: client);
308+
309+
ChatHistory chatHistory = [];
310+
chatHistory.Add(new ChatMessageContent(AuthorRole.User, "Hello"));
311+
312+
var settings = new OpenAIPromptExecutionSettings()
313+
{
314+
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(
315+
autoInvoke: false,
316+
options: new FunctionChoiceBehaviorOptions
317+
{
318+
RetainArgumentTypes = retain
319+
})
320+
};
321+
322+
// Act
323+
var result = await clientCore.GetChatMessageContentsAsync("gpt-4", chatHistory, settings, new Kernel());
324+
325+
// Assert
326+
var functionCalls = FunctionCallContent.GetFunctionCalls(result.Single()).ToArray();
327+
Assert.NotEmpty(functionCalls);
328+
329+
var getCurrentWeatherFunctionCall = functionCalls.FirstOrDefault(call => call.FunctionName == "GetCurrentWeather");
330+
Assert.NotNull(getCurrentWeatherFunctionCall);
331+
332+
var intArgumentsFunctionCall = functionCalls.FirstOrDefault(call => call.FunctionName == "IntArguments");
333+
Assert.NotNull(intArgumentsFunctionCall);
334+
335+
if (retain)
336+
{
337+
var location = Assert.IsType<JsonElement>(getCurrentWeatherFunctionCall.Arguments?["location"]);
338+
Assert.Equal(JsonValueKind.String, location.ValueKind);
339+
Assert.Equal("Boston, MA", location.ToString());
340+
341+
var age = Assert.IsType<JsonElement>(intArgumentsFunctionCall.Arguments?["age"]);
342+
Assert.Equal(JsonValueKind.Number, age.ValueKind);
343+
Assert.Equal(36, age.GetInt32());
344+
}
345+
else
346+
{
347+
var location = Assert.IsType<string>(getCurrentWeatherFunctionCall.Arguments?["location"]);
348+
Assert.Equal("Boston, MA", location);
349+
350+
var age = Assert.IsType<string>(intArgumentsFunctionCall.Arguments?["age"]);
351+
Assert.Equal("36", age);
352+
}
353+
}
354+
292355
internal sealed class ChatMessageContentWithFunctionCalls : TheoryData<ChatMessageContent, bool>
293356
{
294357
private static readonly ChatToolCall s_functionCallWithInvalidFunctionName = ChatToolCall.CreateFunctionToolCall(id: "call123", functionName: "bar.foo", functionArguments: BinaryData.FromString("{}"));

dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ internal async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsy
187187
throw;
188188
}
189189

190-
chatMessageContent = this.CreateChatMessageContent(chatCompletion, targetModel);
190+
chatMessageContent = this.CreateChatMessageContent(chatCompletion, targetModel, functionCallingConfig.Options?.RetainArgumentTypes ?? false);
191191
activity?.SetCompletionResponse([chatMessageContent], chatCompletion.Usage.InputTokenCount, chatCompletion.Usage.OutputTokenCount);
192192
}
193193

@@ -358,7 +358,7 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
358358
ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex);
359359

360360
// Translate all entries into FunctionCallContent instances for diagnostics purposes.
361-
functionCallContents = this.GetFunctionCallContents(toolCalls).ToArray();
361+
functionCallContents = this.GetFunctionCallContents(toolCalls, functionCallingConfig.Options?.RetainArgumentTypes ?? false).ToArray();
362362
}
363363
finally
364364
{
@@ -887,11 +887,11 @@ private static ChatMessageContentPart GetImageContentItem(ImageContent imageCont
887887
return null;
888888
}
889889

890-
private OpenAIChatMessageContent CreateChatMessageContent(OpenAIChatCompletion completion, string targetModel)
890+
private OpenAIChatMessageContent CreateChatMessageContent(OpenAIChatCompletion completion, string targetModel, bool retainArgumentTypes)
891891
{
892892
var message = new OpenAIChatMessageContent(completion, targetModel, this.GetChatCompletionMetadata(completion));
893893

894-
message.Items.AddRange(this.GetFunctionCallContents(completion.ToolCalls));
894+
message.Items.AddRange(this.GetFunctionCallContents(completion.ToolCalls, retainArgumentTypes));
895895

896896
return message;
897897
}
@@ -911,7 +911,7 @@ private OpenAIChatMessageContent CreateChatMessageContent(ChatMessageRole chatRo
911911
return message;
912912
}
913913

914-
private List<FunctionCallContent> GetFunctionCallContents(IEnumerable<ChatToolCall> toolCalls)
914+
private List<FunctionCallContent> GetFunctionCallContents(IEnumerable<ChatToolCall> toolCalls, bool retainArgumentTypes)
915915
{
916916
List<FunctionCallContent> result = [];
917917

@@ -926,7 +926,7 @@ private List<FunctionCallContent> GetFunctionCallContents(IEnumerable<ChatToolCa
926926
try
927927
{
928928
arguments = JsonSerializer.Deserialize<KernelArguments>(toolCall.FunctionArguments);
929-
if (arguments is not null)
929+
if (arguments is { Count: > 0 } && !retainArgumentTypes)
930930
{
931931
// Iterate over copy of the names to avoid mutating the dictionary while enumerating it
932932
var names = arguments.Names.ToArray();

dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/AIFunctionKernelFunction.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ private static IReadOnlyList<KernelParameterMetadata> MapParameterMetadata(AIFun
7878
DefaultValue = param.Value.TryGetProperty("default", out JsonElement defaultValue) ? defaultValue : null,
7979
IsRequired = param.Value.TryGetProperty("required", out JsonElement required) && required.GetBoolean(),
8080
ParameterType = paramInfo?.ParameterType,
81-
Schema = param.Value.TryGetProperty("schema", out JsonElement schema) ? new KernelJsonSchema(schema) : null,
81+
Schema = param.Value.TryGetProperty("schema", out JsonElement schema)
82+
? new KernelJsonSchema(schema)
83+
: new KernelJsonSchema(param.Value),
8284
});
8385
}
8486

dotnet/src/SemanticKernel.Abstractions/AI/FunctionChoiceBehaviors/FunctionChoiceBehaviorOptions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3+
using System.ComponentModel;
4+
using System.Diagnostics.CodeAnalysis;
35
using System.Text.Json.Serialization;
46

57
namespace Microsoft.SemanticKernel;
@@ -35,4 +37,14 @@ public sealed class FunctionChoiceBehaviorOptions
3537
/// </remarks>
3638
[JsonPropertyName("allow_strict_schema_adherence")]
3739
public bool AllowStrictSchemaAdherence { get; set; } = false;
40+
41+
/// <summary>
42+
/// Gets or sets whether the types of function arguments provided by the AI model are retained by SK or not.
43+
/// By default, or if set to false, SK will deserialize function arguments to strings, and type information will not be retained.
44+
/// If set to true, function arguments will be deserialized as <see cref="System.Text.Json.JsonElement"/>, which retains type information.
45+
/// </summary>
46+
[JsonIgnore]
47+
[Experimental("SKEXP0001")]
48+
[EditorBrowsable(EditorBrowsableState.Never)]
49+
public bool RetainArgumentTypes { get; set; } = false;
3850
}

0 commit comments

Comments
 (0)