// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Agents; /// /// Base abstraction for all agent threads. /// A thread represents a specific conversation with an agent. /// public class AgentThread { private string? _conversationId; private IChatMessageStore? _messageStore; /// /// Initializes a new instance of the class. /// public AgentThread() { } /// /// Gets or sets the id of the current thread to support cases where the thread is owned by the agent service. /// /// /// /// Note that either or may be set, but not both. /// If is not null, and is set, /// will be reverted to null, and vice versa. /// /// /// This property may be null in the following cases: /// /// The thread stores messages via the and not in the agent service. /// This thread object is new and a server managed thread has not yet been created in the agent service. /// /// /// /// The id may also change over time where the id is pointing at a /// agent service managed thread, and the default behavior of a service is /// to fork the thread with each iteration. /// /// public string? ConversationId { get => this._conversationId; set { if (string.IsNullOrWhiteSpace(this._conversationId) && string.IsNullOrWhiteSpace(value)) { return; } if (this._messageStore is not null) { // If we have a message store already, we shouldn't switch the thread to use a conversation id // since it means that the thread contents will essentially be deleted, and the thread will not work // with the original agent anymore. throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported."); } this._conversationId = Throw.IfNullOrWhitespace(value); } } /// /// Gets or sets the used by this thread, for cases where messages should be stored in a custom location. /// /// /// /// Note that either or may be set, but not both. /// If is not null, and is set, /// will be reverted to null, and vice versa. /// /// /// This property may be null in the following cases: /// /// The thread stores messages in the agent service and just has an id to the remove thread, instead of in an . /// This thread object is new it is not yet clear whether it will be backed by a server managed thread or an . /// /// /// public IChatMessageStore? MessageStore { get => this._messageStore; set { if (this._messageStore is null && value is null) { return; } if (!string.IsNullOrWhiteSpace(this._conversationId)) { // If we have a conversation id already, we shouldn't switch the thread to use a message store // since it means that the thread will not work with the original agent anymore. throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported."); } this._messageStore = Throw.IfNull(value); } } /// /// Serializes the current object's state to a using the specified serialization options. /// /// The JSON serialization options to use. /// The to monitor for cancellation requests. The default is . /// A representation of the object's state. public virtual async Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { var storeState = this._messageStore is null ? null : await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false); var state = new ThreadState { ConversationId = this.ConversationId, StoreState = storeState }; return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))); } /// /// This method is called when new messages have been contributed to the chat by any participant. /// /// /// Inheritors can use this method to update their context based on the new message. /// /// The new messages. /// The to monitor for cancellation requests. The default is . /// A task that completes when the context has been updated. /// The thread has been deleted. protected internal virtual async Task OnNewMessagesAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) { switch (this) { case { ConversationId: not null }: // If the thread messages are stored in the service // there is nothing to do here, since invoking the // service should already update the thread. break; case { MessageStore: null }: // If there is no conversation id, and no store we can createa a default in memory store and add messages to it. this._messageStore = new InMemoryChatMessageStore(); await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); break; case { MessageStore: not null }: // If a store has been provided, we need to add the messages to the store. await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); break; default: throw new UnreachableException(); } } /// /// Deserializes the state contained in the provided into the properties on this thread. /// /// A representing the state of the thread. /// Optional settings for customizing the JSON deserialization process. /// The to monitor for cancellation requests. The default is . /// A that completes when the state has been deserialized. protected internal virtual async Task DeserializeAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { var state = JsonSerializer.Deserialize( serializedThread, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; if (state?.ConversationId is string threadId) { this.ConversationId = threadId; // Since we have an ID, we should not have a chat message store and we can return here. return; } // If we don't have any IChatMessageStore state return here. if (state?.StoreState is null || state?.StoreState.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) { return; } if (this._messageStore is null) { // If we don't have a chat message store yet, create an in-memory one. this._messageStore = new InMemoryChatMessageStore(); } await this._messageStore.DeserializeStateAsync(state!.StoreState.Value, jsonSerializerOptions, cancellationToken).ConfigureAwait(false); } internal sealed class ThreadState { public string? ConversationId { get; set; } public JsonElement? StoreState { get; set; } } }