.NET: Add helpers to more easily access in-memory ChatHistory and make ChatHistoryProvider management more configurable. (#4224)

* Add helpers to more easily access in-memory ChatHistory and make ChatHistoryProvider management more configurable.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-02-26 14:16:21 +00:00
committed by GitHub
Unverified
parent c9cd067be6
commit 822a3eb5ea
8 changed files with 510 additions and 29 deletions
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for <see cref="AgentSession"/>.
/// </summary>
public static class AgentSessionExtensions
{
/// <summary>
/// Attempts to retrieve the in-memory chat history messages associated with the specified agent session, if the agent is storing memories in the session using the <see cref="InMemoryChatHistoryProvider"/>
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// </remarks>
/// <param name="session">The agent session from which to retrieve in-memory chat history.</param>
/// <param name="messages">When this method returns, contains the list of chat history messages if available; otherwise, null.</param>
/// <param name="stateKey">An optional key used to identify the chat history state in the session's state bag. If null, the default key for
/// in-memory chat history is used.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options to use when accessing the session state. If null, default options are used.</param>
/// <returns><see langword="true"/> if the in-memory chat history messages were found and retrieved; <see langword="false"/> otherwise.</returns>
public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
_ = Throw.IfNull(session);
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state?.Messages is not null)
{
messages = state.Messages;
return true;
}
messages = null;
return false;
}
/// <summary>
/// Sets the in-memory chat message history for the specified agent session, replacing any existing messages.
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// If messages are set, but a different <see cref="ChatHistoryProvider"/> is used, or if chat history is stored in the underlying AI service, the messages will be ignored.
/// </remarks>
/// <param name="session">The agent session whose in-memory chat history will be updated.</param>
/// <param name="messages">The list of chat messages to store in memory for the session. Replaces any existing messages for the specified
/// state key.</param>
/// <param name="stateKey">The key used to identify the in-memory chat history within the session's state bag. If null, a default key is
/// used.</param>
/// <param name="jsonSerializerOptions">The serializer options used when accessing or storing the state. If null, default options are applied.</param>
public static void SetInMemoryChatHistory(this AgentSession session, List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
_ = Throw.IfNull(session);
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state is not null)
{
state.Messages = messages;
return;
}
session.StateBag.SetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), new InMemoryChatHistoryProvider.State() { Messages = messages }, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions);
}
}
@@ -109,7 +109,7 @@ public sealed partial class ChatClientAgent : AIAgent
// Use the ChatHistoryProvider from options if provided.
// If one was not provided, and we later find out that the underlying service does not manage chat history server-side,
// we will use the default InMemoryChatHistoryProvider at that time.
this.ChatHistoryProvider = options?.ChatHistoryProvider;
this.ChatHistoryProvider = options?.ChatHistoryProvider ?? new InMemoryChatHistoryProvider();
this.AIContextProviders = this._agentOptions?.AIContextProviders as IReadOnlyList<AIContextProvider> ?? this._agentOptions?.AIContextProviders?.ToList();
// Validate that no two providers share the same StateKey, since they would overwrite each other's state in the session.
@@ -743,25 +743,31 @@ public sealed partial class ChatClientAgent : AIAgent
if (!string.IsNullOrWhiteSpace(responseConversationId))
{
if (this.ChatHistoryProvider is not null)
if (this._agentOptions?.ChatHistoryProvider is not null)
{
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
throw new InvalidOperationException(
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true)
{
this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName());
}
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true)
{
throw new InvalidOperationException(
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
}
if (this._agentOptions?.ClearOnChatHistoryProviderConflict is true)
{
this.ChatHistoryProvider = null;
}
}
// If we got a conversation id back from the chat client, it means that the service supports server side session storage
// so we should update the session with the new id.
session.ConversationId = responseConversationId;
}
else
{
// If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and
// the agent has no ChatHistoryProvider yet, we should use the default InMemoryChatHistoryProvider so that
// we have somewhere to store the chat history.
this.ChatHistoryProvider ??= new InMemoryChatHistoryProvider();
}
}
private Task NotifyChatHistoryProviderOfFailureAsync(
@@ -807,13 +813,7 @@ public sealed partial class ChatClientAgent : AIAgent
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
{
ChatHistoryProvider? provider = this.ChatHistoryProvider;
if (session.ConversationId is not null && provider is not null)
{
throw new InvalidOperationException(
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured.");
}
ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null;
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
@@ -56,4 +56,17 @@ internal static partial class ChatClientAgentLogMessages
string agentId,
string agentName,
Type clientType);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> warning about <see cref="ChatHistoryProvider"/> conflict.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: Only {ConversationIdName} or {ChatHistoryProviderName} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {ChatHistoryProviderName} configured.")]
public static partial void LogAgentChatClientHistoryProviderConflict(
this ILogger logger,
string conversationIdName,
string chatHistoryProviderName,
string agentId,
string agentName);
}
@@ -59,6 +59,36 @@ public sealed class ChatClientAgentOptions
/// </remarks>
public bool UseProvidedChatClientAsIs { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to set the <see cref="ChatClientAgent.ChatHistoryProvider"/> to <see langword="null"/>
/// if the underlying AI service indicates that it manages chat history (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <remarks>
/// Note that even if this setting is set to <see langword="false"/>, the <see cref="ChatHistoryProvider"/> will still not be used if the underlying AI service indicates that it manages chat history.
/// </remarks>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool ClearOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to log a warning if the underlying AI service indicates that it manages chat history
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool WarnOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether an exception is thrown if the underlying AI service indicates that it manages chat history
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -71,5 +101,9 @@ public sealed class ChatClientAgentOptions
ChatOptions = this.ChatOptions?.Clone(),
ChatHistoryProvider = this.ChatHistoryProvider,
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
};
}