Package: Microsoft.Extensions.AI.Abstractions 10.4.1 (code unchanged on main at tag v10.4.1)
Summary
#6572 ("AIFunctionFactory: tolerate JSON string function parameters", fixing #6544) made
AIFunctionFactory's parameter marshaller tolerant of a parameter whose value is a JSON string
wrapping the real argument (e.g. "{\"a\":1}" for an object parameter). That tolerance
(IsPotentiallyJson + MarshallViaJsonRoundtrip) is only reached when the argument value is a
raw .NET string.
When the same JSON-stringified object arrives as a JsonElement whose ValueKind == String,
the marshaller takes the earlier JsonElement arm and calls
JsonSerializer.Deserialize(element, typeInfo) directly, which throws before the tolerant path runs.
This is the exact shape produced when a model double-encodes a tool-call argument (emits
"input":"{...}" instead of "input":{...}) and the arguments JSON is parsed into JsonElements
(as FunctionInvokingChatClient-backed pipelines do). The failure is intermittent from the model's
side — the model usually emits the correct nested object and only occasionally stringifies it — but
whenever that shape occurs, the deserialization failure below is deterministic. A raw string with the
identical content is handled fine; a JsonElement string is not.
https://github.com/dotnet/extensions/blob/v10.4.1/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs#L889
JsonElement element => JsonSerializer.Deserialize(element, typeInfo), // throws for ValueKind == String
JsonDocument doc => JsonSerializer.Deserialize(doc, typeInfo),
JsonNode node => JsonSerializer.Deserialize(node, typeInfo),
_ => MarshallViaJsonRoundtrip(value), // <- IsPotentiallyJson tolerance lives here
Repro (net9.0, Microsoft.Extensions.AI.Abstractions 10.4.1)
using System.Text.Json;
using Microsoft.Extensions.AI;
public sealed class Input { public string A { get; init; } = ""; public string B { get; init; } = ""; }
var func = AIFunctionFactory.Create((Input input) => "ok", new AIFunctionFactoryOptions { Name = "f" });
string inner = "{\"a\":\"x\",\"b\":\"y\"}";
// A: JsonElement of ValueKind String -> THROWS
await Invoke(JsonSerializer.SerializeToElement(inner));
// B: raw .NET string (same content) -> OK (tolerated by #6572)
await Invoke(inner);
// C: JsonElement of ValueKind Object -> OK
await Invoke(JsonSerializer.Deserialize<JsonElement>(inner));
async Task Invoke(object value)
{
try { Console.WriteLine("OK: " + await func.InvokeAsync(new AIFunctionArguments { ["input"] = value })); }
catch (Exception ex) { Console.WriteLine(ex.GetType().Name + ": " + ex.Message); }
}
Output:
JsonException: The JSON value could not be converted to Input. Path: $ | LineNumber: 0 | BytePositionInLine: 17.
OK: ok
OK: ok
Expected
A JsonElement of ValueKind == String whose content decodes to a valid argument should be
tolerated the same way a raw string is (parity with #6572), rather than throwing JsonException.
Actual
System.Text.Json.JsonException: The JSON value could not be converted to <T>. Path: $ thrown from
ObjectDefaultConverter<T>.OnTryRead via the GetParameterMarshaller delegate. In a
FunctionInvokingChatClient pipeline this surfaces as a failed tool invocation.
Why this affects real first-party clients (not a synthetic-only case)
The failing shape — an argument stored as a JsonElement of ValueKind == String — is exactly what
first-party IChatClient bridges produce, because they parse tool-call arguments into an
IDictionary<string, object?> / Dictionary<string, object?>, and System.Text.Json boxes every
value in such a dictionary as a JsonElement. So when a model double-encodes a tool argument, that
value arrives as a JsonElement{ValueKind: String} and hits the throwing arm above — the #6572
tolerance (raw string only) never runs.
Two public first-party bridges do exactly this today:
-
Microsoft.Extensions.AI.OpenAI — OpenAIResponsesChatClient maps a FunctionCallResponseItem
via OpenAIClientExtensions.ParseCallContent(...), which calls
FunctionCallContent.CreateFromParsedArguments(json, ..., json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)).
Target type IDictionary<string, object> ⇒ values are JsonElement.
https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
(the case FunctionCallResponseItem: arm)
-
Official Anthropic .NET SDK — AnthropicClientExtensions maps a ToolUseBlock via
FunctionCallContent.CreateFromParsedArguments(element.GetRawText(), ..., json => JsonSerializer.Deserialize(json, ...GetTypeInfo(typeof(Dictionary<string, object?>)))).
Target type Dictionary<string, object?> ⇒ values are JsonElement.
https://github.com/anthropics/anthropic-sdk-csharp/blob/main/src/Anthropic/AnthropicClientExtensions.cs
(the case ToolUseBlock toolUse: arm in ContentBlockValueToAIContent)
In both, a double-encoded argument reaches AIFunctionFactory's marshaller as a
JsonElement{ValueKind: String} and throws. Because both first-party bridges share this shape, a fix
in GetParameterMarshaller repairs every provider at once. Note the divergence with Semantic Kernel
(the original #6572 reporter): SK's KernelArguments carry raw .NET string values — the one shape
#6572 already tolerates — so the same logical defect was fixed for SK but not for the
JsonElement-string shape the OpenAI/Anthropic bridges produce.
Suggested fix
In the JsonElement arm of GetParameterMarshaller, when element.ValueKind == JsonValueKind.String
and the target type is not string, attempt to re-parse element.GetString() as JSON before
deserializing — mirroring the existing IsPotentiallyJson / MarshallViaJsonRoundtrip handling used
for raw strings.
Package:
Microsoft.Extensions.AI.Abstractions10.4.1 (code unchanged onmainat tagv10.4.1)Summary
#6572 ("AIFunctionFactory: tolerate JSON string function parameters", fixing #6544) made
AIFunctionFactory's parameter marshaller tolerant of a parameter whose value is a JSON stringwrapping the real argument (e.g.
"{\"a\":1}"for an object parameter). That tolerance(
IsPotentiallyJson+MarshallViaJsonRoundtrip) is only reached when the argument value is araw .NET
string.When the same JSON-stringified object arrives as a
JsonElementwhoseValueKind == String,the marshaller takes the earlier
JsonElementarm and callsJsonSerializer.Deserialize(element, typeInfo)directly, which throws before the tolerant path runs.This is the exact shape produced when a model double-encodes a tool-call argument (emits
"input":"{...}"instead of"input":{...}) and the arguments JSON is parsed intoJsonElements(as
FunctionInvokingChatClient-backed pipelines do). The failure is intermittent from the model'sside — the model usually emits the correct nested object and only occasionally stringifies it — but
whenever that shape occurs, the deserialization failure below is deterministic. A raw string with the
identical content is handled fine; a
JsonElementstring is not.https://github.com/dotnet/extensions/blob/v10.4.1/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs#L889
Repro (net9.0,
Microsoft.Extensions.AI.Abstractions10.4.1)Output:
Expected
A
JsonElementofValueKind == Stringwhose content decodes to a valid argument should betolerated the same way a raw
stringis (parity with #6572), rather than throwingJsonException.Actual
System.Text.Json.JsonException: The JSON value could not be converted to <T>. Path: $thrown fromObjectDefaultConverter<T>.OnTryReadvia theGetParameterMarshallerdelegate. In aFunctionInvokingChatClientpipeline this surfaces as a failed tool invocation.Why this affects real first-party clients (not a synthetic-only case)
The failing shape — an argument stored as a
JsonElementofValueKind == String— is exactly whatfirst-party
IChatClientbridges produce, because they parse tool-call arguments into anIDictionary<string, object?>/Dictionary<string, object?>, andSystem.Text.Jsonboxes everyvalue in such a dictionary as a
JsonElement. So when a model double-encodes a tool argument, thatvalue arrives as a
JsonElement{ValueKind: String}and hits the throwing arm above — the #6572tolerance (raw
stringonly) never runs.Two public first-party bridges do exactly this today:
Microsoft.Extensions.AI.OpenAI —
OpenAIResponsesChatClientmaps aFunctionCallResponseItemvia
OpenAIClientExtensions.ParseCallContent(...), which callsFunctionCallContent.CreateFromParsedArguments(json, ..., json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)).Target type
IDictionary<string, object>⇒ values areJsonElement.https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
(the
case FunctionCallResponseItem:arm)Official Anthropic .NET SDK —
AnthropicClientExtensionsmaps aToolUseBlockviaFunctionCallContent.CreateFromParsedArguments(element.GetRawText(), ..., json => JsonSerializer.Deserialize(json, ...GetTypeInfo(typeof(Dictionary<string, object?>)))).Target type
Dictionary<string, object?>⇒ values areJsonElement.https://github.com/anthropics/anthropic-sdk-csharp/blob/main/src/Anthropic/AnthropicClientExtensions.cs
(the
case ToolUseBlock toolUse:arm inContentBlockValueToAIContent)In both, a double-encoded argument reaches
AIFunctionFactory's marshaller as aJsonElement{ValueKind: String}and throws. Because both first-party bridges share this shape, a fixin
GetParameterMarshallerrepairs every provider at once. Note the divergence with Semantic Kernel(the original #6572 reporter): SK's
KernelArgumentscarry raw .NETstringvalues — the one shape#6572 already tolerates — so the same logical defect was fixed for SK but not for the
JsonElement-string shape the OpenAI/Anthropic bridges produce.Suggested fix
In the
JsonElementarm ofGetParameterMarshaller, whenelement.ValueKind == JsonValueKind.Stringand the target type is not
string, attempt to re-parseelement.GetString()as JSON beforedeserializing — mirroring the existing
IsPotentiallyJson/MarshallViaJsonRoundtriphandling usedfor raw strings.