diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index adb6eb9f83..4c88c62391 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -245,10 +245,11 @@ protected override async Task RunCoreAsync( } // Only notify the session of new messages if the chatResponse was successful to avoid inconsistent message state in the session. - await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false); + var filteredResponseMessages = FilterFinalFunctionResultContent(chatResponse.Messages, options); + await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, inputMessagesForChatClient, filteredResponseMessages, chatOptions, cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. - await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessagesForChatClient, chatResponse.Messages, cancellationToken).ConfigureAwait(false); + await this.NotifyAIContextProviderOfSuccessAsync(safeSession, inputMessagesForChatClient, filteredResponseMessages, cancellationToken).ConfigureAwait(false); return new AgentResponse(chatResponse) { @@ -370,10 +371,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA this.UpdateSessionConversationId(safeSession, chatResponse.ConversationId, cancellationToken); // To avoid inconsistent state we only notify the session of the input messages if no error occurs after the initial request. - await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false); + var filteredResponseMessages = FilterFinalFunctionResultContent(chatResponse.Messages, options); + await this.NotifyChatHistoryProviderOfNewMessagesAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), filteredResponseMessages, chatOptions, cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. - await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), chatResponse.Messages, cancellationToken).ConfigureAwait(false); + await this.NotifyAIContextProviderOfSuccessAsync(safeSession, GetInputMessages(inputMessagesForChatClient, continuationToken), filteredResponseMessages, cancellationToken).ConfigureAwait(false); } /// @@ -910,6 +912,75 @@ private static List GetResponseUpdates(ChatClientAgentContin return token?.ResponseUpdates?.ToList() ?? []; } + /// + /// Filters trailing from response messages when + /// is not . + /// + /// + /// Walks backward through the response messages, removing consecutive trailing messages + /// whose role is and whose content is entirely + /// . Messages with mixed content are left unchanged. + /// The walk stops at the first message that does not match. + /// + private static IList FilterFinalFunctionResultContent( + IList responseMessages, + AgentRunOptions? options) + { + if (options is ChatClientAgentRunOptions { StoreFinalFunctionResultContent: true }) + { + return responseMessages; + } + + if (responseMessages.Count == 0) + { + return responseMessages; + } + + // Walk backward, removing trailing Tool-role messages that contain only FunctionResultContent. + int firstKeptIndex = responseMessages.Count; + for (int i = responseMessages.Count - 1; i >= 0; i--) + { + ChatMessage message = responseMessages[i]; + + if (message.Role != ChatRole.Tool) + { + break; + } + + bool allFunctionResult = message.Contents.Count > 0; + foreach (AIContent content in message.Contents) + { + if (content is not FunctionResultContent) + { + allFunctionResult = false; + break; + } + } + + if (!allFunctionResult) + { + break; + } + + firstKeptIndex = i; + } + + if (firstKeptIndex == responseMessages.Count) + { + // Nothing was filtered. + return responseMessages; + } + + // Return only the messages before the filtered tail. + var trimmed = new List(firstKeptIndex); + for (int j = 0; j < firstKeptIndex; j++) + { + trimmed.Add(responseMessages[j]); + } + + return trimmed; + } + private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent"; /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs index cf35aa80a1..f0fc777e13 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -35,6 +37,7 @@ private ChatClientAgentRunOptions(ChatClientAgentRunOptions options) { this.ChatOptions = options.ChatOptions?.Clone(); this.ChatClientFactory = options.ChatClientFactory; + this.StoreFinalFunctionResultContent = options.StoreFinalFunctionResultContent; } /// @@ -62,6 +65,48 @@ private ChatClientAgentRunOptions(ChatClientAgentRunOptions options) /// public Func? ChatClientFactory { get; set; } + /// + /// Gets or sets a value indicating whether to store in chat history, if it was + /// the last content returned from the . + /// + /// + /// + /// This setting applies when the last content returned from the is of type + /// rather than for example . + /// + /// + /// is typically only returned as the last content, if the function tool calling + /// loop was terminated. In other cases, the would have been passed to the + /// underlying service again as part of the next request, and new content with an answer to the user ask, for example , + /// or new would have been produced. + /// + /// + /// This option is only relevant if the agent does not use chat history storage in the underlying AI service. If + /// chat history is not stored via a , the setting will have no effect. For agents + /// that store chat history in the underlying AI service, final is never stored. + /// + /// + /// When set to , the behavior of chat history storage via + /// matches the behavior of agents that store chat history in the underlying AI service. Note that this means that + /// since the last stored content would have typically been , + /// would need to be provided manually for the existing to continue the session. + /// + /// + /// When set to , the behavior of chat history storage via + /// differs from the behavior of agents that store chat history in the underlying AI service. + /// However, this does mean that a run could potentially be restarted without manually adding , + /// since the would also be persisted in the chat history. + /// Note however that if multiple function calls needed to be made, and termination happened before all functions were called, + /// not all may have a corresponding , resulting in incomplete + /// chat history regardless of this setting's value. + /// + /// + /// + /// Defaults to . + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public bool StoreFinalFunctionResultContent { get; set; } + /// public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs index 7a00a9f796..bef55d3037 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs @@ -349,6 +349,7 @@ public void CloneReturnsNewInstanceWithSameValues() ChatClientFactory = factory, AllowBackgroundResponses = true, ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + StoreFinalFunctionResultContent = true, AdditionalProperties = new AdditionalPropertiesDictionary { ["key1"] = "value1" @@ -370,6 +371,7 @@ public void CloneReturnsNewInstanceWithSameValues() Assert.Same(factory, clone.ChatClientFactory); Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses); Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken); + Assert.True(clone.StoreFinalFunctionResultContent); Assert.NotNull(clone.AdditionalProperties); Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties); Assert.Equal("value1", clone.AdditionalProperties["key1"]); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StoreFinalFunctionResultContentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StoreFinalFunctionResultContentTests.cs new file mode 100644 index 0000000000..6b8de2c188 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StoreFinalFunctionResultContentTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the +/// filtering behavior of the class. +/// +public class ChatClientAgent_StoreFinalFunctionResultContentTests +{ + /// + /// Verify that when is false (default), + /// trailing messages are filtered out before being stored in chat history. + /// + [Fact] + public async Task RunAsync_FiltersFinalFunctionResultContent_WhenSettingIsFalseAsync() + { + // Arrange + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false }); + + // Assert — chat history should have: user message + assistant FunctionCallContent (tool message filtered out) + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal(ChatRole.User, stored[0].Role); + Assert.Equal(ChatRole.Assistant, stored[1].Role); + Assert.True(stored[1].Contents.OfType().Any()); + } + + /// + /// Verify that when is null (defaults to false), + /// trailing messages are filtered out before being stored in chat history. + /// + [Fact] + public async Task RunAsync_FiltersFinalFunctionResultContent_WhenSettingIsNullAsync() + { + // Arrange + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act — no explicit StoreFinalFunctionResultContent set (null → defaults to false behavior) + await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions()); + + // Assert — tool message should be filtered out + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal(ChatRole.Assistant, stored[1].Role); + Assert.True(stored[1].Contents.OfType().Any()); + } + + /// + /// Verify that when is true, + /// trailing messages are kept and stored in chat history. + /// + [Fact] + public async Task RunAsync_KeepsFinalFunctionResultContent_WhenSettingIsTrueAsync() + { + // Arrange + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = true }); + + // Assert — chat history should have all 3 messages (user + assistant + tool) + var stored = GetStoredMessages(agent, session); + Assert.Equal(3, stored.Count); + Assert.Equal(ChatRole.Tool, stored[2].Role); + Assert.True(stored[2].Contents.OfType().Any()); + } + + /// + /// Verify that no filtering occurs when the last content in the response is not . + /// + [Fact] + public async Task RunAsync_NoFiltering_WhenLastContentIsNotFunctionResultAsync() + { + // Arrange + var responseMessages = new List + { + new(ChatRole.Assistant, "The weather is sunny.") + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false }); + + // Assert — chat history should have user + assistant text (no filtering applied) + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal("The weather is sunny.", stored[1].Text); + } + + /// + /// Verify that multiple trailing messages containing only are all removed. + /// + [Fact] + public async Task RunAsync_FiltersMultipleTrailingFunctionResultMessagesAsync() + { + // Arrange — two trailing tool messages with FunctionResultContent + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather"), new FunctionCallContent("c2", "get_news")]), + new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), + new(ChatRole.Tool, [new FunctionResultContent("c2", "Headlines")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + await agent.RunAsync([new(ChatRole.User, "Weather and news?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false }); + + // Assert — both trailing tool messages should be filtered, leaving user + assistant + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal(ChatRole.Assistant, stored[1].Role); + Assert.Equal(2, stored[1].Contents.OfType().Count()); + } + + /// + /// Verify that in the streaming path, trailing is also filtered + /// before being stored in chat history. + /// + [Fact] + public async Task RunStreamingAsync_FiltersFinalFunctionResultContent_WhenSettingIsFalseAsync() + { + // Arrange + var streamingUpdates = new[] + { + new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new FunctionCallContent("c1", "get_weather")] }, + new ChatResponseUpdate { Role = ChatRole.Tool, Contents = [new FunctionResultContent("c1", "Sunny")] } + }; + + Mock mockService = new(); + mockService.Setup( + s => s.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(streamingUpdates.ToAsyncEnumerable()); + + var (agent, session) = CreateAgentWithChatClient(mockService: mockService); + + // Act + await foreach (var update in agent.RunStreamingAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false })) + { + // consume all updates + } + + // Assert — tool message should be filtered from chat history + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal(ChatRole.User, stored[0].Role); + Assert.Equal(ChatRole.Assistant, stored[1].Role); + Assert.True(stored[1].Contents.OfType().Any()); + } + + /// + /// Verify that returned to the caller still contains the unfiltered response. + /// + [Fact] + public async Task RunAsync_ReturnsUnfilteredResponseToCallerAsync() + { + // Arrange + var responseMessages = new List + { + new(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), + new(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + var response = await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false }); + + // Assert — the returned AgentResponse should contain the full unfiltered response + Assert.Equal(2, response.Messages.Count); + Assert.True(response.Messages[^1].Contents.OfType().Any()); + } + + /// + /// Verify that when a trailing message has mixed content (FunctionResultContent and other content), + /// the message is left unchanged. + /// + [Fact] + public async Task RunAsync_KeepsMixedContentMessage_UnchangedAsync() + { + // Arrange — last message has both TextContent and FunctionResultContent + var responseMessages = new List + { + new(ChatRole.Tool, [new TextContent("Some note"), new FunctionResultContent("c1", "Sunny")]) + }; + + var (agent, session) = CreateAgentWithChatClient(responseMessages); + + // Act + await agent.RunAsync([new(ChatRole.User, "What's the weather?")], session, + options: new ChatClientAgentRunOptions { StoreFinalFunctionResultContent = false }); + + // Assert — chat history should have user + the original mixed-content tool message (kept as-is) + var stored = GetStoredMessages(agent, session); + Assert.Equal(2, stored.Count); + Assert.Equal(2, stored[1].Contents.Count); + Assert.IsType(stored[1].Contents[0]); + Assert.IsType(stored[1].Contents[1]); + } + + #region Helpers + + private static (ChatClientAgent Agent, ChatClientAgentSession Session) CreateAgentWithChatClient( + List? responseMessages = null, + Mock? mockService = null) + { + mockService ??= new Mock(); + + if (responseMessages is not null) + { + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(responseMessages)); + } + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test" }, + UseProvidedChatClientAsIs = true, + }); + + ChatClientAgentSession session = new(); + + return (agent, session); + } + + private static List GetStoredMessages(ChatClientAgent agent, ChatClientAgentSession session) + { + var provider = agent.ChatHistoryProvider as InMemoryChatHistoryProvider; + Assert.NotNull(provider); + return provider.GetMessages(session); + } + + #endregion +}