[.NET] Fix durable agent state serialization for non-JSON tool values - #57
Merged
Merged
Conversation
Copilot started reviewing on behalf of
Shyju Krishnankutty (kshyju)
July 29, 2026 18:51
View session
Contributor
There was a problem hiding this comment.
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
JsonElementat persistence boundaries to avoid source-genJsonTypeInfogaps. - Extend function result persistence to support
AIContentandIEnumerable<AIContent>results (while still supporting raw JSON results viaJsonElement). - 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>
Shyju Krishnankutty (kshyju)
force-pushed
the
shkr/fix-mcp-tool-result-33
branch
from
July 29, 2026 19:01
59c5fce to
7608447
Compare
Copilot started reviewing on behalf of
Shyju Krishnankutty (kshyju)
July 29, 2026 19:01
View session
Chris Gillum (cgillum)
left a comment
Member
There was a problem hiding this comment.
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 started reviewing on behalf of
Shyju Krishnankutty (kshyju)
July 29, 2026 21:29
View session
Chris Gillum (cgillum)
approved these changes
Jul 29, 2026
Chris Gillum (cgillum)
left a comment
Member
There was a problem hiding this comment.
New implementation looks good!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
DurableAgentStateJsonContextis source generated and deliberately has no reflection fallback, so everyobject-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.Resultas a plainobject?. Tools built byAIFunctionFactorymarshal their return value into aJsonElement, which is registered in the context — so local tools work.McpClientTool.InvokeCoreAsyncdoes not: it returns aMicrosoft.Extensions.AI.AIContentwhen theCallToolResulthas a single content block, anAIContent[]when it has several, and only falls back toJsonSerializer.SerializeToElementfor 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 withPath: $.Arguments. It goes unnoticed today only because model-supplied arguments happen to arrive asJsonElement. Any caller-suppliedFunctionCallContentholding another value fails — including plain BCL types such asDateOnly— 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
DurableAgentStateContentgains a sharedprotected static JsonElement ToJsonElement(object?)helper backed byAIJsonUtilities.DefaultOptions, plus a cached element for thenullcase. This is the single place loosely typed values get reduced to JSON. The pattern (resolvingJsonTypeInfofromAIJsonUtilities.DefaultOptions) was already used byDurableAgentStateUnknownContent, so AOT compatibility is unchanged.DurableAgentStateFunctionResultContent.Resultis narrowed fromobject?toJsonElement?, and every result shape —JsonElement, arbitrary objects, and theAIContent/AIContent[]that MCP tools return — is encoded throughToJsonElementinto that single existingresultproperty. Values are serialized viatypeof(object)rather than their runtime type, which retains the polymorphic$typediscriminator and keeps the stored JSON self describing. A null result is left absent rather than written as JSONnull, so it round-trips back to null.DurableAgentStateFunctionCallContent.ArgumentsbecomesIReadOnlyDictionary<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
resultproperty, so the DTS dashboard keeps working,schemas/durable-agent-entity-state.jsonstays 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
mainis byte-identical:The only shapes whose output changes are the three MCP ones, which previously threw:
Compatibility
State written by previous versions deserializes unchanged:
resultwas always raw JSON and now lands in aJsonElement?, andargumentswas always a JSON object and now lands in aDictionary<string, JsonElement>.ToAIContent()still hands back boxedJsonElementvalues, 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 (
resultwasobject?, so it lands as aJsonElement) and re-serializes byte-identically.One intentional behavior change: an argument whose value was CLR
nullnow round-trips as aJsonElementofValueKind.Nullrather thannull. This matches what the model path already produces, andNullArgumentRoundTripspins it.Testing
14 new round-trip tests across
DurableAgentStateFunctionCallContentTestsandDurableAgentStateFunctionResultContentTests, covering singleAIContent, multipleAIContent, MCPErrorContent, arbitrary objects,JsonElementpassthrough, 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/65Microsoft.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
AIContentresults in two additional properties,resultContentandresultContents. Per review, those were collapsed into the singleresultproperty 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.
OpenAIChatClientflattens the result to JSON anyway, so its output is byte-identical either way.OpenAIResponsesChatClientdoes specializeAIContentintoinput_text/input_image/input_fileparts, so a multi-modal MCP result is now replayed as JSON instead.Reviving
AIContentfrom the stored JSON was considered and rejected: the polymorphic$typediscriminator is only emitted when the runtime collection type isAIContent[]/IList<AIContent>. A tool returningTextContent[]serializes without it and would silently revive as a bareAIContentwith the payload dropped, which is a worse failure mode than storing JSON.