Skip to content

FunctionInvokingChatClient reorder messages when using FunctionApprovalResponseContent #7156

@NoofSaeidh

Description

@NoofSaeidh

Description

When FunctionInvokingChatClient is used together with FunctionApprovalResponseContent, the client may reorder chat messages and, in some cases, lose user messages after a function approval.

This behavior causes several problems:

  • User or system messages added during a rejection/approval flow are not reliably preserved.
  • It becomes impossible to provide additional rejection details back to the LLM (e.g., explaining why a tool call was rejected and how the model should retry with different arguments).
  • As a result, developers are forced to manually manipulate approval and chat contents, instead of relying on FunctionInvokingChatClient to manage the flow correctly.

This makes it difficult to implement robust function-calling workflows that support:

  • Partial rejections
  • Guided retries with modified arguments
  • Clear, user-visible explanations of what went wrong and what to do next

Reproduction Steps

using Microsoft.Extensions.AI;

#pragma warning disable MEAI001

internal class StepsToReproduce
{
public async Task Execute(IChatClient rawClient)
{
var client = rawClient.AsBuilder().UseFunctionInvocation().Build();

    var chatOptions = new ChatOptions
    {
        Tools =
        [
            new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ConvertString)),
        ],
    };
    
    string prompt = """
                    You are test engineer that need to test AI Functions.

                    As first test case, call ConvertString function with random input.

                    Respect user response if tool was rejected.
                    """;

    List<ChatMessage> messages = [new ChatMessage(ChatRole.User, prompt)];
    var response = await client.GetResponseAsync(messages, chatOptions);
    messages.AddRange(response.Messages);

    // problem 1

    messages.Add(new ChatMessage(ChatRole.User, [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(false)]));
    messages.Add(new ChatMessage(ChatRole.User, "try another input"));
    response = await client.GetResponseAsync(messages, chatOptions);

    // OR problem 2

    messages.Add(new ChatMessage(ChatRole.User, [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(true)]));
    messages.Add(new ChatMessage(ChatRole.User, "provide details whats happened"));
    response = await client.GetResponseAsync(messages, chatOptions);
}

private string ConvertString(string input) => $"Converted: {input}";

}

Expected behavior

Messages should be sent to LLM in the following order:

For problem 1.

user: 1st message
assistant: tool_call
tool_call: reject
user: 2nd message

For problem 2.

user: 1st message
assistant: tool_call
tool_call: approve
user: 2nd message

Actual behavior

Messages are sent to LLM in the following order:

For problem 1.

user: 1st message
user: 2nd message
assistant: tool_call
tool_call: reject

For problem 2.

user: 1st message
assistant: tool_call
tool_call: approve

Regression?

No response

Known Workarounds

Manually replace FunctionApprovalRequestContent with FunctionCallContent and FunctionResultContent

Configuration

No response

Other information

here are the test scenarios I used for testing with manual manipulation and default mechanism and clef log.

export-8de3c0ba9a8b892.log

using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;

#pragma warning disable MEAI001

namespace AiResearch;

public class ChatService(IChatClient rawClient, ILogger<ChatService> logger, ILoggerFactory loggerFactory)
{
    public async Task RunAsync()
    {
        var sink = new MessageSink();
        var client = CreateChatClient(sink);
        foreach (var (testCase, method) in GetMethods())
        {
            await TestMethod(client, sink, testCase, method);
        }
    }


    private IChatClient CreateChatClient(MessageSink messageSink)
    {
        // build in reverse order. logging must wrap original logging to see real messages and not that in function invoking
        return rawClient.AsBuilder()
            .UseFunctionInvocation(loggerFactory)
            .Use(c => new SinkingChatClient(c, loggerFactory.CreateLogger<SinkingChatClient>(), messageSink))
            .Build();
    }


    private async Task TestMethod(IChatClient chatClient, MessageSink messageSink, string testCase, Action<IList<ChatMessage>> method)
    {
        using var scope = logger.BeginScope("{TestCase}", testCase);
        logger.LogInformation("Begin {TestCase}", testCase);

        var chatOptions = new ChatOptions
        {
            Tools =
            [
                new ApprovalRequiredAIFunction(AIFunctionFactory.Create(ConvertString)),
            ],
        };

        try
        {
            string prompt = """
                                You are test engineer that need to test AI Functions.

                                As first test case, call ConvertString function with random input.

                                Respect user response if tool was rejected.
                                """;
            List<ChatMessage> messages = [new ChatMessage(ChatRole.System, prompt)];

            ChatResponse response;
            bool firstCall = true;
            while ((response = await chatClient.GetResponseAsync(messages, chatOptions)).FinishReason != ChatFinishReason.Stop)
            {
                messages.AddRange(response.Messages);
                if (response.FinishReason == ChatFinishReason.ToolCalls)
                {
                    if (firstCall)
                    {
                        method(messages);
                        firstCall = false;
                    }
                    else
                    {
                        messages.Add(new ChatMessage(ChatRole.User,
                            [response.Messages.Last().Contents.OfType<FunctionApprovalRequestContent>().First().CreateResponse(true)]));

                        messages.Add(new ChatMessage(ChatRole.User, "this is result message"));
                    }
                }
            }

            logger.LogInformation("Prompt processed {Log}", JsonSerializer.Serialize(messageSink.ChatMessages, AIJsonUtilities.DefaultOptions));
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error occurred while getting response.");
        }
    }

    private string ConvertString(string value) => $"Converted: {value}";

    private static IList<(string, Action<IList<ChatMessage>>)> GetMethods()
    {
        const string userMessage = "Please use another input.";

        return
        [
            (nameof(Manual_AddUserMessage_AsExtraTextContent), Manual_AddUserMessage_AsExtraTextContent),
            (nameof(Manual_AddUserMessage_AsNextChatMessage), Manual_AddUserMessage_AsNextChatMessage),
            (nameof(Manual_AddUserMessage_AsToolResult), Manual_AddUserMessage_AsToolResult),
            (nameof(Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage), Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage),
            (nameof(Default_AddUserMessageAsNextMessage_ResponseAsNextMessage), Default_AddUserMessageAsNextMessage_ResponseAsNextMessage),
            (nameof(Default_AddUserMessageAsNextMessage_ResponseAsSameMessage), Default_AddUserMessageAsNextMessage_ResponseAsSameMessage),
            (nameof(Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage), Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage),
        ];


        static void Manual_AddUserMessage_AsToolResult(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.Tool, [new FunctionResultContent(approval.FunctionCall.CallId, userMessage)] ));
                }
            }
        }

        static void Manual_AddUserMessage_AsExtraTextContent(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1,
                        new ChatMessage(ChatRole.Tool,
                            [
                                new FunctionResultContent(approval.FunctionCall.CallId, "Error: Tool call invocation was rejected by user."),
                                new TextContent(userMessage)
                            ]
                        )
                    );
                }
            }
        }

        static void Manual_AddUserMessage_AsNextChatMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = approval.FunctionCall;

                    allMessages.Insert(i + 1,
                        new ChatMessage(ChatRole.Tool,
                            [
                                new FunctionResultContent(approval.FunctionCall.CallId, "Error: Tool call invocation was rejected by user."),
                            ]
                        )
                    );
                    allMessages.Insert(i + 2, new ChatMessage(ChatRole.User, userMessage));
                }
            }
        }

        static void Default_AddUserMessage_AsExtraTextContent_ResponseAsSameMessage(IList<ChatMessage> allMessages)
        {
            foreach (var chatMessage in allMessages)
            {
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    var response = approval.CreateResponse(false);
                    var index = chatMessage.Contents.IndexOf(approval);
                    chatMessage.Contents[index] = response;
                    chatMessage.Contents.Insert(index + 1, new TextContent(userMessage));
                }
            }
        }

        static void Default_AddUserMessageAsExtraTextContent_ResponseAsNextMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        approval.CreateResponse(false),
                        new TextContent(userMessage),
                    ]));
                }
            }
        }

        static void Default_AddUserMessageAsNextMessage_ResponseAsSameMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    chatMessage.Contents.Add(approval.CreateResponse(false));
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        new TextContent(userMessage),
                    ]));
                }
            }
        }

        static void Default_AddUserMessageAsNextMessage_ResponseAsNextMessage(IList<ChatMessage> allMessages)
        {
            for (var i = 0; i < allMessages.Count; i++)
            {
                var chatMessage = allMessages[i];
                if (chatMessage.Contents.OfType<FunctionApprovalRequestContent>().FirstOrDefault() is { } approval)
                {
                    allMessages.Insert(i + 1, new ChatMessage(ChatRole.User,
                    [
                        approval.CreateResponse(false),
                    ]));
                    allMessages.Insert(i + 2, new ChatMessage(ChatRole.User,
                    [
                        new TextContent(userMessage),
                    ]));
                }
            }
        }
    }


    private class SinkingChatClient(IChatClient innerClient, ILogger<SinkingChatClient> logger, MessageSink messageSink) : DelegatingChatClient(innerClient)
    {
        public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null,
            CancellationToken cancellationToken = default)
        {
            var log = messages.ToList();
            var result = await base.GetResponseAsync(log.ToList(), options, cancellationToken);
            log.AddRange(result.Messages);

            messageSink.ChatMessages = log;

            return result;
        }
    }

    private class MessageSink
    {
        public IList<ChatMessage> ChatMessages { get; set; }
    }

}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI librariesbugThis issue describes a behavior which is not expected - a bug.untriaged

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions