Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;

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

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

Console.WriteLine(await agent.RunAsync("Tell me a joke about a lemur.", session));
chatHistory = provider?.GetMessages(session);
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
if (session.TryGetInMemoryChatHistory(out chatHistory))
{
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
}

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

chatHistory = provider?.GetMessages(session);
Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n");
if (session.TryGetInMemoryChatHistory(out chatHistory))
{
Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n");
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
36 changes: 18 additions & 18 deletions dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options,
// 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.
Expand Down Expand Up @@ -743,25 +743,31 @@ private void UpdateSessionConversationId(ChatClientAgentSession session, string?

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(
Expand Down Expand Up @@ -807,13 +813,7 @@ private Task NotifyChatHistoryProviderOfNewMessagesAsync(

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,17 @@ public static partial void LogAgentChatClientInvokedStreamingAgent(
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -71,5 +101,9 @@ public ChatClientAgentOptions Clone()
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,
};
}
Loading
Loading