-
Notifications
You must be signed in to change notification settings - Fork 849
Description
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
FunctionInvokingChatClientto 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.
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; }
}
}