[Microsoft.Extensions.AI] AIFunctionFactory does not tolerate a JsonElement of ValueKind String as a JSON-stringified argument (gap in #6572)
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 894
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 23
Description
**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 `JsonElement`s
(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
```csharp
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)
```csharp
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(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 . Path: $` thrown from
`ObjectDefaultConverter.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` / `Dictionary`, 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` ⇒ 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))))`.
Target type `Dictionary` ⇒ 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.
Contributor guide
Research direction
Start in src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs at GetParameterMarshaller around line 889, and compare the JsonElement arm with the existing raw-string IsPotentiallyJson/MarshallViaJsonRoundtrip path. Use the reported repro to verify that a JsonElement with ValueKind.String containing a valid JSON argument succeeds like a raw string, while ordinary string parameters and object-valued JsonElements retain their existing behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, json
- Domain
- ai, backend-api-design
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100