Skip to content

.NET: [Bug]: ToolApprovalRequestContent / ToolApprovalResponseContent reconciliation failure after Session Serialization #5189

@MirkoMattioliSacmi

Description

@MirkoMattioliSacmi

Description

Describe the bug

The ToolApprovalRequestContent and ToolApprovalResponseContent reconciliation logic in FunctionInvokingChatClient fails if the session is serialized and deserialized between the request and the response.

When the session is restored, the new instances of the messages are no longer recognized as matching, leading to repeated approval prompts and eventually a System.InvalidOperationException.

To Reproduce

This bug can be reproduced by adding just two lines of code to the official sample:
samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals

Modified Code:
Insert the serialization/deserialization logic immediately after the agent runs and produces an approval request:

AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session);
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();

// --- START OF REPRODUCTION STEPS ---
// Simulate session persistence using built-in framework methods
var serializedState = await agent.SerializeSessionAsync(session);
session = await agent.DeserializeSessionAsync(serializedState);
// --- END OF REPRODUCTION STEPS ---

while (approvalRequests.Count > 0)
{
    // ... standard sample logic to get user input (Y/N) ...
    response = await agent.RunAsync(userInputResponses, session);
}

Steps:

  1. Run the modified sample.
  2. When prompted to approve the function call, reply with N (Reject).
  3. Observe that the agent does not acknowledge the rejection and prompts for approval again (LLM acts as if the tool failed or was never answered).
  4. Reply with N a second time.

Actual behavior

The second rejection triggers a crash because the message history now contains duplicate or orphaned approval requests that the internal validator cannot reconcile due to broken object references:

Expected behavior

The framework should match requests and responses based on their CallId or RequestId string values. Session persistence should be transparent to the tool approval workflow, allowing requests and responses to be matched even after a serialization round-trip.

Code Sample

// Copyright (c) Microsoft. All rights reserved.

// This sample demonstrates how to use a ChatClientAgent with function tools that require a human in the loop for approvals.
// It shows both non-streaming and streaming agent interactions using menu-related tools.
// If the agent is hosted in a service, with a remote user, combine this sample with the Persisted Conversations sample to persist the chat history
// while the agent is waiting for user input.

using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;

var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";

// Create a sample function tool that the agent can use.
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
    => $"The weather in {location} is cloudy with a high of 15°C.";

// Create the chat client and agent.
// Note that we are wrapping the function tool with ApprovalRequiredAIFunction to require user approval before invoking it.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
    new Uri(endpoint),
    new DefaultAzureCredential())
    .GetChatClient(deploymentName)
    .AsAIAgent(instructions: "You are a helpful assistant", tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]);

// Call the agent and check if there are any function approval requests to handle.
// For simplicity, we are assuming here that only function approvals are pending.
AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session);
List<ToolApprovalRequestContent> approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();

// --- START OF REPRODUCTION STEPS ---
// Simulate session persistence using built-in framework methods
var serializedState = await agent.SerializeSessionAsync(session);
session = await agent.DeserializeSessionAsync(serializedState);
// --- END OF REPRODUCTION STEPS ---

// For streaming use:
// var updates = await agent.RunStreamingAsync("What is the weather like in Amsterdam?", session).ToListAsync();
// approvalRequests = updates.SelectMany(x => x.Contents).OfType<ToolApprovalRequestContent>().ToList();

while (approvalRequests.Count > 0)
{
    // Ask the user to approve each function call request.
    List<ChatMessage> userInputResponses = approvalRequests
        .ConvertAll(functionApprovalRequest =>
        {
            Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}");
            return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
        });

    // Pass the user input responses back to the agent for further processing.
    response = await agent.RunAsync(userInputResponses, session);

    approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();

    // For streaming use:
    // updates = await agent.RunStreamingAsync(userInputResponses, session).ToListAsync();
    // approvalRequests = updates.SelectMany(x => x.Contents).OfType<ToolApprovalRequestContent>().ToList();
}

Console.WriteLine($"\nAgent: {response}");

// For streaming use:
// Console.WriteLine($"\nAgent: {updates.ToAgentResponse()}");

Error Messages / Stack Traces

The agent would like to invoke the following function, please reply Y to approve: Name GetWeather
N
The agent would like to invoke the following function, please reply Y to approve: Name GetWeather
N

Unhandled exception. System.InvalidOperationException: ToolApprovalRequestContent found with FunctionCall.CallId(s) 'call_cViqO50QJF92cudvsprnjpka' that have no matching ToolApprovalResponseContent.
   at Microsoft.Shared.Diagnostics.Throw.InvalidOperationException(String message)
   at Microsoft.Extensions.AI.FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses(List`1 messages)
   at Microsoft.Extensions.AI.FunctionInvokingChatClient.ProcessFunctionApprovalResponses(List`1 originalMessages, Boolean hasConversationId, String toolMessageId, String functionCallContentFallbackMessageId)
   at Microsoft.Extensions.AI.FunctionInvokingChatClient.GetResponseAsync(IEnumerable`1 messages, ChatOptions options, CancellationToken cancellationToken)
   at Microsoft.Agents.AI.ChatClientAgent.RunCoreAsync(IEnumerable`1 messages, AgentSession session, AgentRunOptions options, CancellationToken cancellationToken)

Package Versions

Microsoft.Agents.AI: v1.0.0

.NET Version

.NET 10.0

Additional Context

Tested on Azure OpenAI o4-mini

Important Note on AI "Diplomatic" Responses

In some runs, the LLM might attempt to "bypass" the technical failure by providing a generic conversational response (e.g., "I'm having trouble accessing real-time data right now, but I can tell you that...") instead of crashing immediately.

This behavior further confirms the bug:

  1. The N (rejection) signal is lost during deserialization.
  2. The LLM sees an "unanswered" tool call in the history but no valid ToolApprovalResponseContent to explain it.
  3. The LLM assumes a technical failure and hallucinates a polite excuse.

While this might prevent an immediate crash in some instances, it results in an unreliable and non-deterministic workflow, where the user's explicit rejection is ignored by the agent's logic. The InvalidOperationException typically triggers on the subsequent turn when the framework's internal validator finally detects the inconsistent message state.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions