From ad06819cd5afebdd588ced8de478e3d0222f4ec8 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 2 Jul 2025 20:40:22 +0300 Subject: [PATCH 1/4] AIFunctionFactory: tolerate JSON string function parameters. --- .../Functions/AIFunctionFactory.cs | 15 +++++++++ .../ChatClientIntegrationTests.cs | 33 +++++++++++++++++++ .../Functions/AIFunctionFactoryTest.cs | 18 ++++++++++ 3 files changed, 66 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 320df4098a3..84734bcebed 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -825,6 +825,21 @@ static bool IsAsyncMethod(MethodInfo method) { try { + if (value is string potentiallyJsonString) + { + // Account for the parameter potentially being a JSON string. + // This is needed for compatibility with Semantic Kernel, + // which may pass parameters as JSON strings. + try + { + return JsonSerializer.Deserialize(potentiallyJsonString, typeInfo); + } + catch (JsonException) + { + // If the string is not valid JSON, fall through to the round-trip. + } + } + string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); return JsonSerializer.Deserialize(json, typeInfo); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index d84d767fd4c..ffa94f64531 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -341,6 +341,39 @@ public virtual async Task FunctionInvocation_NestedParameters() AssertUsageAgainstActivities(response, activities); } + [ConditionalFact] + public virtual async Task FunctionInvocation_ArrayParameter() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var chatClient = new FunctionInvokingChatClient( + new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + + List messages = + [ + new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?") + ]; + + string? shopperName = null; + List shoppingCart = []; + AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart"); + var response = await chatClient.GetResponseAsync(messages, new() + { + Tools = [func] + }); + + Assert.Equal("Peter", shopperName); + Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart); + AssertUsageAgainstActivities(response, activities); + } + private static void AssertUsageAgainstActivities(ChatResponse response, List activities) { // If the underlying IChatClient provides usage data, function invocation should aggregate the diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 84298788e8c..4467e5bec2d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -75,6 +76,23 @@ public async Task Parameters_MissingRequiredParametersFail_Async() } } + [Fact] + public async Task Parameters_ToleratesJsonEncodedParameters() + { + AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u); + + var result = await func.InvokeAsync(new() + { + ["x"] = "1", + ["y"] = JsonNode.Parse("2"), + ["z"] = JsonDocument.Parse("3"), + ["w"] = JsonDocument.Parse("4").RootElement, + ["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion + }); + + AssertExtensions.EqualFunctionCallResults(15, result); + } + [Fact] public async Task Parameters_MappedByType_Async() { From a668ecb01aba63a9ce5e394c79ab453c444457f8 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 2 Jul 2025 20:42:50 +0300 Subject: [PATCH 2/4] Add debug assertion. --- .../Functions/AIFunctionFactory.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 84734bcebed..1fd97697378 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -827,6 +827,8 @@ static bool IsAsyncMethod(MethodInfo method) { if (value is string potentiallyJsonString) { + Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); + // Account for the parameter potentially being a JSON string. // This is needed for compatibility with Semantic Kernel, // which may pass parameters as JSON strings. From 50deb8fd62193a7c1ef8fddb1a9a53d39b7e5ea7 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 2 Jul 2025 22:46:03 +0300 Subject: [PATCH 3/4] Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs Co-authored-by: Stephen Toub --- .../Functions/AIFunctionFactory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 1fd97697378..9fd89a6d6c3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -830,8 +830,8 @@ static bool IsAsyncMethod(MethodInfo method) Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); // Account for the parameter potentially being a JSON string. - // This is needed for compatibility with Semantic Kernel, - // which may pass parameters as JSON strings. + // The value is a string but the type is not. Try to deserialize it under the assumption that it's JSON. + // If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize. try { return JsonSerializer.Deserialize(potentiallyJsonString, typeInfo); From 77d324af616a4d7d2f20e0ead63af7082426d91e Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 3 Jul 2025 13:30:03 +0300 Subject: [PATCH 4/4] Add regex-based JSON string recognition and add more tests. --- .../Functions/AIFunctionFactory.cs | 34 ++++++++++++++- .../Functions/AIFunctionFactoryTest.cs | 43 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 9fd89a6d6c3..e864923883e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -26,6 +26,7 @@ #pragma warning disable S2333 // Redundant modifiers should not be used #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1202 // Public members should come before private members +#pragma warning disable SA1203 // Constants should appear before fields namespace Microsoft.Extensions.AI; @@ -825,7 +826,7 @@ static bool IsAsyncMethod(MethodInfo method) { try { - if (value is string potentiallyJsonString) + if (value is string text && IsPotentiallyJson(text)) { Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); @@ -834,7 +835,7 @@ static bool IsAsyncMethod(MethodInfo method) // If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize. try { - return JsonSerializer.Deserialize(potentiallyJsonString, typeInfo); + return JsonSerializer.Deserialize(text, typeInfo); } catch (JsonException) { @@ -1038,6 +1039,35 @@ private record struct DescriptorKey( AIJsonSchemaCreateOptions SchemaOptions); } + /// + /// Quickly checks if the specified string is potentially JSON + /// by checking if the first non-whitespace characters are valid JSON start tokens. + /// + /// The string to check. + /// If then the string is definitely not valid JSON. + private static bool IsPotentiallyJson(string value) => PotentiallyJsonRegex().IsMatch(value); +#if NET + [GeneratedRegex(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex PotentiallyJsonRegex(); +#else + private static Regex PotentiallyJsonRegex() => _potentiallyJsonRegex; + private static readonly Regex _potentiallyJsonRegex = new(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); +#endif + private const string PotentiallyJsonRegexString = """ + ^\s* # Optional whitespace at the start of the string + ( null # null literal + | false # false literal + | true # true literal + | \d # positive number + | -\d # negative number + | " # string + | \[ # start array + | { # start object + | // # Start of single-line comment + | /\* # Start of multi-line comment + ) + """; + /// /// Removes characters from a .NET member name that shouldn't be used in an AI function name. /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 4467e5bec2d..b15d200a39a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -93,6 +93,49 @@ public async Task Parameters_ToleratesJsonEncodedParameters() AssertExtensions.EqualFunctionCallResults(15, result); } + [Theory] + [InlineData(" null")] + [InlineData(" false ")] + [InlineData("true ")] + [InlineData("42")] + [InlineData("0.0")] + [InlineData("-1e15")] + [InlineData(" \"I am a string!\" ")] + [InlineData(" {}")] + [InlineData("[]")] + public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = jsonStringParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n")] + [InlineData("I am a string!")] + [InlineData("/* Code snippet */ int main(void) { return 0; }")] + [InlineData("let rec Y F x = F (Y F) x")] + [InlineData("+3")] + public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = invalidJsonParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + [Fact] public async Task Parameters_MappedByType_Async() {