Skip to content

.NET: [Bug]: MAF approval-required function call is persisted as a dangling FunctionCallContent and pending approval state has no public read API #7862

Closed

Description

@soul-soft

[Bug] MAF approval-required function call is persisted as a dangling FunctionCallContent and pending approval state has no public read API

Reconstructed draft based on the original issue context. This is not guaranteed to be a byte-for-byte copy of the previous text.

Problem

When an approval-required function tool is called, MAF/FICC can persist the public conversation history with a raw FunctionCallContent, while the corresponding pending approval state is stored separately in session metadata.

This creates a practical restore problem for durable host applications:

  • The visible message history says the assistant emitted a FunctionCallContent.
  • The session state says there is a pending approval request for that tool call.
  • The public message history does not contain a host-facing ToolApprovalRequestContent.
  • There is no public API to enumerate the pending approval requests from the restored session.

As a result, the host cannot reliably restore the approval UI from public state alone.

Reproduction

  1. Configure an approval-required function tool, for example get_weather.
  2. Ask the agent a question that causes the model to call that tool.
  3. The run stops and waits for user approval.
  4. Before the user approves or rejects the tool call, close or refresh the conversation.
  5. Later, restore the same historical conversation/session.

At this point, the host must restore the approval UI and should not allow normal user input before the approval is resolved.

Persisted history example

The stored user message is ordinary text:

{
  "role": "user",
  "contents": [
    {
      "$type": "text",
      "text": "北京的天气"
    }
  ]
}

The stored assistant message contains reasoning/text and a raw FunctionCallContent:

{
  "role": "assistant",
  "contents": [
    {
      "$type": "reasoning",
      "text": "The user asks about the weather in Beijing. I should call the get_weather tool. It requires user approval before execution, but let me try."
    },
    {
      "$type": "text",
      "text": ""
    },
    {
      "$type": "functionCall",
      "name": "get_weather",
      "arguments": {
        "location": "北京",
        "unit": "celsius"
      },
      "informationalOnly": false,
      "callId": "call_00_vigxDpknXBrXBTnynu6P8614"
    }
  ]
}

The problem is that durable message history does not contain a ToolApprovalRequestContent that the host can render directly.

Why this is a bug

For a host application with durable sessions, pending approval is not an internal implementation detail. It is user-visible conversation state.

The user is waiting on a decision: approve or reject the tool call. If the host cannot restore that state, the conversation is left in an invalid state:

  • If the host allows a new normal user message, the previous function call remains unresolved.
  • If the host wants to restore the approval UI, it has no public way to enumerate the pending request.
  • If the host reads private serialized state, the implementation becomes fragile.

This means the framework exposes a durable conversation state that cannot be interpreted reliably through public contracts.

Current workaround

The workaround is to inspect private session state and reconstruct the approval UI manually.

In practice, the relevant pending approval state may appear in internal state such as:

  • stateBag._pendingApprovalRequests
  • stateBag._sessionThreadState.queuedApprovalRequests

This works as a defensive workaround, but it is not a stable contract. Field names and serialized shapes are private implementation details.

Expected behavior

MAF should provide one of the following:

  • Persist approval-required tool calls as stable ToolApprovalRequestContent in chat history.
  • Expose a public API to enumerate pending approvals from a restored session.
  • Document a stable contract that maps persisted FunctionCallContent to the pending approval request id.

With that, the host can restore the approval UI, block normal user input while pending approvals exist, and submit an approval/rejection response safely.

Desired restored flow

After restoring a historical session:

  1. The host asks MAF whether the session has pending approvals.
  2. If pending approvals exist, the host renders approval UI instead of accepting normal user input.
  3. The user approves or rejects.
  4. The host sends the corresponding ToolApprovalResponseContent.
  5. MAF/FICC resumes the original tool lifecycle, executes or rejects the function, and produces the corresponding result.

Scope

This issue is specifically about durable restore of pending approval state.

It is not about changing approval policy, and it is not asking for automatic approval.

Activity

  1. atty57 commented on Aug 25, 2026

    @atty57
    Contributor

    I'd like to take this on, starting with option 2 (a public API to enumerate pending approvals from a restored session).

    The state is already there — ApprovalResponseBindingChatClient records every model-originated ToolApprovalRequestContent into the session state bag under the internal _pendingApprovalRequests key, and consumes the entry once a matching ToolApprovalResponseContent is bound. So a host reading that key is reading correct state; it just isn't a supported contract, which is exactly the fragility described here.

    My proposal is a small additive extension method that reads the same recorded state:

    public static bool TryGetPendingToolApprovalRequests(
        this AgentSession session,
        [NotNullWhen(true)] out IReadOnlyList<ToolApprovalRequestContent>? requests);

    This lets a host, after DeserializeSessionAsync, detect that the conversation is paused on an approval, restore its approval UI, and submit by request id — removing the need for a host-side mirror like _sessionThreadState.queuedApprovalRequests.

    Deliberately not in scope, since they look like maintainer design calls rather than something to land unsolicited:

    • Option 1 (persisting ToolApprovalRequestContent into durable chat history instead of a bare FunctionCallContent) changes what goes into message history and affects replay for every provider — happy to follow up on it separately if you'd like it, but it seems worth agreeing on the direction first.
    • Any guard that blocks replaying an unresolved assistant function call followed by a new user message. Option 2 gives the host enough information to enforce that itself, which is what you're already doing.

    Draft PR incoming. If you'd rather see a different shape for the API (or a different option entirely), I'm happy to rework it.

  2. soul-soft commented on Aug 26, 2026

    @soul-soft
    Author

    I agree with this direction. This feels like a conservative fix because it does not force one recovery policy on every
    host. Instead, it gives developers a supported way to decide how to handle pending approvals after restore.

    The important part is that it opens a unified semantic: pending approval is durable, user-visible conversation state,
    and hosts should be able to discover and resolve it through public APIs instead of parsing private session metadata.

  3. soul-soft commented on Aug 26, 2026

    @soul-soft
    Author

    One more API design consideration: server-side approval endpoints usually receive only a requestId from the client, then need to create the matching approval response.

    Because of that, I wonder whether pending approvals should have a small session-level management surface instead of only a one-off enumeration helper. For example, the session could expose an approval-oriented API that supports:

    • enumerating pending approval requests
    • looking up a pending approval request by RequestId
    • removing or marking a pending approval as resolved

    The exact shape does not need to be this, but conceptually something like:

    session.ToolApprovals.TryGet(requestId, out ToolApprovalRequestContent? request)

    This would make the API more sustainable if approval lifecycle management grows beyond restore-only scenarios. It would also let a server reconstruct the correct ToolApprovalResponseContent without parsing private session metadata or maintaining a separate host-side mirror of pending requests.

  4. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 27, 2026
  5. westey-m commented on Aug 28, 2026

    @westey-m
    Collaborator

    1448376744 (@soul-soft), have you considered just storing the last output from the agent when closing the application, so that it can be restored when re-loading the application, and using that restore the UI?

    The last response from the agent would contain the ToolApprovalRequestContent items required to render the approval UI again.

    Also, are you using per-service-call persistence? I'm assuming you are storing chat history in the session?

  6. soul-soft commented on Aug 28, 2026

    @soul-soft
    Author

    1448376744 (@soul-soft), have you considered just storing the last output from the agent when closing the application, so that it can be restored when re-loading the application, and using that restore the UI?

    The last response from the agent would contain the ToolApprovalRequestContent items required to render the approval UI again.

    Also, are you using per-service-call persistence? I'm assuming you are storing chat history in the session?

    Yes, I am using per-service-call persistence. The reason is conversation continuity: my app supports interruption, so
    I need the session/chat state to remain consistent across interrupted and resumed flows.

    And yes, storing the last agent output does sound like a good direction for another related problem I have: when an
    interruption happens, how should the host surface unresolved regular function calls that are not approval requests?

    The question I want to clarify here is: what is the recommended way to store the last output from the agent? Is there
    a public API or pattern for capturing that output in a form that can later be restored and used to rebuild the UI?

  7. westey-m commented on Aug 31, 2026

    @westey-m
    Collaborator

    1448376744 (@soul-soft), thanks for the additional information. The reason I asked about per-service-call persistence is because this means that chat history is persisted below FunctionInvokingChatClient. If per-service-call persistence is off, approval requests and responses are actually persisted in chat history, since then chat history is persisted above FunctionInvokingChatClient.

    Regardless though, separately maintaining a UX friendly chat history is not complex. It is just a List<ChatMessage>, which would need both the user input and agent output added to it. This can be serialized directly using a JsonSerializer. All ChatMessage instances are fully serializable.

    I do think you raise a valid point about wanting to just fetch the public facing chat history from the agent, since in theory it should already have it somewhere. In practice though, the agent doesn't always have access to the chat history. E.g. if the ChatClientAgent is using the responses service with store=true, then the ChatClientAgent has no chat history at all, it only stores an id in the session that identifies the chat history stored in the responses service.

    Service-stored chat history can also (for some services) be modified via service endpoints, without the agent's involvement. So any copy of ChatHistory that we maintain in the session may become un-synced, if a developer was making such changes.

    We currently also don't have any abstraction or implementations for accessing service-stored chat history for all services, so the agent is unable to natively retrieve the chat history from such a service. Agent Framework relies on the service using it's own chat history, service side, by just pre-pending it on to new messages.

    Therefore, the recommendation right now, is to maintain your own UX friendly chat history outside of the agent.

  8. mo3in commented on Sep 20, 2026

    @mo3in

    Adding another durable-host data point from a .NET 1.22 integration. I am not yet claiming the same root cause, but the scenario is close enough to be useful here.

    Our stack is:

    HarnessAgent
    + ApprovalRequiredAIFunction
    + AG-UI
    + custom durable ChatHistoryProvider (PostgreSQL)
    + custom durable AgentSessionStore (PostgreSQL)
    

    Observed behavior:

    1. A normal user turn causes the model to emit the protected function call.
    2. MAF surfaces ToolApprovalRequestContent.
    3. The protected function is not executed.
    4. The interrupted AgentSession is serialized and persisted successfully.
    5. The same user sends approval through the normal AG-UI resume path.
    6. The same persisted session is restored.
    7. The resumed run currently ends as AG-UI RUN_ERROR / StreamingError.
    8. The protected function remains unexecuted.

    Important comparison: we also have a compatibility canary using stock MapAGUIServer + serialized AgentSessionStore where approval interrupt -> serialize -> restore -> approve works and executes exactly once.

    We are currently isolating whether the divergence appears when adding the durable custom ChatHistoryProvider, the custom hosting route, or their interaction with approval response binding.

    Diagnostic matrix:

    A. MapAGUIServer + default/simple history + serialized session store
       -> passes
    
    B. custom hardened route + simple/default history + equivalent session store
    
    C. MapAGUIServer-equivalent hosting + custom durable history/session stores
    
    D. custom hardened route + custom durable history/session stores
       -> current failing full path
    

    I'll follow up with a minimal reproduction if this proves to be the same durable-approval/history-state issue rather than a bug in our custom route.


  9. westey-m commented on Sep 22, 2026

    @westey-m
    Collaborator

    Closing this issue as the main ask has been addressed.

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: .Net

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions