Skip to content

.NET: [Bug]: Checkpoint round-trip loses ToolApprovalRequestContent.ToolCall concrete type (FunctionCallContent → base ToolCallContent), breaking FICC approval resume #5350

Closed

Description

@aeras3637

Description

Summary

When a ToolApprovalRequestContent is persisted via CheckpointManager.CreateJson(...) (a JsonCheckpointStore-based store) during a HITL workflow and then restored on resume, ToolApprovalRequestContent.ToolCall appears to lose its concrete FunctionCallContent type and come back as something that no longer satisfies is FunctionCallContent in FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses.

Because that method matches with

case ToolApprovalRequestContent tarc
    when tarc.ToolCall is FunctionCallContent { InformationalOnly: false }:

the TARC is silently skipped, the call id is never added to approvalRequestCallIds, and the downstream validation / invocation logic does not handle the approval as expected.

Environment

  • Microsoft.Agents.AI / Microsoft.Agents.AI.Workflows 1.1 (.NET 10)
  • Microsoft.Extensions.AI 10.5
  • Storage: custom JsonCheckpointStore subclass persisting the JSON to SQL Server (Dapper). The CheckpointManager is created via CheckpointManager.CreateJson(store); no custom JsonSerializerOptions passed.
  • Flow: workflow.AsAIAgent() is not used in the affected path; we drive the run directly with InProcessExecution.RunStreamingAsync(workflow, input, CheckpointManager, sessionId, ct) and resume with InProcessExecution.ResumeStreamingAsync(...).
  • HITL mechanism: ApprovalRequiredAIFunction-wrapped tools inside a ChatClientAgent node that is bound into the workflow (pattern "B" in the published GroupChatToolApproval sample).

Expected behavior

After a checkpoint → resume cycle, ToolApprovalRequestContent.ToolCall restored through JsonCheckpointStore should remain an instance of the original concrete ToolCallContent subclass (FunctionCallContent), so that FunctionInvokingChatClient’s pattern matching continues to work unchanged.

Actual behavior

After resume, the ToolCall instance returned by request.TryGetDataAs<ToolApprovalRequestContent>() no longer matches is FunctionCallContent. As a result the approval pair is not extracted by FICC and the run either:

  • raises InvalidOperationException: ToolApprovalRequestContent found with FunctionCall.CallId(s) '…' that have no matching ToolApprovalResponseContent. (if only the TARC side degrades), or
  • passes the approval through unprocessed and causes OpenAI Responses to reject with No tool output found for function call call_… in the subsequent turn.

Reproduction outline

  1. Build a workflow where a ChatClientAgent node is bound into WorkflowBuilder and uses an ApprovalRequiredAIFunction tool.
  2. Run the workflow with durable checkpointing:
    var checkpointManager = CheckpointManager.CreateJson(myJsonCheckpointStore);
    await foreach (var evt in InProcessExecution.RunStreamingAsync(
        workflow, input, checkpointManager, sessionId, ct).WatchStreamAsync(ct))
    { ... }
  3. On RequestInfoEvent, persist the envelope, terminate the run.
  4. Later, resume:
    var run = await InProcessExecution.ResumeStreamingAsync(
        workflow, checkpointManager, sessionId, ct);
    var request = /* the resumed RequestInfoEvent.Request */;
    var tarc = request.TryGetDataAs<ToolApprovalRequestContent>();
    Console.WriteLine(tarc!.ToolCall?.GetType().FullName);
    // Observed: NOT FunctionCallContent
  5. Build a ToolApprovalResponseContent with the restored tarc.ToolCall verbatim and send it back via run.SendResponseAsync(request.CreateResponse(taResp)).
  6. FICC’s ExtractAndRemoveApprovalRequestsAndResponses fails to pick up the pair.

(I can produce a minimal standalone repro against a fake IChatClient if useful — let me know.)

Likely root cause (hypothesis)

ToolApprovalRequestContent.ToolCall is declared as the abstract base ToolCallContent. System.Text.Json requires polymorphic metadata ([JsonPolymorphic] + [JsonDerivedType] or equivalent TypeInfoResolver) to round-trip an abstract property through its concrete subtype. Unless JsonCheckpointStore is configured with AIJsonUtilities.DefaultOptions (or an equivalent resolver that knows about FunctionCallContent), ToolCall serializes as the base shape and deserializes back as the base type.

Two things to confirm in the repo:

  1. Whether AIContent / ToolCallContent / FunctionCallContent currently carry [JsonPolymorphic] + [JsonDerivedType(typeof(FunctionCallContent), "...")] — or are only known to the resolver bundled in AIJsonUtilities.DefaultOptions.
  2. What JsonSerializerOptions the default CheckpointManager.CreateJson(...) uses. If it does not pull from AIJsonUtilities.DefaultOptions by default, the polymorphic information for AIContent-derived members is effectively lost for any custom store.

Workaround currently in use

In our resume handler we reconstruct the concrete type before building the response:

var toolCall = tarc.ToolCall is FunctionCallContent fcc
    ? fcc
    : new FunctionCallContent(tarc.ToolCall!.CallId, /*name*/..., /*args*/...);
var response = new ToolApprovalResponseContent(tarc.RequestId, approved, toolCall);

This is fragile (we must know the original Name / Arguments to rebuild the FCC) and only masks the serializer-level problem.

Asks

  1. Is this a known limitation of the JSON checkpoint path for AIContent-valued properties whose declared type is abstract? If so, documenting it would help.
  2. Can CheckpointManager.CreateJson(...) default to (or transparently compose with) AIJsonUtilities.DefaultOptions so that AIContent polymorphism is preserved out of the box for JsonCheckpointStore implementations?
  3. Alternatively, can ToolCallContent / AIContent be annotated with [JsonPolymorphic] + [JsonDerivedType(...)] so the polymorphism travels with the type itself regardless of the resolver?
  4. If the official guidance is "you must pass your own JsonSerializerOptions", please document that explicitly next to CheckpointManager.CreateJson and the HITL samples. The current GroupChatToolApproval sample works because it does not checkpoint across process boundaries; the failure only appears under durable resume.

Code Sample

Error Messages / Stack Traces

Package Versions

Microsoft.Agents.AI 1.1.0, Microsoft.Agents.AI.Workflows 1.1.0

.NET Version

.NET 10

Additional Context

No response

Activity

  1. added
    bugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)
    .NETUsage: [Issues, PRs], Target: .Net
    on Apr 19, 2026
  2. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Apr 27, 2026
  3. lokitoth commented on May 1, 2026

    @lokitoth
    Contributor

    Most likely this is due to a lack of appropriate [JsonDerivedType] annotation somewhere. Thanks for filing the issue, awata (@aeras3637).

  4. lokitoth commented on May 5, 2026

    @lokitoth
    Contributor

    Looking at the MEAI types, it does have [JsonDerivedType(typeof(FunctionCallContent), "functionCall")] set on it, and has had it that way for some time, so it is unlikely to be the root cause.

    Expanding a little on how the checkpoint serialization works with JSON backends, we rely on JsonMarshaller (here) to manage the translation. It takes an optional JsonSerializationOptions provided by the user (you can inject this when calling CheckpointManager.CreateJson(ICheckpointStore<JsonElement> store, JsonSerializerOptions? customOptions = null)), but this is used only if the internal one (via WorkflowsJsonUtilities, which chains to AgentAbstractionsJsonUtilities, which then chains to the AIJsonUtilities class).

    So the fact that it still loses the type information is strange.

  5. added 2 commits that reference this issue on May 19, 2026
  6. lokitoth commented on May 19, 2026

    @lokitoth
    Contributor

    Hey awata (@aeras3637) - I spent some time trying to reproduce the issue but have not been able to. Here is a set of unit tests that I believe capture the issue as you described, with successively more machinery being exercised.

    Could you check if you are still running into the issue described? If you are, let's see if we can figure out where I did not quite do the same thing that you are.

  7. aeras3637 commented on May 20, 2026

    @aeras3637
    Author

    Thank you for looking into this.

    I also created a minimal repro and tested the checkpoint / resume flow through CheckpointManager.CreateJson(...) and JsonCheckpointStore, without our application-specific implementation involved.

    As a result, I confirmed that in the standard workflow checkpoint / resume path, ToolApprovalRequestContent.ToolCall is still restored as FunctionCallContent after resume.
    I also tested this with the current package versions and with versions close to the ones we were using at the time, and I could not reproduce the concrete type being lost on the framework side.

    After further investigation, the issue turned out to be in our application-side HITL resume response generation.

    In our implementation, we rebuild FunctionCallContent before sending the approval response, because we need to add application-specific arguments such as requestId, responseAction, and payload.
    That code did not validate strongly enough that the request-side ToolCall was actually a FunctionCallContent. If it was ever handled as an unexpected ToolCallContent, it could produce a response that lost the original function name and arguments.

    So the problem was most likely not that the framework lost the concrete type during checkpoint restore. Instead, we appear to have misdiagnosed an issue in our own resume response generation as a framework checkpoint serialization issue.

    On our side, I have made the following changes:

    • Added a minimal repro confirming that FunctionCallContent is preserved across framework checkpoint / resume
    • Changed our application code to fail fast if the approval request ToolCall is not a FunctionCallContent
    • Added a regression test that resumes from an actual checkpoint and verifies that the tool is invoked successfully through our response generation path

    Based on your investigation and the repro results above, I agree that this does not appear to be a framework bug.

    Sorry for opening this issue before isolating the application-specific part of the behavior. Thank you again for spending time trying to reproduce it.

    Please feel free to close this issue as an application-side implementation problem.

  8. lokitoth commented on May 21, 2026

    @lokitoth
    Contributor

    Sorry for opening this issue before isolating the application-specific part of the behavior. Thank you again for spending time trying to reproduce it.

    No worries; investigation discovered an unrelated issue that we'll be fixing :)

    Going to close this out now, but please reopen if this issue reappears.

  9. moved this from In Progress to Done in Agent Frameworkon May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

.NETUsage: [Issues, PRs], Target: .NetbugUsage: [Issues], Target: all issues (Legacy, prefer issue type: bug)

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions