Skip to content

[Microsoft.Extensions.AI] AIFunctionFactory does not tolerate a JsonElement of ValueKind String as a JSON-stringified argument (gap in #6572) #7626

Description

@flaviocdc

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI librariesenhancementThis issue represents an ask for new feature or an enhancement to an existing one

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions