Skip to content

[.NET] Fix durable agent state serialization for non-JSON tool values - #57

Merged
Shyju Krishnankutty (kshyju) merged 2 commits into
mainfrom
shkr/fix-mcp-tool-result-33
Jul 30, 2026
Merged

[.NET] Fix durable agent state serialization for non-JSON tool values#57
Shyju Krishnankutty (kshyju) merged 2 commits into
mainfrom
shkr/fix-mcp-tool-result-33

Conversation

@kshyju

@kshyju Shyju Krishnankutty (kshyju) commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #33.

Problem

A durable agent configured with MCP tools fails the moment a tool is invoked. The LLM call succeeds, the tool runs, and then persisting the entity state throws:

System.NotSupportedException: JsonTypeInfo metadata for type 'Microsoft.Extensions.AI.TextContent'
was not provided by TypeInfoResolver of type 'DurableAgentStateJsonContext'.
... Path: $.Result

Because the failure happens inside the entity operation, the operation is retried forever — the caller's HTTP request never returns, and the agent is effectively wedged.

Root cause

DurableAgentStateJsonContext is source generated and deliberately has no reflection fallback, so every object-typed member of the state model must be reduced to JSON before the state is handed to the serializer. Two members were not.

1. DurableAgentStateFunctionResultContent.Result (the reported bug)

It stored FunctionResultContent.Result as a plain object?. Tools built by AIFunctionFactory marshal their return value into a JsonElement, which is registered in the context — so local tools work. McpClientTool.InvokeCoreAsync does not: it returns a Microsoft.Extensions.AI.AIContent when the CallToolResult has a single content block, an AIContent[] when it has several, and only falls back to JsonSerializer.SerializeToElement for error/structured/meta cases. Those first two shapes hit the missing-metadata path.

2. DurableAgentStateFunctionCallContent.Arguments (same hole, latent)

IReadOnlyDictionary<string, object?> has exactly the same problem and fails identically with Path: $.Arguments. It goes unnoticed today only because model-supplied arguments happen to arrive as JsonElement. Any caller-supplied FunctionCallContent holding another value fails — including plain BCL types such as DateOnly — which is reachable through history replay, human-in-the-loop/approval resumption, and middleware that strong-types arguments before they reach the entity. Fixing it alongside avoids shipping a fix for one half of the same bug.

Fix

  • DurableAgentStateContent gains a shared protected static JsonElement ToJsonElement(object?) helper backed by AIJsonUtilities.DefaultOptions, plus a cached element for the null case. This is the single place loosely typed values get reduced to JSON. The pattern (resolving JsonTypeInfo from AIJsonUtilities.DefaultOptions) was already used by DurableAgentStateUnknownContent, so AOT compatibility is unchanged.
  • DurableAgentStateFunctionResultContent.Result is narrowed from object? to JsonElement?, and every result shape — JsonElement, arbitrary objects, and the AIContent / AIContent[] that MCP tools return — is encoded through ToJsonElement into that single existing result property. Values are serialized via typeof(object) rather than their runtime type, which retains the polymorphic $type discriminator and keeps the stored JSON self describing. A null result is left absent rather than written as JSON null, so it round-trips back to null.
  • DurableAgentStateFunctionCallContent.Arguments becomes IReadOnlyDictionary<string, JsonElement>, with values encoded on the way in.

Serialization format

No property is added or removed. Every function result is persisted under the existing result property, so the DTS dashboard keeps working, schemas/durable-agent-entity-state.json stays accurate as written, and the shape stays aligned with the Python implementation.

Comparing the stored JSON before and after this change, every shape that already serialized on main is byte-identical:

JsonElement result     {"$type":"functionResult","callId":"r1","result":{"city":"Seattle","tempF":72}}
string result          {"$type":"functionResult","callId":"r2","result":"just text"}
null result            {"$type":"functionResult","callId":"r3"}
args (JsonElement)     {"$type":"functionCall","arguments":{"city":"Seattle","days":3},...}
args (raw string/int)  {"$type":"functionCall","arguments":{"city":"Seattle","days":3},...}
args (null value)      {"$type":"functionCall","arguments":{"x":null},...}

The only shapes whose output changes are the three MCP ones, which previously threw:

MCP single AIContent   {"$type":"functionResult","callId":"m1","result":{"$type":"text","text":"hi from mcp"}}
MCP multi AIContent    {"$type":"functionResult","callId":"m2","result":[{"$type":"text",...},{"$type":"uri",...}]}
MCP ErrorContent       {"$type":"functionResult","callId":"m3","result":{"$type":"error","message":"boom",...}}

Compatibility

State written by previous versions deserializes unchanged: result was always raw JSON and now lands in a JsonElement?, and arguments was always a JSON object and now lands in a Dictionary<string, JsonElement>. ToAIContent() still hands back boxed JsonElement values, which is exactly what the model-supplied path produces today, so consumers see no difference.

Rollback is also safe: feeding the new MCP payloads above to the previous version's code deserializes cleanly (result was object?, so it lands as a JsonElement) and re-serializes byte-identically.

One intentional behavior change: an argument whose value was CLR null now round-trips as a JsonElement of ValueKind.Null rather than null. This matches what the model path already produces, and NullArgumentRoundTrips pins it.

Testing

14 new round-trip tests across DurableAgentStateFunctionCallContentTests and DurableAgentStateFunctionResultContentTests, covering single AIContent, multiple AIContent, MCP ErrorContent, arbitrary objects, JsonElement passthrough, BCL values, null, and empty results.

Reverting the three source files makes exactly 7 of them fail with the error from the issue, so they genuinely pin the bug rather than the implementation.

Full suite: 207/207 Microsoft.Agents.AI.DurableTask.UnitTests, 65/65 Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests, clean solution build with no new warnings.

This was also verified end-to-end using a throwaway Azure Functions sample with a real MCP tool server against live Azure OpenAI: without the fix the host logged the issue's exception verbatim and the HTTP request hung indefinitely; with it the agent answered normally. The scratch sample is not part of this PR. That run predates the format change described below; the change was re-verified at unit level by confirming the stored JSON and the resulting model-facing payloads are unchanged.

Review feedback

The first revision of this PR stored AIContent results in two additional properties, resultContent and resultContents. Per review, those were collapsed into the single result property so the DTS dashboard, the entity state schema, and the Python serialization format all stay accurate without changes.

The fidelity that structured storage would have preserved is narrow, and giving it up is not a regression — that path threw before this fix. OpenAIChatClient flattens the result to JSON anyway, so its output is byte-identical either way. OpenAIResponsesChatClient does specialize AIContent into input_text / input_image / input_file parts, so a multi-modal MCP result is now replayed as JSON instead.

Reviving AIContent from the stored JSON was considered and rejected: the polymorphic $type discriminator is only emitted when the runtime collection type is AIContent[] / IList<AIContent>. A tool returning TextContent[] serializes without it and would silently revive as a bare AIContent with the payload dropped, which is a worse failure mode than storing JSON.

Copilot AI review requested due to automatic review settings July 29, 2026 18:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a durable entity state persistence failure when tool call arguments/results contain non-JSON CLR types (notably AIContent shapes returned by MCP tools), by normalizing loosely-typed state fields to JSON and preserving structured AIContent results across checkpoints.

Changes:

  • Normalize function call arguments to JsonElement at persistence boundaries to avoid source-gen JsonTypeInfo gaps.
  • Extend function result persistence to support AIContent and IEnumerable<AIContent> results (while still supporting raw JSON results via JsonElement).
  • Add regression/compat round-trip unit tests and a changelog entry documenting the fix.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateContent.cs Adds a shared helper to reduce object? values to JsonElement before state serialization.
dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionCallContent.cs Stores function call arguments as IReadOnlyDictionary<string, JsonElement> and rehydrates them back to FunctionCallContent.
dotnet/src/Microsoft.Agents.AI.DurableTask/State/DurableAgentStateFunctionResultContent.cs Persists tool results in one of three shapes (JsonElement, single AIContent, multiple AIContent) to match MCP/local tool behavior.
dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionCallContentTests.cs Adds argument round-trip/legacy compatibility regression tests for non-JSON values (including null and BCL types).
dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/State/DurableAgentStateFunctionResultContentTests.cs Adds result round-trip/legacy compatibility regression tests for AIContent, collections, and arbitrary objects.
dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md Adds an Unreleased entry describing the fixed serialization failure for MCP/non-JSON tool values.

DurableAgentStateJsonContext is source generated and has no reflection fallback, so
every object typed member of the state model has to be reduced to JSON before the
state is written. Two members were not:

- FunctionResultContent.Result was stored as a plain object. MCP tools return
  Microsoft.Extensions.AI.AIContent, or an AIContent[] when the tool result carries
  multiple content blocks, rather than the JsonElement that AIFunctionFactory created
  tools produce, so persisting the entity state after the tool call threw
  NotSupportedException and the entity operation retried indefinitely.
- FunctionCallContent.Arguments had the same hole. It survives today only because
  model supplied arguments happen to arrive as JsonElement; a caller supplied function
  call holding any other value, including a BCL type such as DateOnly, fails the same
  way.

AIContent results are now stored through the existing DurableAgentStateContent
polymorphic model (new resultContent and resultContents properties) and every other
loosely typed value is encoded with a shared DurableAgentStateContent.ToJsonElement
helper backed by AIJsonUtilities.DefaultOptions. Previously persisted state that
stores raw JSON under "result" and "arguments" continues to deserialize unchanged.

Fixes #33

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 29, 2026 19:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@cgillum Chris Gillum (cgillum) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple thoughts on the schema change and the potential compatibility issues that may arise.

Persist every function result shape under the existing `result` property
instead of adding `resultContent` and `resultContents`, so the DTS
dashboard, `schemas/durable-agent-entity-state.json`, and the Python
serialization format all stay accurate without changes.

Loosely typed values are now serialized through `object` rather than
their runtime type, which retains the polymorphic `$type` discriminator
and keeps the persisted JSON self describing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 29, 2026 21:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@cgillum Chris Gillum (cgillum) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New implementation looks good!

@kshyju
Shyju Krishnankutty (kshyju) merged commit fd469df into main Jul 30, 2026
9 checks passed
@kshyju
Shyju Krishnankutty (kshyju) deleted the shkr/fix-mcp-tool-result-33 branch July 30, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Bug]: Durable Agent fails to serialize state when using MCP tools

3 participants