Skip to content

Commit 822a3eb

Browse files
westey-mCopilot
andauthored
.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>
1 parent c9cd067 commit 822a3eb

8 files changed

Lines changed: 510 additions & 29 deletions

File tree

dotnet/samples/02-agents/Agents/Agent_Step13_ChatReduction/Program.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
using Microsoft.Agents.AI;
1111
using Microsoft.Extensions.AI;
1212
using OpenAI.Chat;
13-
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
1413

1514
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
1615
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -39,9 +38,10 @@
3938
// We can use the ChatHistoryProvider, that is also used by the agent, to read the
4039
// chat history from the session state, and see how the reducer is affecting the stored messages.
4140
// Here we expect to see 2 messages, the original user message and the agent response message.
42-
var provider = agent.GetService<InMemoryChatHistoryProvider>();
43-
List<ChatMessage>? chatHistory = provider?.GetMessages(session);
44-
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
41+
if (session.TryGetInMemoryChatHistory(out var chatHistory))
42+
{
43+
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
44+
}
4545

4646
// Invoke the agent a few more times.
4747
Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", session));
@@ -51,16 +51,22 @@
5151
// to trigger the reducer is just before messages are contributed to a new agent run.
5252
// So at this time, we have not yet triggered the reducer for the most recently added messages,
5353
// and they are still in the chat history.
54-
chatHistory = provider?.GetMessages(session);
55-
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
54+
if (session.TryGetInMemoryChatHistory(out chatHistory))
55+
{
56+
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
57+
}
5658

5759
Console.WriteLine(await agent.RunAsync("Tell me a joke about a lemur.", session));
58-
chatHistory = provider?.GetMessages(session);
59-
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
60+
if (session.TryGetInMemoryChatHistory(out chatHistory))
61+
{
62+
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
63+
}
6064

6165
// At this point, the chat history has exceeded the limit and the original message will not exist anymore,
6266
// so asking a follow up question about it may not work as expected.
6367
Console.WriteLine(await agent.RunAsync("What was the first joke I asked you to tell again?", session));
6468

65-
chatHistory = provider?.GetMessages(session);
66-
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
69+
if (session.TryGetInMemoryChatHistory(out chatHistory))
70+
{
71+
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
72+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Diagnostics.CodeAnalysis;
5+
using System.Text.Json;
6+
using Microsoft.Extensions.AI;
7+
using Microsoft.Shared.Diagnostics;
8+
9+
namespace Microsoft.Agents.AI;
10+
11+
/// <summary>
12+
/// Provides extension methods for <see cref="AgentSession"/>.
13+
/// </summary>
14+
public static class AgentSessionExtensions
15+
{
16+
/// <summary>
17+
/// 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"/>
18+
/// </summary>
19+
/// <remarks>
20+
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
21+
/// </remarks>
22+
/// <param name="session">The agent session from which to retrieve in-memory chat history.</param>
23+
/// <param name="messages">When this method returns, contains the list of chat history messages if available; otherwise, null.</param>
24+
/// <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
25+
/// in-memory chat history is used.</param>
26+
/// <param name="jsonSerializerOptions">Optional JSON serializer options to use when accessing the session state. If null, default options are used.</param>
27+
/// <returns><see langword="true"/> if the in-memory chat history messages were found and retrieved; <see langword="false"/> otherwise.</returns>
28+
public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
29+
{
30+
_ = Throw.IfNull(session);
31+
32+
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state?.Messages is not null)
33+
{
34+
messages = state.Messages;
35+
return true;
36+
}
37+
38+
messages = null;
39+
return false;
40+
}
41+
42+
/// <summary>
43+
/// Sets the in-memory chat message history for the specified agent session, replacing any existing messages.
44+
/// </summary>
45+
/// <remarks>
46+
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
47+
/// 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.
48+
/// </remarks>
49+
/// <param name="session">The agent session whose in-memory chat history will be updated.</param>
50+
/// <param name="messages">The list of chat messages to store in memory for the session. Replaces any existing messages for the specified
51+
/// state key.</param>
52+
/// <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
53+
/// used.</param>
54+
/// <param name="jsonSerializerOptions">The serializer options used when accessing or storing the state. If null, default options are applied.</param>
55+
public static void SetInMemoryChatHistory(this AgentSession session, List<ChatMessage> messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null)
56+
{
57+
_ = Throw.IfNull(session);
58+
59+
if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state is not null)
60+
{
61+
state.Messages = messages;
62+
return;
63+
}
64+
65+
session.StateBag.SetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), new InMemoryChatHistoryProvider.State() { Messages = messages }, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions);
66+
}
67+
}

dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options,
109109
// Use the ChatHistoryProvider from options if provided.
110110
// If one was not provided, and we later find out that the underlying service does not manage chat history server-side,
111111
// we will use the default InMemoryChatHistoryProvider at that time.
112-
this.ChatHistoryProvider = options?.ChatHistoryProvider;
112+
this.ChatHistoryProvider = options?.ChatHistoryProvider ?? new InMemoryChatHistoryProvider();
113113
this.AIContextProviders = this._agentOptions?.AIContextProviders as IReadOnlyList<AIContextProvider> ?? this._agentOptions?.AIContextProviders?.ToList();
114114

115115
// Validate that no two providers share the same StateKey, since they would overwrite each other's state in the session.
@@ -743,25 +743,31 @@ private void UpdateSessionConversationId(ChatClientAgentSession session, string?
743743

744744
if (!string.IsNullOrWhiteSpace(responseConversationId))
745745
{
746-
if (this.ChatHistoryProvider is not null)
746+
if (this._agentOptions?.ChatHistoryProvider is not null)
747747
{
748748
// The agent has a ChatHistoryProvider configured, but the service returned a conversation id,
749749
// meaning the service manages chat history server-side. Both cannot be used simultaneously.
750-
throw new InvalidOperationException(
751-
$"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.");
750+
if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true)
751+
{
752+
this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName());
753+
}
754+
755+
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true)
756+
{
757+
throw new InvalidOperationException(
758+
$"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.");
759+
}
760+
761+
if (this._agentOptions?.ClearOnChatHistoryProviderConflict is true)
762+
{
763+
this.ChatHistoryProvider = null;
764+
}
752765
}
753766

754767
// If we got a conversation id back from the chat client, it means that the service supports server side session storage
755768
// so we should update the session with the new id.
756769
session.ConversationId = responseConversationId;
757770
}
758-
else
759-
{
760-
// If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and
761-
// the agent has no ChatHistoryProvider yet, we should use the default InMemoryChatHistoryProvider so that
762-
// we have somewhere to store the chat history.
763-
this.ChatHistoryProvider ??= new InMemoryChatHistoryProvider();
764-
}
765771
}
766772

767773
private Task NotifyChatHistoryProviderOfFailureAsync(
@@ -807,13 +813,7 @@ private Task NotifyChatHistoryProviderOfNewMessagesAsync(
807813

808814
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
809815
{
810-
ChatHistoryProvider? provider = this.ChatHistoryProvider;
811-
812-
if (session.ConversationId is not null && provider is not null)
813-
{
814-
throw new InvalidOperationException(
815-
$"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.");
816-
}
816+
ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null;
817817

818818
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
819819
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)

dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,17 @@ public static partial void LogAgentChatClientInvokedStreamingAgent(
5656
string agentId,
5757
string agentName,
5858
Type clientType);
59+
60+
/// <summary>
61+
/// Logs <see cref="ChatClientAgent"/> warning about <see cref="ChatHistoryProvider"/> conflict.
62+
/// </summary>
63+
[LoggerMessage(
64+
Level = LogLevel.Warning,
65+
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.")]
66+
public static partial void LogAgentChatClientHistoryProviderConflict(
67+
this ILogger logger,
68+
string conversationIdName,
69+
string chatHistoryProviderName,
70+
string agentId,
71+
string agentName);
5972
}

dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,36 @@ public sealed class ChatClientAgentOptions
5959
/// </remarks>
6060
public bool UseProvidedChatClientAsIs { get; set; }
6161

62+
/// <summary>
63+
/// Gets or sets a value indicating whether to set the <see cref="ChatClientAgent.ChatHistoryProvider"/> to <see langword="null"/>
64+
/// 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.
65+
/// </summary>
66+
/// <remarks>
67+
/// 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.
68+
/// </remarks>
69+
/// <value>
70+
/// Default is <see langword="true"/>.
71+
/// </value>
72+
public bool ClearOnChatHistoryProviderConflict { get; set; } = true;
73+
74+
/// <summary>
75+
/// Gets or sets a value indicating whether to log a warning if the underlying AI service indicates that it manages chat history
76+
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
77+
/// </summary>
78+
/// <value>
79+
/// Default is <see langword="true"/>.
80+
/// </value>
81+
public bool WarnOnChatHistoryProviderConflict { get; set; } = true;
82+
83+
/// <summary>
84+
/// Gets or sets a value indicating whether an exception is thrown if the underlying AI service indicates that it manages chat history
85+
/// (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
86+
/// </summary>
87+
/// <value>
88+
/// Default is <see langword="true"/>.
89+
/// </value>
90+
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
91+
6292
/// <summary>
6393
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
6494
/// </summary>
@@ -71,5 +101,9 @@ public ChatClientAgentOptions Clone()
71101
ChatOptions = this.ChatOptions?.Clone(),
72102
ChatHistoryProvider = this.ChatHistoryProvider,
73103
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
104+
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
105+
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
106+
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
107+
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
74108
};
75109
}

0 commit comments

Comments
 (0)