Skip to content

Commit 2fa81c9

Browse files
Merge branch 'main' into users/evgenyfedorov/add_cputime
2 parents 0cad699 + 3b96435 commit 2fa81c9

File tree

3 files changed

+143
-0
lines changed

3 files changed

+143
-0
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#pragma warning disable S2333 // Redundant modifiers should not be used
2727
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
2828
#pragma warning disable SA1202 // Public members should come before private members
29+
#pragma warning disable SA1203 // Constants should appear before fields
2930

3031
namespace Microsoft.Extensions.AI;
3132

@@ -825,6 +826,23 @@ static bool IsAsyncMethod(MethodInfo method)
825826
{
826827
try
827828
{
829+
if (value is string text && IsPotentiallyJson(text))
830+
{
831+
Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch.");
832+
833+
// Account for the parameter potentially being a JSON string.
834+
// The value is a string but the type is not. Try to deserialize it under the assumption that it's JSON.
835+
// If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize.
836+
try
837+
{
838+
return JsonSerializer.Deserialize(text, typeInfo);
839+
}
840+
catch (JsonException)
841+
{
842+
// If the string is not valid JSON, fall through to the round-trip.
843+
}
844+
}
845+
828846
string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType()));
829847
return JsonSerializer.Deserialize(json, typeInfo);
830848
}
@@ -1021,6 +1039,34 @@ private record struct DescriptorKey(
10211039
AIJsonSchemaCreateOptions SchemaOptions);
10221040
}
10231041

1042+
/// <summary>
1043+
/// Quickly checks if the specified string is potentially JSON
1044+
/// by checking if the first non-whitespace characters are valid JSON start tokens.
1045+
/// </summary>
1046+
/// <param name="value">The string to check.</param>
1047+
/// <returns>If <see langword="false"/> then the string is definitely not valid JSON.</returns>
1048+
private static bool IsPotentiallyJson(string value) => PotentiallyJsonRegex().IsMatch(value);
1049+
#if NET
1050+
[GeneratedRegex(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace)]
1051+
private static partial Regex PotentiallyJsonRegex();
1052+
#else
1053+
private static Regex PotentiallyJsonRegex() => _potentiallyJsonRegex;
1054+
private static readonly Regex _potentiallyJsonRegex = new(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
1055+
#endif
1056+
private const string PotentiallyJsonRegexString = """
1057+
^\s* # Optional whitespace at the start of the string
1058+
( null # null literal
1059+
| false # false literal
1060+
| true # true literal
1061+
| -?[0-9]# number
1062+
| " # string
1063+
| \[ # start array
1064+
| { # start object
1065+
| // # Start of single-line comment
1066+
| /\* # Start of multi-line comment
1067+
)
1068+
""";
1069+
10241070
/// <summary>
10251071
/// Removes characters from a .NET member name that shouldn't be used in an AI function name.
10261072
/// </summary>

test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,39 @@ public virtual async Task FunctionInvocation_NestedParameters()
341341
AssertUsageAgainstActivities(response, activities);
342342
}
343343

344+
[ConditionalFact]
345+
public virtual async Task FunctionInvocation_ArrayParameter()
346+
{
347+
SkipIfNotEnabled();
348+
349+
var sourceName = Guid.NewGuid().ToString();
350+
var activities = new List<Activity>();
351+
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
352+
.AddSource(sourceName)
353+
.AddInMemoryExporter(activities)
354+
.Build();
355+
356+
using var chatClient = new FunctionInvokingChatClient(
357+
new OpenTelemetryChatClient(_chatClient, sourceName: sourceName));
358+
359+
List<ChatMessage> messages =
360+
[
361+
new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?")
362+
];
363+
364+
string? shopperName = null;
365+
List<string> shoppingCart = [];
366+
AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart");
367+
var response = await chatClient.GetResponseAsync(messages, new()
368+
{
369+
Tools = [func]
370+
});
371+
372+
Assert.Equal("Peter", shopperName);
373+
Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart);
374+
AssertUsageAgainstActivities(response, activities);
375+
}
376+
344377
private static void AssertUsageAgainstActivities(ChatResponse response, List<Activity> activities)
345378
{
346379
// If the underlying IChatClient provides usage data, function invocation should aggregate the

test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.ComponentModel;
77
using System.Reflection;
88
using System.Text.Json;
9+
using System.Text.Json.Nodes;
910
using System.Text.Json.Serialization;
1011
using System.Threading;
1112
using System.Threading.Tasks;
@@ -75,6 +76,69 @@ public async Task Parameters_MissingRequiredParametersFail_Async()
7576
}
7677
}
7778

79+
[Fact]
80+
public async Task Parameters_ToleratesJsonEncodedParameters()
81+
{
82+
AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u);
83+
84+
var result = await func.InvokeAsync(new()
85+
{
86+
["x"] = "1",
87+
["y"] = JsonNode.Parse("2"),
88+
["z"] = JsonDocument.Parse("3"),
89+
["w"] = JsonDocument.Parse("4").RootElement,
90+
["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion
91+
});
92+
93+
AssertExtensions.EqualFunctionCallResults(15, result);
94+
}
95+
96+
[Theory]
97+
[InlineData(" null")]
98+
[InlineData(" false ")]
99+
[InlineData("true ")]
100+
[InlineData("42")]
101+
[InlineData("0.0")]
102+
[InlineData("-1e15")]
103+
[InlineData(" \"I am a string!\" ")]
104+
[InlineData(" {}")]
105+
[InlineData("[]")]
106+
[InlineData("// single-line comment\r\nnull")]
107+
[InlineData("/* multi-line\r\ncomment */\r\nnull")]
108+
public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam)
109+
{
110+
JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { ReadCommentHandling = JsonCommentHandling.Skip };
111+
AIFunction func = AIFunctionFactory.Create((JsonElement param) => param, serializerOptions: options);
112+
JsonElement expectedResult = JsonDocument.Parse(jsonStringParam, new() { CommentHandling = JsonCommentHandling.Skip }).RootElement;
113+
114+
var result = await func.InvokeAsync(new()
115+
{
116+
["param"] = jsonStringParam
117+
});
118+
119+
AssertExtensions.EqualFunctionCallResults(expectedResult, result);
120+
}
121+
122+
[Theory]
123+
[InlineData("")]
124+
[InlineData(" \r\n")]
125+
[InlineData("I am a string!")]
126+
[InlineData("/* Code snippet */ int main(void) { return 0; }")]
127+
[InlineData("let rec Y F x = F (Y F) x")]
128+
[InlineData("+3")]
129+
public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam)
130+
{
131+
AIFunction func = AIFunctionFactory.Create((JsonElement param) => param);
132+
JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement;
133+
134+
var result = await func.InvokeAsync(new()
135+
{
136+
["param"] = invalidJsonParam
137+
});
138+
139+
AssertExtensions.EqualFunctionCallResults(expectedResult, result);
140+
}
141+
78142
[Fact]
79143
public async Task Parameters_MappedByType_Async()
80144
{

0 commit comments

Comments
 (0)