Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,11 @@ protected override async Task<AgentResponse> 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)
{
Expand Down Expand Up @@ -370,10 +371,11 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> 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);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -910,6 +912,75 @@ private static List<ChatResponseUpdate> GetResponseUpdates(ChatClientAgentContin
return token?.ResponseUpdates?.ToList() ?? [];
}

/// <summary>
/// Filters trailing <see cref="FunctionResultContent"/> from response messages when
/// <see cref="ChatClientAgentRunOptions.StoreFinalFunctionResultContent"/> is not <see langword="true"/>.
/// </summary>
/// <remarks>
/// Walks backward through the response messages, removing consecutive trailing messages
/// whose role is <see cref="ChatRole.Tool"/> and whose content is entirely
/// <see cref="FunctionResultContent"/>. Messages with mixed content are left unchanged.
/// The walk stops at the first message that does not match.
/// </remarks>
private static IList<ChatMessage> FilterFinalFunctionResultContent(
IList<ChatMessage> 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<ChatMessage>(firstKeptIndex);
for (int j = 0; j < firstKeptIndex; j++)
{
trimmed.Add(responseMessages[j]);
}

return trimmed;
}

private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -35,6 +37,7 @@ private ChatClientAgentRunOptions(ChatClientAgentRunOptions options)
{
this.ChatOptions = options.ChatOptions?.Clone();
this.ChatClientFactory = options.ChatClientFactory;
this.StoreFinalFunctionResultContent = options.StoreFinalFunctionResultContent;
}

/// <summary>
Expand Down Expand Up @@ -62,6 +65,48 @@ private ChatClientAgentRunOptions(ChatClientAgentRunOptions options)
/// </value>
public Func<IChatClient, IChatClient>? ChatClientFactory { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to store <see cref="FunctionResultContent"/> in chat history, if it was
/// the last content returned from the <see cref="IChatClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// This setting applies when the last content returned from the <see cref="IChatClient"/> is of type <see cref="FunctionResultContent"/>
/// rather than for example <see cref="TextContent"/>.
/// </para>
/// <para>
/// <see cref="FunctionResultContent"/> is typically only returned as the last content, if the function tool calling
/// loop was terminated. In other cases, the <see cref="FunctionResultContent"/> 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 <see cref="TextContent"/>,
/// or new <see cref="FunctionCallContent"/> would have been produced.
/// </para>
/// <para>
/// 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 <see cref="ChatHistoryProvider"/>, the setting will have no effect. For agents
/// that store chat history in the underlying AI service, final <see cref="FunctionResultContent"/> is never stored.
/// </para>
/// <para>
/// When set to <see langword="false"/>, the behavior of chat history storage via <see cref="ChatHistoryProvider"/>
/// 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 <see cref="FunctionCallContent"/>, <see cref="FunctionResultContent"/>
/// would need to be provided manually for the existing <see cref="FunctionCallContent"/> to continue the session.
/// </para>
/// <para>
/// When set to <see langword="true"/>, the behavior of chat history storage via <see cref="ChatHistoryProvider"/>
/// 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 <see cref="FunctionResultContent"/>,
/// since the <see cref="FunctionResultContent"/> 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 <see cref="FunctionCallContent"/> may have a corresponding <see cref="FunctionResultContent"/>, resulting in incomplete
/// chat history regardless of this setting's value.
/// </para>
/// </remarks>
/// <value>
/// Defaults to <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool StoreFinalFunctionResultContent { get; set; }

/// <inheritdoc/>
public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]);
Expand Down
Loading
Loading