.NET: [BREAKING] Subclass AgentThread so that different agents have their own threads with their own typed settings. (#798)

* Subclass AgentThread so that different agents have their own threads with their own typed settings.

* Address PR comment.

* Add unit tests for base abstract threads

* Fix style warning

* Fix stlying

* FIx and suppress warnings as needed.

* Remove covariant thread response types and fix some styling.

* Remove unecessary json property name attributes and make OrchestratingAgentThread private

* Fix break from merge from main.

* Fix formatting

* Fix deserialization bug in Memory sample

* Remove thread deletion from basic samples.

* Remove public constructors for thread subclasses and add more factory methods to concrete agent types.

* Update AgentProxy thread constructors to be internal as well.

* Revert AgentProxyThread to internal

* Change AIContextProvider to internal set

* Change conversation id and message store properties to internal set

* Update styling.

* Seal various thread types.

* Add thread type check for thread deletion

* Fix tests after latest merge from main

* Add thread type checks for thread deletion.

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
This commit is contained in:
westey
2025-09-23 11:30:06 +01:00
committed by GitHub
Unverified
parent 230cb083ce
commit 3571a7d321
68 changed files with 1774 additions and 613 deletions
@@ -1076,8 +1076,7 @@
{
var actorClient = (selectedProtocol is Protocol.A2A) ? A2AActorClient : ActorClient;
var agent = new AgentProxy(currentConversation.AgentName, actorClient);
var thread = agent.GetNewThread();
thread.ConversationId = currentConversation.SessionId;
var thread = agent.GetNewThread(currentConversation.SessionId);
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, userMessage)],
@@ -123,7 +123,7 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent.
// Create a parent span for the entire agent session
using var sessionActivity = activitySource.StartActivity("Agent Session");
var sessionId = thread.ConversationId ?? Guid.NewGuid().ToString();
var sessionId = Guid.NewGuid().ToString();
sessionActivity?
.SetTag("agent.name", "OpenTelemetryDemoAgent")
.SetTag("session.id", sessionId)
@@ -153,9 +153,9 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output)
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id, cancellationToken);
// If a thread is provided, delete it as well.
if (thread is not null)
if (thread is ChatClientAgentThread chatThread)
{
await persistentAgentsClient.Threads.DeleteThreadAsync(thread.ConversationId, cancellationToken);
await persistentAgentsClient.Threads.DeleteThreadAsync(chatThread.ConversationId, cancellationToken);
}
}
@@ -169,9 +169,9 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output)
await assistantClient.DeleteAssistantAsync(agent.Id, cancellationToken);
// If a thread is provided, delete it as well.
if (thread is not null)
if (thread is ChatClientAgentThread chatThread)
{
await assistantClient.DeleteThreadAsync(thread.ConversationId, cancellationToken);
await assistantClient.DeleteThreadAsync(chatThread.ConversationId, cancellationToken);
}
}
@@ -36,6 +36,5 @@ AgentThread thread = agent1.GetNewThread();
Console.WriteLine(await agent1.RunAsync("Tell me a joke about a pirate.", thread));
// Cleanup for sample purposes.
await persistentAgentsClient.Threads.DeleteThreadAsync(thread.ConversationId);
await persistentAgentsClient.Administration.DeleteAgentAsync(agent1.Id);
await persistentAgentsClient.Administration.DeleteAgentAsync(agent2.Id);
@@ -8,6 +8,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -30,6 +31,12 @@ namespace SampleApp
// Custom agent that parrot's the user input back in upper case.
internal sealed class UpperCaseParrotAgent : AIAgent
{
public override AgentThread GetNewThread()
=> new CustomAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new CustomAgentThread(serializedThread, jsonSerializerOptions);
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
// Create a thread if the user didn't supply one.
@@ -96,5 +103,17 @@ namespace SampleApp
return messageClone;
});
/// <summary>
/// A thread type for our custom agent that only supports in memory storage of messages.
/// </summary>
internal sealed class CustomAgentThread : InMemoryAgentThread
{
internal CustomAgentThread()
: base() { }
internal CustomAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions) { }
}
}
}
@@ -37,6 +37,5 @@ AgentThread thread = agent1.GetNewThread();
Console.WriteLine(await agent1.RunAsync("Tell me a joke about a pirate.", thread));
// Cleanup for sample purposes.
await assistantClient.DeleteThreadAsync(thread.ConversationId);
await assistantClient.DeleteAssistantAsync(agent1.Id);
await assistantClient.DeleteAssistantAsync(agent2.Id);
@@ -38,12 +38,12 @@ AIAgent agent = new AzureOpenAIClient(
{
Name = JokerName,
Instructions = JokerInstructions,
ChatMessageStoreFactory = (jsonElement, jso) =>
ChatMessageStoreFactory = ctx =>
{
// Create a new chat message store for this agent that stores the messages in a vector store.
// Each thread must get its own copy of the VectorChatMessageStore, since the store
// also contains the id that the thread is stored under.
return new VectorChatMessageStore(vectorStore, jsonElement, jso);
return new VectorChatMessageStore(vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions);
}
});
@@ -38,7 +38,7 @@ ChatClient chatClient = new AzureOpenAIClient(
AIAgent agent = chatClient.CreateAIAgent(new ChatClientAgentOptions()
{
Instructions = "You are a friendly assistant. Always address the user by their name.",
AIContextProviderFactory = (jse, jso) => new UserInfoMemory(chatClient.AsIChatClient(), jse, jso)
AIContextProviderFactory = ctx => new UserInfoMemory(chatClient.AsIChatClient(), ctx.SerializedState, ctx.JsonSerializerOptions)
});
// Create a new thread for the conversation.
@@ -63,7 +63,7 @@ Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedT
Console.WriteLine("\n>> Read memories from memory component\n");
// It's possible to access the memory component via the thread's AIContextProvider property.
var userInfo = ((UserInfoMemory)deserializedThread.AIContextProvider!).UserInfo;
var userInfo = ((deserializedThread as ChatClientAgentThread)!.AIContextProvider as UserInfoMemory)!.UserInfo;
// Output the user info that was captured by the memory component.
Console.WriteLine($"MEMORY - User Name: {userInfo.UserName}");
@@ -71,18 +71,15 @@ Console.WriteLine($"MEMORY - User Age: {userInfo.UserAge}");
Console.WriteLine("\n>> Use new thread with previously created memories\n");
// Create a new thread.
thread = agent.GetNewThread();
// It is also possible to add the memory component to an individual thread only instead of all
// threads via the factory above.
// In this case we will also use the same user info object, so this thread will share the same
// memories as the previous thread.
thread.AIContextProvider = new UserInfoMemory(chatClient.AsIChatClient(), userInfo);
// It is also possible to set the memories in a memory component on an individual thread.
// This is useful if we want to start a new thread, but have it share the same memories as a previous thread.
// For this scenario, we have to know the underlying agent thread type and the memory component type.
var newThread = agent.GetNewThread();
((newThread as ChatClientAgentThread)!.AIContextProvider as UserInfoMemory)!.UserInfo = userInfo;
// Invoke the agent and output the text result.
// This time the agent should remember the user's name and use it in the response.
Console.WriteLine(await agent.RunAsync("What is my name and age?", thread));
Console.WriteLine(await agent.RunAsync("What is my name and age?", newThread));
namespace SampleApp
{
@@ -102,7 +99,10 @@ namespace SampleApp
public UserInfoMemory(IChatClient chatClient, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._chatClient = chatClient;
this.UserInfo = serializedState.Deserialize<UserInfo>(jsonSerializerOptions) ?? new UserInfo();
this.UserInfo = serializedState.ValueKind == JsonValueKind.Object ?
serializedState.Deserialize<UserInfo>(jsonSerializerOptions)! :
new UserInfo();
}
public UserInfo UserInfo { get; set; }
@@ -71,6 +71,9 @@ async Task AFAgentAsync()
}
// Clean up
await azureAgentClient.Threads.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId);
}
await azureAgentClient.Administration.DeleteAgentAsync(agent.Id);
}
@@ -80,6 +80,9 @@ async Task AFAgentAsync()
}
// Clean up
await azureAgentClient.Threads.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId);
}
await azureAgentClient.Administration.DeleteAgentAsync(agent.Id);
}
@@ -93,6 +93,9 @@ async Task AFAgentAsync()
// Clean up
var azureAgentClient = serviceProvider.GetRequiredService<PersistentAgentsClient>();
await azureAgentClient.Threads.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId);
}
await azureAgentClient.Administration.DeleteAgentAsync(agent.Id);
}
@@ -4,6 +4,7 @@ using System.Text;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.AzureAI;
@@ -116,6 +117,9 @@ async Task AFAgentAsync()
}
// Clean up
await azureAgentClient.Threads.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId);
}
await azureAgentClient.Administration.DeleteAgentAsync(agent.Id);
}
@@ -71,6 +71,9 @@ async Task AFAgentAsync()
}
// Clean up
await assistantClient.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await assistantClient.DeleteThreadAsync(chatThread.ConversationId);
}
await assistantClient.DeleteAssistantAsync(agent.Id);
}
@@ -90,6 +90,9 @@ async Task AFAgentAsync()
}
// Clean up
await assistantClient.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await assistantClient.DeleteThreadAsync(chatThread.ConversationId);
}
await assistantClient.DeleteAssistantAsync(agent.Id);
}
@@ -90,6 +90,9 @@ async Task AFAgentAsync()
// Clean up
var assistantClient = serviceProvider.GetRequiredService<AssistantClient>();
await assistantClient.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await assistantClient.DeleteThreadAsync(chatThread.ConversationId);
}
await assistantClient.DeleteAssistantAsync(agent.Id);
}
@@ -2,6 +2,7 @@
using System.Text;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Agents;
using Microsoft.SemanticKernel.Agents.OpenAI;
@@ -120,6 +121,9 @@ async Task AFAgentAsync()
}
// Clean up
await assistantsClient.DeleteThreadAsync(thread.ConversationId);
if (thread is ChatClientAgentThread chatThread)
{
await assistantsClient.DeleteThreadAsync(chatThread.ConversationId);
}
await assistantsClient.DeleteAssistantAsync(agent.Id);
}
@@ -63,6 +63,14 @@ public abstract partial class OrchestratingAgent : AIAgent
/// </summary>
public Func<AgentRunResponseUpdate, ValueTask>? StreamingResponseCallback { get; set; }
/// <inheritdoc/>
public override AgentThread GetNewThread()
=> new OrchestratingAgentThread();
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new OrchestratingAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc />
public sealed override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
@@ -71,12 +79,17 @@ public abstract partial class OrchestratingAgent : AIAgent
if (thread is not null)
{
if (thread.MessageStore is null)
if (thread is not OrchestratingAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
if (typedThread.MessageStore is null)
{
throw new InvalidOperationException("An agent service managed thread is not supported by this agent.");
}
List<ChatMessage> messagesList = (await thread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)).ToList();
List<ChatMessage> messagesList = (await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)).ToList();
messagesList.AddRange(messages);
messages = messagesList;
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI.Agents;
namespace Microsoft.Agents.Orchestration;
/// <summary>
/// The thread implementation used by <see cref="OrchestratingAgent"/>.
/// </summary>
internal sealed class OrchestratingAgentThread : InMemoryAgentThread
{
internal OrchestratingAgentThread()
: base() { }
internal OrchestratingAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions) { }
}
@@ -53,7 +53,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
Instructions = additionalInstructions,
});
AgentThread agentThread = new() { ConversationId = conversationId };
AgentThread agentThread = conversationId is not null && agent is ChatClientAgent chatClientAgent ? chatClientAgent.GetNewThread(conversationId) : agent.GetNewThread();
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates =
inputMessages is not null ?
agent.RunStreamingAsync([.. inputMessages.ToChatMessages()], agentThread, options, cancellationToken) :
@@ -56,7 +56,7 @@ internal sealed class AIAgentHostExecutor : Executor
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey).ConfigureAwait(false);
if (threadValue.HasValue)
{
this._thread = this._agent.DeserializeThread(threadValue.Value, cancellationToken: cancellation);
this._thread = this._agent.DeserializeThread(threadValue.Value);
}
JsonElement? messagesValue = await context.ReadStateAsync<JsonElement?>(PendingMessagesStateKey).ConfigureAwait(false);
@@ -48,7 +48,7 @@ internal sealed class WorkflowHostAgent : AIAgent
public override AgentThread GetNewThread() => new WorkflowThread(this.Id, this.Name, this.GenerateNewId());
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new WorkflowThread(serializedThread, jsonSerializerOptions);
private async
@@ -14,7 +14,7 @@ internal sealed class WorkflowThread : AgentThread
{
public WorkflowThread(string workflowId, string? workflowName, string runId)
{
base.MessageStore = this.MessageStore = new();
this.MessageStore = new();
this.RunId = Throw.IfNullOrEmpty(runId, nameof(runId));
}
@@ -46,5 +46,5 @@ internal sealed class WorkflowThread : AgentThread
}
/// <inheritdoc/>
public new WorkflowMessageStore MessageStore { get; }
public WorkflowMessageStore MessageStore { get; }
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
@@ -51,6 +52,22 @@ internal sealed class A2AAgent : AIAgent
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
/// <inheritdoc/>
public override sealed AgentThread GetNewThread()
=> new A2AAgentThread();
/// <summary>
/// Get a new <see cref="AgentThread"/> instance using an existing context id, to continue that conversation.
/// </summary>
/// <param name="contextId">The context id to continue.</param>
/// <returns>A new <see cref="AgentThread"/> instance.</returns>
public AgentThread GetNewThread(string contextId)
=> new A2AAgentThread() { ContextId = contextId };
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new A2AAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
@@ -58,8 +75,14 @@ internal sealed class A2AAgent : AIAgent
var a2aMessage = messages.ToA2AMessage();
thread ??= this.GetNewThread();
if (thread is not A2AAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
// Linking the message to the existing conversation, if any.
a2aMessage.ContextId = thread?.ConversationId;
a2aMessage.ContextId = typedThread.ContextId;
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
@@ -69,7 +92,7 @@ internal sealed class A2AAgent : AIAgent
if (a2aResponse is Message message)
{
UpdateThreadConversationId(thread, message.ContextId);
UpdateThreadConversationId(typedThread, message.ContextId);
return new AgentRunResponse
{
@@ -82,7 +105,7 @@ internal sealed class A2AAgent : AIAgent
}
if (a2aResponse is AgentTask agentTask)
{
UpdateThreadConversationId(thread, agentTask.ContextId);
UpdateThreadConversationId(typedThread, agentTask.ContextId);
return new AgentRunResponse
{
@@ -104,8 +127,14 @@ internal sealed class A2AAgent : AIAgent
var a2aMessage = messages.ToA2AMessage();
thread ??= this.GetNewThread();
if (thread is not A2AAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
// Linking the message to the existing conversation, if any.
a2aMessage.ContextId = thread?.ConversationId;
a2aMessage.ContextId = typedThread.ContextId;
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
@@ -120,7 +149,7 @@ internal sealed class A2AAgent : AIAgent
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}");
}
UpdateThreadConversationId(thread, message.ContextId);
UpdateThreadConversationId(typedThread, message.ContextId);
yield return new AgentRunResponseUpdate
{
@@ -160,7 +189,7 @@ internal sealed class A2AAgent : AIAgent
}
}
private static void UpdateThreadConversationId(AgentThread? thread, string? contextId)
private static void UpdateThreadConversationId(A2AAgentThread? thread, string? contextId)
{
if (thread is null)
{
@@ -169,13 +198,13 @@ internal sealed class A2AAgent : AIAgent
// Surface cases where the A2A agent responds with a response that
// has a different context Id than the thread's conversation Id.
if (thread.ConversationId is not null && contextId is not null && thread.ConversationId != contextId)
if (thread.ContextId is not null && contextId is not null && thread.ContextId != contextId)
{
throw new InvalidOperationException(
$"The {nameof(contextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
}
// Assign a server-generated context Id to the thread if it's not already set.
thread.ConversationId ??= contextId;
thread.ContextId ??= contextId;
}
}
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.A2A;
/// <summary>
/// Thread for A2A based agents.
/// </summary>
public sealed class A2AAgentThread : ServiceIdAgentThread
{
internal A2AAgentThread()
{
}
internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions)
{
}
/// <summary>
/// Gets the ID for the current conversation with the A2A agent.
/// </summary>
public string? ContextId
{
get { return this.ServiceThreadId; }
internal set { this.ServiceThreadId = value; }
}
}
@@ -81,17 +81,15 @@ public abstract class AIAgent
/// If the thread needs to be created via a service call it would be created on first use.
/// </para>
/// </remarks>
public virtual AgentThread GetNewThread() => new();
public abstract AgentThread GetNewThread();
/// <summary>
/// Deserialize the thread from JSON.
/// </summary>
/// <param name="serializedThread">The <see cref="JsonElement"/> representing the thread state.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> to use for deserializing the thread state.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The deserialized <see cref="AgentThread"/> instance.</returns>
public virtual AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(serializedThread, jsonSerializerOptions);
public abstract AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null);
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
@@ -57,7 +57,8 @@ public static partial class AgentAbstractionsJsonUtilities
[JsonSerializable(typeof(AgentRunResponse[]))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
[JsonSerializable(typeof(AgentRunResponseUpdate[]))]
[JsonSerializable(typeof(AgentThread.ThreadState))]
[JsonSerializable(typeof(ServiceIdAgentThread.ServiceIdAgentThreadState))]
[JsonSerializable(typeof(InMemoryAgentThread.InMemoryAgentThreadState))]
[JsonSerializable(typeof(InMemoryChatMessageStore.StoreState))]
[ExcludeFromCodeCoverage]
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -14,167 +13,23 @@ namespace Microsoft.Extensions.AI.Agents;
/// Base abstraction for all agent threads.
/// A thread represents a specific conversation with an agent.
/// </summary>
public class AgentThread
public abstract class AgentThread
{
private string? _conversationId;
private IChatMessageStore? _messageStore;
/// <summary>
/// Initializes a new instance of the <see cref="AgentThread"/> class.
/// </summary>
public AgentThread()
protected AgentThread()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentThread"/> class from serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="chatMessageStoreFactory">An optional factory function to create a custom <see cref="IChatMessageStore"/>.</param>
/// <param name="aiContextProviderFactory">An optional factory function to create a custom <see cref="AIContextProvider"/>.</param>
public AgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, IChatMessageStore>? chatMessageStoreFactory = null,
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = null)
{
if (serializedThreadState.ValueKind is not JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = serializedThreadState.Deserialize(
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState;
this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions);
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;
}
this._messageStore = chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions);
// If we didn't get a custom store, create an in-memory one.
this._messageStore ??= new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions);
}
/// <summary>
/// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="MessageStore "/> is not null, and <see cref="ConversationId"/> is set, <see cref="MessageStore "/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages via the <see cref="IChatMessageStore"/> and not in the agent service.</item>
/// <item>This thread object is new and a server managed thread has not yet been created in the agent service.</item>
/// </list>
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// Gets or sets the <see cref="IChatMessageStore"/> used by this thread, for cases where messages should be stored in a custom location.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="ConversationId"/> is not null, and <see cref="MessageStore "/> is set, <see cref="ConversationId"/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages in the agent service and just has an id to the remove thread, instead of in an <see cref="IChatMessageStore"/>.</item>
/// <item>This thread object is new it is not yet clear whether it will be backed by a server managed thread or an <see cref="IChatMessageStore"/>.</item>
/// </list>
/// </para>
/// </remarks>
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);
}
}
/// <summary>
/// Gets or sets the <see cref="AIContextProvider"/> used by this thread to provide additional context to the AI model before each invocation.
/// </summary>
public AIContextProvider? AIContextProvider { get; set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public virtual async Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
var storeState = this._messageStore is null ?
null :
await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var aiContextProviderState = this.AIContextProvider is null ?
null :
await this.AIContextProvider.SerializeAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var state = new ThreadState
{
ConversationId = this.ConversationId,
StoreState = storeState,
AIContextProviderState = aiContextProviderState
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState)));
}
public virtual Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> Task.FromResult(default(JsonElement));
/// <summary>
/// This method is called when new messages have been contributed to the chat by any participant.
@@ -186,38 +41,36 @@ public class AgentThread
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been updated.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
protected internal virtual async Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
protected internal virtual Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <summary>Asks the <see cref="AgentThread"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the <see cref="AgentThread"/>,
/// including itself or any services it might be wrapping. For example, to access the <see cref="AgentThreadMetadata"/> for the instance,
/// <see cref="GetService"/> may be used to request it.
/// </remarks>
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
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;
_ = Throw.IfNull(serviceType);
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();
}
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: null;
}
internal sealed class ThreadState
{
public string? ConversationId { get; set; }
public JsonElement? StoreState { get; set; }
public JsonElement? AIContextProviderState { get; set; }
}
/// <summary>Asks the <see cref="AgentThread"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="AgentThread"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public TService? GetService<TService>(object? serviceKey = null)
=> this.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Provides metadata about an <see cref="AgentThread"/>.</summary>
public class AgentThreadMetadata
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentThreadMetadata"/> class.
/// </summary>
/// <param name="conversationId">The unique identifier for the conversation, if available.</param>
public AgentThreadMetadata(string? conversationId)
{
ConversationId = conversationId;
}
/// <summary>
/// Gets the unique identifier for the conversation, if available.
/// </summary>
/// <remarks>
/// The meaning of this ID may vary depending on the agent implementation.
/// </remarks>
public string? ConversationId { get; }
}
@@ -54,8 +54,8 @@ public class DelegatingAIAgent : AIAgent
public override AgentThread GetNewThread() => this.InnerAgent.GetNewThread();
/// <inheritdoc />
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions, cancellationToken);
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc />
public override Task<AgentRunResponse> RunAsync(
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// A base class for agent threads that operate entirely in memory without external storage.
/// </summary>
public abstract class InMemoryAgentThread : AgentThread
{
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryAgentThread"/> class.
/// </summary>
/// <param name="messageStore">An optional <see cref="InMemoryChatMessageStore"/> to use for storing chat messages. If null, a new instance will be created.</param>
protected InMemoryAgentThread(InMemoryChatMessageStore? messageStore = null)
{
this.MessageStore = messageStore ?? new InMemoryChatMessageStore();
}
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryAgentThread"/> class with the specified initial messages.
/// </summary>
/// <param name="messages">The messages to initialize the thread with.</param>
protected InMemoryAgentThread(IEnumerable<ChatMessage> messages)
{
this.MessageStore = new InMemoryChatMessageStore();
foreach (var message in messages)
{
this.MessageStore.Add(message);
}
}
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryAgentThread"/> class from serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="messageStoreFactory">A factory function to create the <see cref="InMemoryChatMessageStore"/> from its serialized state.</param>
/// <exception cref="ArgumentException">The <paramref name="serializedThreadState"/> is not a JSON object.</exception>
/// <exception cref="JsonException">The <paramref name="serializedThreadState"/> is invalid or cannot be deserialized to the expected type.</exception>
protected InMemoryAgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, InMemoryChatMessageStore>? messageStoreFactory = null)
{
if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = JsonSerializer.Deserialize(
serializedThreadState,
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentThreadState))) as InMemoryAgentThreadState;
this.MessageStore =
messageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions) ??
new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions);
}
/// <summary>
/// Gets or sets the <see cref="InMemoryChatMessageStore"/> used by this thread.
/// </summary>
public InMemoryChatMessageStore MessageStore { get; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public override async Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
var storeState = await this.MessageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var state = new InMemoryAgentThreadState
{
StoreState = storeState,
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InMemoryAgentThreadState)));
}
/// <inheritdoc />
protected internal override Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
=> this.MessageStore.AddMessagesAsync(newMessages, cancellationToken);
internal sealed class InMemoryAgentThreadState
{
public JsonElement? StoreState { get; set; }
}
}
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>
/// A base class for agent threads that always store conversation state in the service, and only keep an ID reference in the <see cref="AgentThread"/>.
/// </summary>
public abstract class ServiceIdAgentThread : AgentThread
{
/// <summary>
/// Initializes a new instance of the <see cref="ServiceIdAgentThread"/> class.
/// </summary>
protected ServiceIdAgentThread()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ServiceIdAgentThread"/> class with the specified service thread ID.
/// </summary>
/// <param name="serviceThreadId">The ID that the conversation state is stored under in the service.</param>
protected ServiceIdAgentThread(string serviceThreadId)
{
this.ServiceThreadId = Throw.IfNullOrEmpty(serviceThreadId);
}
/// <summary>
/// Initializes a new instance of the <see cref="ServiceIdAgentThread"/> class from serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <exception cref="ArgumentException">The <paramref name="serializedThreadState"/> is not a JSON object.</exception>
/// <exception cref="JsonException">The <paramref name="serializedThreadState"/> is invalid or cannot be deserialized to the expected type.</exception>
protected ServiceIdAgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null)
{
if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = JsonSerializer.Deserialize(
serializedThreadState,
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentThreadState))) as ServiceIdAgentThreadState;
if (state?.ServiceThreadId is string serviceThreadId)
{
this.ServiceThreadId = serviceThreadId;
}
}
/// <summary>
/// Gets the ID that the conversation state is stored under in the service.
/// </summary>
protected string? ServiceThreadId { get; set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public override async Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
var state = new ServiceIdAgentThreadState
{
ServiceThreadId = this.ServiceThreadId,
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ServiceIdAgentThreadState)));
}
internal sealed class ServiceIdAgentThreadState
{
public string? ServiceThreadId { get; set; }
}
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.CopilotStudio.Client;
@@ -39,6 +40,22 @@ public class CopilotStudioAgent : AIAgent
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<CopilotStudioAgent>();
}
/// <inheritdoc/>
public override sealed AgentThread GetNewThread()
=> new CopilotStudioAgentThread();
/// <summary>
/// Get a new <see cref="AgentThread"/> instance using an existing conversation id, to continue that conversation.
/// </summary>
/// <param name="conversationId">The conversation id to continue.</param>
/// <returns>A new <see cref="AgentThread"/> instance.</returns>
public AgentThread GetNewThread(string conversationId)
=> new CopilotStudioAgentThread() { ConversationId = conversationId };
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
@@ -51,11 +68,16 @@ public class CopilotStudioAgent : AIAgent
// Ensure that we have a valid thread to work with.
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
thread ??= this.GetNewThread();
thread.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
if (thread is not CopilotStudioAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
typedThread.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, thread.ConversationId, cancellationToken), streaming: false, this._logger);
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedThread.ConversationId, cancellationToken), streaming: false, this._logger);
var responseMessagesList = new List<ChatMessage>();
await foreach (var message in responseMessages.ConfigureAwait(false))
{
@@ -84,11 +106,16 @@ public class CopilotStudioAgent : AIAgent
// Ensure that we have a valid thread to work with.
// If the thread ID is null, we need to start a new conversation and set the thread ID accordingly.
thread ??= this.GetNewThread();
thread.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
if (thread is not CopilotStudioAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
typedThread.ConversationId ??= await this.StartNewConversationAsync(cancellationToken).ConfigureAwait(false);
// Invoke the Copilot Studio agent with the provided messages.
string question = string.Join("\n", messages.Select(m => m.Text));
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, thread.ConversationId, cancellationToken), streaming: true, this._logger);
var responseMessages = ActivityProcessor.ProcessActivityAsync(this.Client.AskQuestionAsync(question, typedThread.ConversationId, cancellationToken), streaming: true, this._logger);
// Enumerate the response messages
await foreach (ChatMessage message in responseMessages.ConfigureAwait(false))
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.CopilotStudio;
/// <summary>
/// Thread for CopilotStudio based agents.
/// </summary>
public sealed class CopilotStudioAgentThread : ServiceIdAgentThread
{
internal CopilotStudioAgentThread()
{
}
internal CopilotStudioAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions)
{
}
/// <summary>
/// Gets the ID for the current conversation with the Copilot Studio agent.
/// </summary>
public string? ConversationId
{
get { return this.ServiceThreadId; }
internal set { this.ServiceThreadId = value; }
}
}
@@ -37,7 +37,7 @@ internal sealed class AgentActor(
if (response.Results[0] is GetValueResult { Value: { } threadJson })
{
// Deserialize the thread state if it exists
this._thread = agent.DeserializeThread(threadJson, cancellationToken: cancellationToken);
this._thread = agent.DeserializeThread(threadJson);
hasExistingThread = true;
}
@@ -37,15 +37,15 @@ public sealed class AgentProxy : AIAgent
public override AgentThread GetNewThread() => new AgentProxyThread();
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new AgentProxyThread(serializedThread, jsonSerializerOptions);
/// <summary>
/// Gets a thread by its <see cref="AgentThread.ConversationId"/>.
/// Gets a thread by its <see cref="AgentProxyThread.ConversationId"/>.
/// </summary>
/// <param name="conversationId">The thread identifier.</param>
/// <returns>The thread.</returns>
public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId);
public AgentThread GetNewThread(string conversationId) => new AgentProxyThread(conversationId);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Hosting;
/// <summary>
/// Represents an agent thread for a <see cref="AgentProxy"/>.
/// </summary>
internal sealed partial class AgentProxyThread : AgentThread
internal sealed partial class AgentProxyThread : ServiceIdAgentThread
{
#if NET7_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.StringSyntax("Regex")]
@@ -42,7 +42,7 @@ internal sealed partial class AgentProxyThread : AgentThread
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
/// </summary>
/// <param name="id">The unique identifier for the agent proxy thread.</param>
public AgentProxyThread(string id)
internal AgentProxyThread(string id)
{
Throw.IfNullOrEmpty(id);
ValidateId(id);
@@ -52,7 +52,7 @@ internal sealed partial class AgentProxyThread : AgentThread
/// <summary>
/// Initializes a new instance of the <see cref="AgentProxyThread"/> class with the specified identifier.
/// </summary>
public AgentProxyThread() : this(CreateId())
internal AgentProxyThread() : this(CreateId())
{
}
@@ -61,11 +61,20 @@ internal sealed partial class AgentProxyThread : AgentThread
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
public AgentProxyThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
internal AgentProxyThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions)
{
}
/// <summary>
/// Gets the ID that the conversation state is stored under for the agent.
/// </summary>
public string? ConversationId
{
get => this.ServiceThreadId;
private set => this.ServiceThreadId = value;
}
internal static string CreateId() => Guid.NewGuid().ToString("N");
/// <summary>
@@ -84,8 +84,8 @@ public class OpenAIChatClientAgent : AIAgent
=> this._chatClientAgent.GetNewThread();
/// <inheritdoc/>
public sealed override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this._chatClientAgent.DeserializeThread(serializedThread, jsonSerializerOptions, cancellationToken);
public sealed override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> this._chatClientAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI.Agents;
/// <summary>Provides a collection of utility methods for working with JSON data in the context of agents.</summary>
public static partial class AgentJsonUtilities
{
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> singleton used as the default in JSON serialization operations.
/// </summary>
/// <remarks>
/// <para>
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
/// includes source generated contracts for all common exchange types contained in this library.
/// </para>
/// <para>
/// It additionally turns on the following settings:
/// <list type="number">
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// </list>
/// </para>
/// </remarks>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
/// <summary>
/// Creates default options to use for agents-related serialization.
/// </summary>
/// <returns>The configured options.</returns>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")]
private static JsonSerializerOptions CreateDefaultOptions()
{
// Copy the configuration from the source generated context.
JsonSerializerOptions options = new(JsonContext.Default.Options);
// Chain with all supported types from Microsoft.Extensions.AI.Abstractions.
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
// Keep in sync with CreateDefaultOptions above.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
// Agent abstraction types
[JsonSerializable(typeof(ChatClientAgentThread.ThreadState))]
[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
}
@@ -108,7 +108,7 @@ public sealed class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(AgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var agentName = this.GetLoggingAgentName();
@@ -157,7 +157,7 @@ public sealed class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(AgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
int messageCount = threadMessages.Count;
@@ -236,23 +236,53 @@ public sealed class ChatClientAgent : AIAgent
: this.ChatClient.GetService(serviceType, serviceKey));
/// <inheritdoc/>
public override AgentThread GetNewThread() =>
new()
public override AgentThread GetNewThread()
=> new ChatClientAgentThread
{
MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(default, null),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(default, null)
MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
};
/// <summary>
/// Get a new <see cref="AgentThread"/> instance using an existing conversation id, to continue that conversation.
/// </summary>
/// <param name="conversationId">The conversation id to continue.</param>
/// <returns>A new <see cref="AgentThread"/> instance.</returns>
/// <remarks>
/// Note that any <see cref="AgentThread"/> created with this method will only work with <see cref="ChatClientAgent"/> instances that support storing
/// chat history in the underlying service provided by the <see cref="IChatClient"/>.
/// </remarks>
public AgentThread GetNewThread(string conversationId)
=> new ChatClientAgentThread()
{
ConversationId = conversationId,
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
};
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(serializedThread, jsonSerializerOptions, this._agentOptions?.ChatMessageStoreFactory, this._agentOptions?.AIContextProviderFactory);
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
Func<JsonElement, JsonSerializerOptions?, IChatMessageStore>? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ?
null :
(jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = this._agentOptions?.AIContextProviderFactory is null ?
null :
(jse, jso) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
return new ChatClientAgentThread(
serializedThread,
jsonSerializerOptions,
chatMessageStoreFactory,
aiContextProviderFactory);
}
#region Private
/// <summary>
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfSuccessAsync(AgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
@@ -264,7 +294,7 @@ public sealed class ChatClientAgent : AIAgent
/// <summary>
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfFailureAsync(AgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
@@ -396,7 +426,7 @@ public sealed class ChatClientAgent : AIAgent
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(AgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> ThreadMessages)> PrepareThreadAndMessagesAsync(
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> ThreadMessages)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable<ChatMessage> inputMessages,
AgentRunOptions? runOptions,
@@ -405,20 +435,24 @@ public sealed class ChatClientAgent : AIAgent
ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
thread ??= this.GetNewThread();
if (thread is not ChatClientAgentThread typedThread)
{
throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used.");
}
// Add any existing messages from the thread to the messages to be sent to the chat client.
List<ChatMessage> threadMessages = [];
if (thread.MessageStore is not null)
if (typedThread.MessageStore is not null)
{
threadMessages.AddRange(await thread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
}
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
if (thread.AIContextProvider is not null)
if (typedThread.AIContextProvider is not null)
{
var invokingContext = new AIContextProvider.InvokingContext(inputMessages);
var aiContext = await thread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
{
threadMessages.AddRange(aiContext.Messages);
@@ -446,7 +480,7 @@ public sealed class ChatClientAgent : AIAgent
// If a user provided two different thread ids, via the thread object and options, we should throw
// since we don't know which one to use.
if (!string.IsNullOrWhiteSpace(thread.ConversationId) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && thread.ConversationId != chatOptions!.ConversationId)
if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && typedThread.ConversationId != chatOptions!.ConversationId)
{
throw new InvalidOperationException(
$"""
@@ -462,16 +496,16 @@ public sealed class ChatClientAgent : AIAgent
}
// Only create or update ChatOptions if we have an id on the thread and we don't have the same one already in ChatOptions.
if (!string.IsNullOrWhiteSpace(thread.ConversationId) && thread.ConversationId != chatOptions?.ConversationId)
if (!string.IsNullOrWhiteSpace(typedThread.ConversationId) && typedThread.ConversationId != chatOptions?.ConversationId)
{
chatOptions ??= new();
chatOptions.ConversationId = thread.ConversationId;
chatOptions.ConversationId = typedThread.ConversationId;
}
return (thread, chatOptions, threadMessages);
return (typedThread, chatOptions, threadMessages);
}
private void UpdateThreadWithTypeAndConversationId(AgentThread thread, string? responseConversationId)
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
{
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(thread.ConversationId))
{
@@ -491,7 +525,7 @@ public sealed class ChatClientAgent : AIAgent
// If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
// the thread has no MessageStore yet, and we have a custom messages store, we should update the thread
// with the custom MessageStore so that it has somewhere to store the chat history.
thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(default, null);
thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null });
}
}
@@ -79,14 +79,14 @@ public class ChatClientAgentOptions
/// Gets or sets a factory function to create an instance of <see cref="IChatMessageStore"/>
/// which will be used to store chat messages for this agent.
/// </summary>
public Func<JsonElement, JsonSerializerOptions?, IChatMessageStore>? ChatMessageStoreFactory { get; set; }
public Func<ChatMessageStoreFactoryContext, IChatMessageStore>? ChatMessageStoreFactory { get; set; }
/// <summary>
/// Gets or sets a factory function to create an instance of <see cref="AIContextProvider"/>
/// which will be used to create a context provider for each new thread, and can then
/// provide additional context for each agent run.
/// </summary>
public Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? AIContextProviderFactory { get; set; }
public Func<AIContextProviderFactoryContext, AIContextProvider>? AIContextProviderFactory { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
@@ -116,4 +116,38 @@ public class ChatClientAgentOptions
ChatMessageStoreFactory = this.ChatMessageStoreFactory,
AIContextProviderFactory = this.AIContextProviderFactory,
};
/// <summary>
/// Context object passed to the <see cref="AIContextProviderFactory"/> to create a new instance of <see cref="AIContextProvider"/>.
/// </summary>
public class AIContextProviderFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the <see cref="AIContextProvider"/>, if any.
/// </summary>
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="AIContextProvider"/> is first created.</value>
public JsonElement SerializedState { get; set; }
/// <summary>
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
}
/// <summary>
/// Context object passed to the <see cref="ChatMessageStoreFactory"/> to create a new instance of <see cref="IChatMessageStore"/>.
/// </summary>
public class ChatMessageStoreFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the chat message store, if any.
/// </summary>
/// <value><see langword="default"/> if there is no state, e.g. when the <see cref="IChatMessageStore"/> is first created.</value>
public JsonElement SerializedState { get; set; }
/// <summary>
/// Gets or sets the JSON serialization options to use when deserializing the <see cref="SerializedState"/>.
/// </summary>
public JsonSerializerOptions? JsonSerializerOptions { get; set; }
}
}
@@ -0,0 +1,224 @@
// 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;
/// <summary>
/// Thread for ChatClient based agents.
/// </summary>
public class ChatClientAgentThread : AgentThread
{
private string? _conversationId;
private IChatMessageStore? _messageStore;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentThread"/> class.
/// </summary>
internal ChatClientAgentThread()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentThread"/> class from serialized state.
/// </summary>
/// <param name="serializedThreadState">A <see cref="JsonElement"/> representing the serialized state of the thread.</param>
/// <param name="jsonSerializerOptions">Optional settings for customizing the JSON deserialization process.</param>
/// <param name="chatMessageStoreFactory">An optional factory function to create a custom <see cref="IChatMessageStore"/>.</param>
/// <param name="aiContextProviderFactory">An optional factory function to create a custom <see cref="AIContextProvider"/>.</param>
internal ChatClientAgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, IChatMessageStore>? chatMessageStoreFactory = null,
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = null)
{
if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = JsonSerializer.Deserialize(
serializedThreadState,
AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState;
this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions);
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;
}
this._messageStore = chatMessageStoreFactory?.Invoke(state?.StoreState ?? default, jsonSerializerOptions);
if (this._messageStore is null)
{
// If we didn't get a custom store, create an in-memory one.
this._messageStore = new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions);
}
}
/// <summary>
/// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="MessageStore "/> is not null, and <see cref="ConversationId"/> is set, <see cref="MessageStore "/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages via the <see cref="IChatMessageStore"/> and not in the agent service.</item>
/// <item>This thread object is new and a server managed thread has not yet been created in the agent service.</item>
/// </list>
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public string? ConversationId
{
get => this._conversationId;
internal 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);
}
}
/// <summary>
/// Gets or sets the <see cref="IChatMessageStore"/> used by this thread, for cases where messages should be stored in a custom location.
/// </summary>
/// <remarks>
/// <para>
/// Note that either <see cref="ConversationId"/> or <see cref="MessageStore "/> may be set, but not both.
/// If <see cref="ConversationId"/> is not null, and <see cref="MessageStore "/> is set, <see cref="ConversationId"/>
/// will be reverted to null, and vice versa.
/// </para>
/// <para>
/// This property may be null in the following cases:
/// <list type="bullet">
/// <item>The thread stores messages in the agent service and just has an id to the remove thread, instead of in an <see cref="IChatMessageStore"/>.</item>
/// <item>This thread object is new it is not yet clear whether it will be backed by a server managed thread or an <see cref="IChatMessageStore"/>.</item>
/// </list>
/// </para>
/// </remarks>
public IChatMessageStore? MessageStore
{
get => this._messageStore;
internal 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);
}
}
/// <summary>
/// Gets or sets the <see cref="AIContextProvider"/> used by this thread to provide additional context to the AI model before each invocation.
/// </summary>
public AIContextProvider? AIContextProvider { get; internal set; }
/// <summary>
/// Serializes the current object's state to a <see cref="JsonElement"/> using the specified serialization options.
/// </summary>
/// <param name="jsonSerializerOptions">The JSON serialization options to use.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="JsonElement"/> representation of the object's state.</returns>
public override async Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
var storeState = this._messageStore is null ?
null :
await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var aiContextProviderState = this.AIContextProvider is null ?
null :
await this.AIContextProvider.SerializeAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
var state = new ThreadState
{
ConversationId = this.ConversationId,
StoreState = storeState,
AIContextProviderState = aiContextProviderState
};
return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState)));
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
return serviceType == typeof(AgentThreadMetadata) ?
new AgentThreadMetadata(this.ConversationId) :
base.GetService(serviceType, serviceKey);
}
/// <inheritdoc />
protected override async Task MessagesReceivedAsync(IEnumerable<ChatMessage> 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();
}
}
internal sealed class ThreadState
{
public string? ConversationId { get; set; }
public JsonElement? StoreState { get; set; }
public JsonElement? AIContextProviderState { get; set; }
}
}
@@ -246,9 +246,10 @@ public sealed partial class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
// Add conversation ID if thread is available (following gen_ai.conversation.id convention)
if (!string.IsNullOrWhiteSpace(thread?.ConversationId))
var metadata = thread?.GetService<AgentThreadMetadata>();
if (!string.IsNullOrWhiteSpace(metadata?.ConversationId))
{
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, thread.ConversationId);
_ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, metadata.ConversationId);
}
// Add instructions if available (for ChatClientAgent)
@@ -27,9 +27,10 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
{
List<ChatMessage> messages = [];
var typedThread = (ChatClientAgentThread)thread;
await foreach (var threadMessage in (AsyncPageable<PersistentThreadMessage>)this._persistentAgentsClient.Messages.GetMessagesAsync(
threadId: thread.ConversationId, order: ListSortOrder.Ascending))
threadId: typedThread.ConversationId, order: ListSortOrder.Ascending))
{
var message = new ChatMessage
{
@@ -76,9 +77,10 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture
public Task DeleteThreadAsync(AgentThread thread)
{
if (thread?.ConversationId is not null)
var typedThread = (ChatClientAgentThread)thread;
if (typedThread?.ConversationId is not null)
{
return this._persistentAgentsClient.Threads.DeleteThreadAsync(thread.ConversationId);
return this._persistentAgentsClient.Threads.DeleteThreadAsync(typedThread.ConversationId);
}
return Task.CompletedTask;
@@ -1,11 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Moq;
namespace Microsoft.Agents.Orchestration.UnitTest;
@@ -27,7 +28,11 @@ internal sealed class MockAgent(int index) : AIAgent
public override string? Description => $"test {index}";
public override AgentThread GetNewThread() => new() { ConversationId = Guid.NewGuid().ToString() };
public override AgentThread GetNewThread()
=> new Mock<AgentThread>().Object;
public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null)
=> new Mock<AgentThread>().Object;
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
@@ -89,6 +89,10 @@ public class OrchestrationResultTests
private sealed class MockAgent : AIAgent
{
public override AgentThread GetNewThread()
=> throw new NotSupportedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotSupportedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -71,6 +72,12 @@ public class AgentWorkflowBuilderTests
private class DoubleEchoAgent(string name) : AIAgent
{
public override AgentThread GetNewThread()
=> new DoubleEchoAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new DoubleEchoAgentThread();
public override Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
@@ -89,6 +96,8 @@ public class AgentWorkflowBuilderTests
}
}
private sealed class DoubleEchoAgentThread() : InMemoryAgentThread();
[Fact]
public async Task BuildConcurrent_AgentsRunInParallelAsync()
{
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -22,6 +23,12 @@ public class RepresentationTests
private sealed class TestAgent : AIAgent
{
public override AgentThread GetNewThread()
=> throw new NotImplementedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
@@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -95,6 +96,12 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
public override string Id => id;
public override string? Name => id;
public override AgentThread GetNewThread()
=> new HelloAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new HelloAgentThread();
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
IEnumerable<AgentRunResponseUpdate> update = [
@@ -116,6 +123,8 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
}
}
internal sealed class HelloAgentThread() : InMemoryAgentThread();
internal sealed class EchoAgent(string id = nameof(EchoAgent)) : AIAgent
{
public const string Prefix = "You said: ";
@@ -124,6 +133,12 @@ internal sealed class EchoAgent(string id = nameof(EchoAgent)) : AIAgent
public override string Id => id;
public override string? Name => id;
public override AgentThread GetNewThread()
=> new EchoAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new EchoAgentThread();
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
IEnumerable<AgentRunResponseUpdate> update = [
@@ -159,6 +174,8 @@ internal sealed class EchoAgent(string id = nameof(EchoAgent)) : AIAgent
}
}
internal sealed class EchoAgentThread() : InMemoryAgentThread();
internal sealed class GroupChatHistory
{
private readonly List<ChatMessage> _messages = [];
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -49,6 +50,12 @@ public class SpecializedExecutorSmokeTests
return result;
}
public override AgentThread GetNewThread()
=> new TestAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new TestAgentThread();
public static TestAIAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
@@ -103,6 +110,8 @@ public class SpecializedExecutorSmokeTests
}
}
public sealed class TestAgentThread() : InMemoryAgentThread();
internal sealed class TestWorkflowContext : IWorkflowContext
{
public List<List<ChatMessage>> Updates { get; } = [];
@@ -155,7 +155,9 @@ public sealed class A2AAgentTests : IDisposable
await this._agent.RunAsync(inputMessages, thread);
// Assert
Assert.Equal("new-context-id", thread.ConversationId);
Assert.IsType<A2AAgentThread>(thread);
var a2aThread = (A2AAgentThread)thread;
Assert.Equal("new-context-id", a2aThread.ContextId);
}
[Fact]
@@ -168,7 +170,8 @@ public sealed class A2AAgentTests : IDisposable
};
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
// Act
await this._agent.RunAsync(inputMessages, thread);
@@ -200,7 +203,8 @@ public sealed class A2AAgentTests : IDisposable
};
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => this._agent.RunAsync(inputMessages, thread));
@@ -278,7 +282,8 @@ public sealed class A2AAgentTests : IDisposable
}
// Assert
Assert.Equal("new-stream-context", thread.ConversationId);
var a2aThread = (A2AAgentThread)thread;
Assert.Equal("new-stream-context", a2aThread.ContextId);
}
[Fact]
@@ -293,7 +298,8 @@ public sealed class A2AAgentTests : IDisposable
this._handler.StreamingResponseToReturn = new Message();
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
// Act
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, thread))
@@ -312,7 +318,8 @@ public sealed class A2AAgentTests : IDisposable
{
// Arrange
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
var inputMessages = new List<ChatMessage>
{
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Moq;
@@ -231,7 +232,6 @@ public class AIAgentTests
var threadMock = new Mock<TestAgentThread> { CallBase = true };
threadMock.SetupAllProperties();
threadMock.Object.ConversationId = "test-thread-id";
await MockAgent.NotifyThreadOfNewMessagesAsync(threadMock.Object, messages, cancellationToken);
@@ -361,7 +361,14 @@ public class AIAgentTests
private sealed class MockAgent : AIAgent
{
public static new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IEnumerable<ChatMessage> messages, CancellationToken cancellationToken) => AIAgent.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken);
public static new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IEnumerable<ChatMessage> messages, CancellationToken cancellationToken) =>
AIAgent.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken);
public override AgentThread GetNewThread()
=> throw new NotImplementedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.ObjectModel;
using System.Text.Json;
using System.Threading;
@@ -35,6 +36,18 @@ public class AIContextProviderTests
Assert.Equal(default, actual);
}
[Fact]
public void InvokingContext_Constructor_ThrowsForNullMessages()
{
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokingContext(null!));
}
[Fact]
public void InvokedContext_Constructor_ThrowsForNullMessages()
{
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!));
}
private sealed class TestAIContextProvider : AIContextProvider
{
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
@@ -2,335 +2,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Moq;
#pragma warning disable CA1861 // Avoid constant arrays as arguments
namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests;
/// <summary>
/// Tests for <see cref="AgentThread"/>
/// </summary>
public class AgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void ConstructorSetsDefaults()
public async Task SerializeAsync_ReturnsDefaultJsonElementAsync()
{
// Arrange & Act
var thread = new AgentThread();
// Assert
Assert.Null(thread.ConversationId);
Assert.Null(thread.MessageStore);
var thread = new TestAgentThread();
var result = await thread.SerializeAsync();
Assert.Equal(default, result);
}
[Fact]
public void SetConversationIdRoundtrips()
public void MessagesReceivedAsync_ReturnsCompletedTask()
{
// Arrange
var thread = new AgentThread();
const string Conversationid = "test-thread-id";
// Act
thread.ConversationId = Conversationid;
// Assert
Assert.Equal(Conversationid, thread.ConversationId);
Assert.Null(thread.MessageStore);
var thread = new TestAgentThread();
var messages = new List<ChatMessage> { new(ChatRole.User, "hello") };
var result = thread.MessagesReceivedAsync(messages);
Assert.True(result.IsCompleted);
}
[Fact]
public void SetChatMessageStoreRoundtrips()
{
// Arrange
var thread = new AgentThread();
var messageStore = new InMemoryChatMessageStore();
// Act
thread.MessageStore = messageStore;
// Assert
Assert.Same(messageStore, thread.MessageStore);
Assert.Null(thread.ConversationId);
}
[Fact]
public void SetConversationIdThrowsWhenMessageStoreIsSet()
{
// Arrange
var thread = new AgentThread
{
MessageStore = new InMemoryChatMessageStore()
};
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.ConversationId = "new-thread-id");
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreThrowsWhenConversationIdIsSet()
{
// Arrange
var thread = new AgentThread
{
ConversationId = "existing-thread-id"
};
var store = new InMemoryChatMessageStore();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.MessageStore = store);
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.ConversationId);
}
#endregion Constructor and Property Tests
#region OnNewMessagesAsync Tests
[Fact]
public async Task OnNewMessagesAsyncDoesNothingWhenAgentServiceIdAsync()
{
// Arrange
var thread = new AgentThread { ConversationId = "thread-123" };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
// Act
await thread.MessagesReceivedAsync(messages, CancellationToken.None);
Assert.Equal("thread-123", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task OnNewMessagesAsyncAddsMessagesToStoreAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var thread = new AgentThread { MessageStore = store };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
// Act
await thread.MessagesReceivedAsync(messages, CancellationToken.None);
// Assert
Assert.Equal(2, store.Count);
Assert.Equal("Hello", store[0].Text);
Assert.Equal("Hi there!", store[1].Text);
}
#endregion OnNewMessagesAsync Tests
#region Deserialize Tests
[Fact]
public async Task VerifyDeserializeConstructorWithMessagesAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"storeState": { "messages": [{"authorName": "testAuthor"}] }
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act.
var thread = new AgentThread(json);
// Assert
Assert.Null(thread.ConversationId);
var messageStore = thread.MessageStore as InMemoryChatMessageStore;
Assert.NotNull(messageStore);
Assert.Single(messageStore);
Assert.Equal("testAuthor", messageStore[0].AuthorName);
}
[Fact]
public async Task VerifyDeserializeConstructorWithIdAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"ConversationId": "TestConvId"
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var thread = new AgentThread(json);
// Assert
Assert.Equal("TestConvId", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task VerifyDeserializeConstructorWithAIContextProviderAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"ConversationId": "TestConvId",
"aiContextProviderState": ["CP1"]
}
""", TestJsonSerializerContext.Default.JsonElement);
Mock<AIContextProvider> mockProvider = new();
// Act
var thread = new AgentThread(json, aiContextProviderFactory: (_, _) => mockProvider.Object);
// Assert
Assert.Null(thread.MessageStore);
Assert.Same(thread.AIContextProvider, mockProvider.Object);
}
[Fact]
public async Task DeserializeContructorWithInvalidJsonThrowsAsync()
{
// Arrange
var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement);
// Act & Assert
Assert.Throws<ArgumentException>(() => new AgentThread(invalidJson));
}
#endregion Deserialize Tests
#region Serialize Tests
#region GetService Method Tests
/// <summary>
/// Verify thread serialization to JSON when the thread has an id.
/// Verify that GetService returns the thread itself when requesting the exact thread type.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithIdAsync()
public void GetService_RequestingExactThreadType_ReturnsThread()
{
// Arrange
var thread = new AgentThread { ConversationId = "TestConvId" };
var thread = new TestAgentThread();
// Act
var json = await thread.SerializeAsync();
var result = thread.GetService(typeof(TestAgentThread));
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
Assert.Equal("TestConvId", idProperty.GetString());
Assert.False(json.TryGetProperty("storeState", out _));
Assert.NotNull(result);
Assert.Same(thread, result);
}
/// <summary>
/// Verify thread serialization to JSON when the thread has messages.
/// Verify that GetService returns the thread itself when requesting the base AgentThread type.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithMessagesAsync()
public void GetService_RequestingAgentThreadType_ReturnsThread()
{
// Arrange
var store = new InMemoryChatMessageStore
{
new ChatMessage(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" }
};
var thread = new AgentThread { MessageStore = store };
var thread = new TestAgentThread();
// Act
var json = await thread.SerializeAsync();
var result = thread.GetService(typeof(AgentThread));
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out _));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Single(messagesProperty.EnumerateArray());
var message = messagesProperty.EnumerateArray().First();
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
Assert.Single(contentsProperty.EnumerateArray());
var textContent = contentsProperty.EnumerateArray().First();
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
}
[Fact]
public async Task VerifyThreadSerializationWithWithAIContextProviderAsync()
{
// Arrange
Mock<AIContextProvider> mockProvider = new();
var providerStateElement = JsonSerializer.SerializeToElement(["CP1"], TestJsonSerializerContext.Default.StringArray);
mockProvider
.Setup(m => m.SerializeAsync(It.IsAny<JsonSerializerOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(providerStateElement);
var thread = new AgentThread
{
AIContextProvider = mockProvider.Object
};
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("aiContextProviderState", out var providerStateProperty));
Assert.Equal(JsonValueKind.Array, providerStateProperty.ValueKind);
Assert.Single(providerStateProperty.EnumerateArray());
Assert.Equal("CP1", providerStateProperty.EnumerateArray().First().GetString());
mockProvider.Verify(m => m.SerializeAsync(It.IsAny<JsonSerializerOptions?>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(result);
Assert.Same(thread, result);
}
/// <summary>
/// Verify thread serialization to JSON with custom options.
/// Verify that GetService returns null when requesting an unrelated type.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithCustomOptionsAsync()
public void GetService_RequestingUnrelatedType_ReturnsNull()
{
// Arrange
var thread = new AgentThread();
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
var storeStateElement = JsonSerializer.SerializeToElement(
new Dictionary<string, object> { ["Key"] = "TestValue" },
TestJsonSerializerContext.Default.DictionaryStringObject);
var messageStoreMock = new Mock<IChatMessageStore>();
messageStoreMock
.Setup(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()))
.ReturnsAsync(storeStateElement);
thread.MessageStore = messageStoreMock.Object;
var thread = new TestAgentThread();
// Act
var json = await thread.SerializeAsync(options);
var result = thread.GetService(typeof(string));
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out var idProperty));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("Key", out var keyProperty));
Assert.Equal("TestValue", keyProperty.GetString());
messageStoreMock.Verify(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()), Times.Once);
Assert.Null(result);
}
#endregion Serialize Tests
/// <summary>
/// Verify that GetService returns null when a service key is provided, even for matching types.
/// </summary>
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var thread = new TestAgentThread();
// Act
var result = thread.GetService(typeof(TestAgentThread), "some-key");
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetService throws ArgumentNullException when serviceType is null.
/// </summary>
[Fact]
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
{
// Arrange
var thread = new TestAgentThread();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => thread.GetService(null!));
}
/// <summary>
/// Verify that GetService generic method works correctly.
/// </summary>
[Fact]
public void GetService_Generic_ReturnsCorrectType()
{
// Arrange
var thread = new TestAgentThread();
// Act
var result = thread.GetService<TestAgentThread>();
// Assert
Assert.NotNull(result);
Assert.Same(thread, result);
}
/// <summary>
/// Verify that GetService generic method returns null for unrelated types.
/// </summary>
[Fact]
public void GetService_Generic_ReturnsNullForUnrelatedType()
{
// Arrange
var thread = new TestAgentThread();
// Act
var result = thread.GetService<string>();
// Assert
Assert.Null(result);
}
#endregion
private sealed class TestAgentThread : AgentThread
{
protected internal override Task MessagesReceivedAsync(IEnumerable<ChatMessage> newMessages, CancellationToken cancellationToken = default)
=> base.MessagesReceivedAsync(newMessages, cancellationToken);
public override Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
}
@@ -27,7 +27,7 @@ public class DelegatingAIAgentTests
this._innerAgentMock = new Mock<AIAgent>();
this._testResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Test response"));
this._testStreamingResponses = [new AgentRunResponseUpdate(ChatRole.Assistant, "Test streaming response")];
this._testThread = new AgentThread();
this._testThread = new TestAgentThread();
// Setup inner agent mock
this._innerAgentMock.Setup(x => x.Id).Returns("test-agent-id");
@@ -149,7 +149,7 @@ public class DelegatingAIAgentTests
{
// Arrange
var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
var expectedThread = new AgentThread();
var expectedThread = new TestAgentThread();
var expectedOptions = new AgentRunOptions();
var expectedCancellationToken = new CancellationToken();
var expectedResult = new TaskCompletionSource<AgentRunResponse>();
@@ -180,7 +180,7 @@ public class DelegatingAIAgentTests
{
// Arrange
var expectedMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
var expectedThread = new AgentThread();
var expectedThread = new TestAgentThread();
var expectedOptions = new AgentRunOptions();
var expectedCancellationToken = new CancellationToken();
AgentRunResponseUpdate[] expectedResults =
@@ -300,5 +300,9 @@ public class DelegatingAIAgentTests
public new AIAgent InnerAgent => base.InnerAgent;
}
private sealed class TestAgentThread : AgentThread
{
}
#endregion
}
@@ -0,0 +1,144 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests;
/// <summary>
/// Contains tests for <see cref="InMemoryAgentThread"/>.
/// </summary>
public class InMemoryAgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void Constructor_SetsDefaultMessageStore()
{
// Arrange & Act
var thread = new TestInMemoryAgentThread();
// Assert
Assert.NotNull(thread.GetMessageStore());
Assert.Empty(thread.GetMessageStore());
}
[Fact]
public void Constructor_WithMessageStore_SetsProperty()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "Hello"));
// Act
var thread = new TestInMemoryAgentThread(store);
// Assert
Assert.Same(store, thread.GetMessageStore());
Assert.Single(thread.GetMessageStore());
Assert.Equal("Hello", thread.GetMessageStore()[0].Text);
}
[Fact]
public void Constructor_WithMessages_SetsProperty()
{
// Arrange
var messages = new List<ChatMessage> { new(ChatRole.User, "Hi") };
// Act
var thread = new TestInMemoryAgentThread(messages);
// Assert
Assert.NotNull(thread.GetMessageStore());
Assert.Single(thread.GetMessageStore());
Assert.Equal("Hi", thread.GetMessageStore()[0].Text);
}
[Fact]
public async Task Constructor_WithSerializedState_SetsPropertyAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "TestMsg"));
var storeState = await store.SerializeStateAsync();
var json = JsonSerializer.SerializeToElement(new { storeState });
// Act
var thread = new TestInMemoryAgentThread(json);
// Assert
Assert.NotNull(thread.GetMessageStore());
Assert.Single(thread.GetMessageStore());
Assert.Equal("TestMsg", thread.GetMessageStore()[0].Text);
}
[Fact]
public void Constructor_WithInvalidJson_ThrowsArgumentException()
{
// Arrange
var invalidJson = JsonSerializer.SerializeToElement(42);
// Act & Assert
Assert.Throws<ArgumentException>(() => new TestInMemoryAgentThread(invalidJson));
}
#endregion
#region SerializeAsync Tests
[Fact]
public async Task SerializeAsync_ReturnsCorrectJson_WhenMessagesExistAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "TestContent"));
var thread = new TestInMemoryAgentThread(store);
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
var messagesList = messagesProperty.EnumerateArray().ToList();
Assert.Single(messagesList);
}
[Fact]
public async Task SerializeAsync_ReturnsEmptyMessages_WhenNoMessagesAsync()
{
// Arrange
var thread = new TestInMemoryAgentThread();
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Empty(messagesProperty.EnumerateArray());
}
#endregion
// Sealed test subclass to expose protected members for testing
private sealed class TestInMemoryAgentThread : InMemoryAgentThread
{
public TestInMemoryAgentThread() : base() { }
public TestInMemoryAgentThread(InMemoryChatMessageStore? store) : base(store) { }
public TestInMemoryAgentThread(IEnumerable<ChatMessage> messages) : base(messages) { }
public TestInMemoryAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { }
public InMemoryChatMessageStore GetMessageStore() => this.MessageStore;
public override Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
}
@@ -149,7 +149,7 @@ public class InMemoryChatMessageStoreTests
{
// Arrange
var stateWithEmptyMessages = JsonSerializer.SerializeToElement(
new Dictionary<string, object> { ["Messages"] = new List<ChatMessage>() },
new Dictionary<string, object> { ["messages"] = new List<ChatMessage>() },
TestJsonSerializerContext.Default.IDictionaryStringObject);
// Act
@@ -164,7 +164,7 @@ public class InMemoryChatMessageStoreTests
{
// Arrange
var stateWithNullMessages = JsonSerializer.SerializeToElement(
new Dictionary<string, object> { ["Messages"] = null! },
new Dictionary<string, object> { ["messages"] = null! },
TestJsonSerializerContext.Default.DictionaryStringObject);
// Act
@@ -183,7 +183,7 @@ public class InMemoryChatMessageStoreTests
new(ChatRole.User, "User message"),
new(ChatRole.Assistant, "Assistant message")
};
var state = new Dictionary<string, object> { ["Messages"] = messages };
var state = new Dictionary<string, object> { ["messages"] = messages };
var serializedState = JsonSerializer.SerializeToElement(
state,
TestJsonSerializerContext.Default.DictionaryStringObject);
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests;
/// <summary>
/// Tests for <see cref="ServiceIdAgentThread"/>.
/// </summary>
public class ServiceIdAgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void Constructor_SetsDefaults()
{
// Arrange & Act
var thread = new TestServiceIdAgentThread();
// Assert
Assert.Null(thread.GetServiceThreadId());
}
[Fact]
public void Constructor_WithServiceThreadId_SetsProperty()
{
// Arrange & Act
var thread = new TestServiceIdAgentThread("service-id-123");
// Assert
Assert.Equal("service-id-123", thread.GetServiceThreadId());
}
[Fact]
public void Constructor_WithSerializedId_SetsProperty()
{
// Arrange
var json = JsonSerializer.SerializeToElement(new { ServiceThreadId = "service-id-456" });
// Act
var thread = new TestServiceIdAgentThread(json);
// Assert
Assert.Equal("service-id-456", thread.GetServiceThreadId());
}
[Fact]
public void Constructor_WithSerializedUndefinedId_SetsProperty()
{
// Arrange
var json = JsonSerializer.SerializeToElement(new { });
// Act
var thread = new TestServiceIdAgentThread(json);
// Assert
Assert.Null(thread.GetServiceThreadId());
}
[Fact]
public void Constructor_WithInvalidJson_ThrowsArgumentException()
{
// Arrange
var invalidJson = JsonSerializer.SerializeToElement(42);
// Act & Assert
Assert.Throws<ArgumentException>(() => new TestServiceIdAgentThread(invalidJson));
}
#endregion
#region SerializeAsync Tests
[Fact]
public async Task SerializeAsync_ReturnsCorrectJson_WhenServiceThreadIdIsSetAsync()
{
// Arrange
var thread = new TestServiceIdAgentThread("service-id-789");
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("serviceThreadId", out var idProperty));
Assert.Equal("service-id-789", idProperty.GetString());
}
[Fact]
public async Task SerializeAsync_ReturnsUndefinedServiceThreadId_WhenNotSetAsync()
{
// Arrange
var thread = new TestServiceIdAgentThread();
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("serviceThreadId", out _));
}
#endregion
// Sealed test subclass to expose protected members for testing
private sealed class TestServiceIdAgentThread : ServiceIdAgentThread
{
public TestServiceIdAgentThread() : base() { }
public TestServiceIdAgentThread(string serviceThreadId) : base(serviceThreadId) { }
public TestServiceIdAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { }
public string? GetServiceThreadId() => this.ServiceThreadId;
public override Task<JsonElement> SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken);
}
}
@@ -40,10 +40,10 @@ public class AgentActorTests
[Fact]
public async Task RunAsync_WithNoExistingThreadState_CallsGetNewThreadAsync()
{
var expectedThread = new AgentThread { ConversationId = "new-thread-id" };
var mockExpectedThread = new Mock<AgentThread>();
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.GetNewThread()).Returns(expectedThread);
mockAgent.Setup(a => a.GetNewThread()).Returns(mockExpectedThread.Object);
var mockContext = new Mock<IActorRuntimeContext>();
var actorId = new ActorId("TestAgent", "test-instance");
@@ -102,8 +102,10 @@ public class AgentActorTests
public async Task HandleAgentRequest_UsesCorrectThreadAsync()
{
var threadJson = JsonSerializer.SerializeToElement(new { conversationId = "expected-thread-id" });
var mockThread = new Mock<AgentThread>();
var testAgent = new TestAgent();
testAgent.ThreadForCreate = mockThread.Object;
var mockContext = new Mock<IActorRuntimeContext>();
var actorId = new ActorId("TestAgent", "test-instance");
@@ -144,7 +146,8 @@ public class AgentActorTests
// Verify the thread was used in RunStreamingAsync and has the expected ID
Assert.NotNull(testAgent.ThreadUsedInRunStreamingAsync);
Assert.Equal("expected-thread-id", testAgent.ThreadUsedInRunStreamingAsync.ConversationId);
Assert.Same(mockThread.Object, testAgent.ThreadUsedInRunStreamingAsync);
Assert.Equal(threadJson, testAgent.ElementUsedInDeserializeThread);
}
/// <summary>
@@ -172,9 +175,22 @@ public class AgentActorTests
/// </summary>
private sealed class TestAgent : AIAgent
{
public AgentThread? ThreadForCreate { get; set; }
public JsonElement? ElementUsedInDeserializeThread { get; set; }
public bool RunStreamingAsyncCalled { get; private set; }
public AgentThread? ThreadUsedInRunStreamingAsync { get; private set; }
public override AgentThread GetNewThread()
{
return this.ThreadForCreate!;
}
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
this.ElementUsedInDeserializeThread = serializedThread;
return this.ThreadForCreate!;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
this.ThreadUsedInRunStreamingAsync = thread;
@@ -107,7 +107,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy(AgentName, mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act
var result = await proxy.RunAsync(s_emptyMessages, thread);
@@ -144,7 +144,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy(AgentName, mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -179,7 +179,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy(AgentName, mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
@@ -214,7 +214,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy(AgentName, mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act & Assert
var exception = await Assert.ThrowsAsync<NotSupportedException>(() =>
@@ -286,7 +286,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act
var results = new List<AgentRunResponseUpdate>();
@@ -329,7 +329,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act
var results = new List<AgentRunResponseUpdate>();
@@ -377,7 +377,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act
var results = new List<AgentRunResponseUpdate>();
@@ -432,7 +432,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
@@ -568,7 +568,7 @@ public class AgentProxyTests
var proxy = new AgentProxy("agentName", mockClient.Object);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() =>
@@ -645,7 +645,7 @@ public class AgentProxyTests
.ReturnsAsync(fakeHandle);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
@@ -690,7 +690,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
var messages = new List<ChatMessage>
{
new(ChatRole.User, "first"),
@@ -733,7 +733,7 @@ public class AgentProxyTests
.ReturnsAsync(mockHandle.Object);
var proxy = new AgentProxy("agentName", mockClient.Object);
var thread = proxy.GetThread(ThreadId);
var thread = proxy.GetNewThread(ThreadId);
var messages = new List<ChatMessage> { new(ChatRole.User, "test") };
// Act
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Moq;
@@ -297,6 +298,12 @@ public class AgentExtensionsTests
this._exceptionToThrow = exceptionToThrow;
}
public override AgentThread GetNewThread()
=> throw new NotImplementedException();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override string? Name { get; }
public override string? Description { get; }
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Moq;
namespace Microsoft.Extensions.AI.Agents.UnitTests.ChatCompletion;
@@ -165,8 +164,8 @@ public class ChatClientAgentOptionsTests
const string Name = "Test name";
const string Description = "Test description";
var tools = new List<AITool> { AIFunctionFactory.Create(() => "test") };
static IChatMessageStore ChatMessageStoreFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock<IChatMessageStore>().Object;
static AIContextProvider AIContextProviderFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock<AIContextProvider>().Object;
static IChatMessageStore ChatMessageStoreFactory(ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock<IChatMessageStore>().Object;
static AIContextProvider AIContextProviderFactory(ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => new Mock<AIContextProvider>().Object;
var original = new ChatClientAgentOptions(Instructions, Name, Description, tools)
{
@@ -322,7 +322,7 @@ public class ChatClientAgentTests
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" });
AgentThread thread = new() { ConversationId = "ConvId" };
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act & Assert
var response = await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions));
@@ -342,7 +342,7 @@ public class ChatClientAgentTests
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" });
AgentThread thread = new() { ConversationId = "ThreadId" };
ChatClientAgentThread thread = new() { ConversationId = "ThreadId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)));
@@ -365,7 +365,7 @@ public class ChatClientAgentTests
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" });
AgentThread thread = new() { ConversationId = "ConvId" };
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act
await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions));
@@ -390,7 +390,7 @@ public class ChatClientAgentTests
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" });
AgentThread thread = new() { ConversationId = "ConvId" };
ChatClientAgentThread thread = new() { ConversationId = "ConvId" };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], thread));
@@ -410,7 +410,7 @@ public class ChatClientAgentTests
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions" });
AgentThread thread = new();
ChatClientAgentThread thread = new();
// Act
await agent.RunAsync([new(ChatRole.User, "test")], thread);
@@ -461,7 +461,7 @@ public class ChatClientAgentTests
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
await agent.RunAsync(requestMessages);
@@ -506,7 +506,7 @@ public class ChatClientAgentTests
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(requestMessages));
@@ -548,7 +548,7 @@ public class ChatClientAgentTests
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext());
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = (_, _) => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
// Act
await agent.RunAsync([new(ChatRole.User, "user message")]);
@@ -1144,11 +1144,11 @@ public class ChatClientAgentTests
// Arrange
var agentChatOptions = new ChatOptions
{
RawRepresentationFactory = (_) => agentSetting
RawRepresentationFactory = _ => agentSetting
};
var requestChatOptions = new ChatOptions
{
RawRepresentationFactory = (_) => requestSetting
RawRepresentationFactory = _ => requestSetting
};
Mock<IChatClient> mockService = new();
@@ -1713,7 +1713,7 @@ public class ChatClientAgentTests
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions",
ChatMessageStoreFactory = (_, _) =>
ChatMessageStoreFactory = _ =>
{
factoryCalled = true;
return mockStore.Object;
@@ -1725,7 +1725,9 @@ public class ChatClientAgentTests
// Assert
Assert.True(factoryCalled, "ChatMessageStoreFactory was not called.");
Assert.Same(mockStore.Object, thread.MessageStore);
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockStore.Object, typedThread.MessageStore);
}
[Fact]
@@ -1738,7 +1740,7 @@ public class ChatClientAgentTests
var agent = new ChatClientAgent(mockChatClient.Object, new ChatClientAgentOptions
{
Instructions = "Test instructions",
AIContextProviderFactory = (_, _) =>
AIContextProviderFactory = _ =>
{
factoryCalled = true;
return mockContextProvider.Object;
@@ -1750,7 +1752,9 @@ public class ChatClientAgentTests
// Assert
Assert.True(factoryCalled, "AIContextProviderFactory was not called.");
Assert.Same(mockContextProvider.Object, thread.AIContextProvider);
Assert.IsType<ChatClientAgentThread>(thread);
var typedThread = (ChatClientAgentThread)thread;
Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider);
}
#endregion
@@ -0,0 +1,353 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Moq;
#pragma warning disable CA1861 // Avoid constant arrays as arguments
namespace Microsoft.Extensions.AI.Agents.UnitTests.ChatCompletion;
public class ChatClientAgentThreadTests
{
#region Constructor and Property Tests
[Fact]
public void ConstructorSetsDefaults()
{
// Arrange & Act
var thread = new ChatClientAgentThread();
// Assert
Assert.Null(thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetConversationIdRoundtrips()
{
// Arrange
var thread = new ChatClientAgentThread();
const string ConversationId = "test-thread-id";
// Act
thread.ConversationId = ConversationId;
// Assert
Assert.Equal(ConversationId, thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreRoundtrips()
{
// Arrange
var thread = new ChatClientAgentThread();
var messageStore = new InMemoryChatMessageStore();
// Act
thread.MessageStore = messageStore;
// Assert
Assert.Same(messageStore, thread.MessageStore);
Assert.Null(thread.ConversationId);
}
[Fact]
public void SetConversationIdThrowsWhenMessageStoreIsSet()
{
// Arrange
var thread = new ChatClientAgentThread
{
MessageStore = new InMemoryChatMessageStore()
};
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.ConversationId = "new-thread-id");
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.MessageStore);
}
[Fact]
public void SetChatMessageStoreThrowsWhenConversationIdIsSet()
{
// Arrange
var thread = new ChatClientAgentThread
{
ConversationId = "existing-thread-id"
};
var store = new InMemoryChatMessageStore();
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => thread.MessageStore = store);
Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message);
Assert.NotNull(thread.ConversationId);
}
#endregion Constructor and Property Tests
#region OnNewMessagesAsync Tests
[Fact]
public async Task OnNewMessagesAsyncDoesNothingWhenAgentServiceIdAsync()
{
// Arrange
var thread = new ChatClientAgentThread { ConversationId = "thread-123" };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
var agent = new MessageSendingAgent();
// Act
await agent.SendMessagesAsync(thread, messages, CancellationToken.None);
Assert.Equal("thread-123", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task OnNewMessagesAsyncAddsMessagesToStoreAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
var thread = new ChatClientAgentThread { MessageStore = store };
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
};
var agent = new MessageSendingAgent();
// Act
await agent.SendMessagesAsync(thread, messages, CancellationToken.None);
// Assert
Assert.Equal(2, store.Count);
Assert.Equal("Hello", store[0].Text);
Assert.Equal("Hi there!", store[1].Text);
}
#endregion OnNewMessagesAsync Tests
#region Deserialize Tests
[Fact]
public async Task VerifyDeserializeConstructorWithMessagesAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"storeState": { "messages": [{"authorName": "testAuthor"}] }
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act.
var thread = new ChatClientAgentThread(json);
// Assert
Assert.Null(thread.ConversationId);
var messageStore = thread.MessageStore as InMemoryChatMessageStore;
Assert.NotNull(messageStore);
Assert.Single(messageStore);
Assert.Equal("testAuthor", messageStore[0].AuthorName);
}
[Fact]
public async Task VerifyDeserializeConstructorWithIdAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId"
}
""", TestJsonSerializerContext.Default.JsonElement);
// Act
var thread = new ChatClientAgentThread(json);
// Assert
Assert.Equal("TestConvId", thread.ConversationId);
Assert.Null(thread.MessageStore);
}
[Fact]
public async Task VerifyDeserializeConstructorWithAIContextProviderAsync()
{
// Arrange
var json = JsonSerializer.Deserialize("""
{
"conversationId": "TestConvId",
"aiContextProviderState": ["CP1"]
}
""", TestJsonSerializerContext.Default.JsonElement);
Mock<AIContextProvider> mockProvider = new();
// Act
var thread = new ChatClientAgentThread(json, aiContextProviderFactory: (_, _) => mockProvider.Object);
// Assert
Assert.Null(thread.MessageStore);
Assert.Same(thread.AIContextProvider, mockProvider.Object);
}
[Fact]
public async Task DeserializeContructorWithInvalidJsonThrowsAsync()
{
// Arrange
var invalidJson = JsonSerializer.Deserialize("[42]", TestJsonSerializerContext.Default.JsonElement);
var thread = new ChatClientAgentThread();
// Act & Assert
Assert.Throws<ArgumentException>(() => new ChatClientAgentThread(invalidJson));
}
#endregion Deserialize Tests
#region Serialize Tests
/// <summary>
/// Verify thread serialization to JSON when the thread has an id.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithIdAsync()
{
// Arrange
var thread = new ChatClientAgentThread { ConversationId = "TestConvId" };
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("conversationId", out var idProperty));
Assert.Equal("TestConvId", idProperty.GetString());
Assert.False(json.TryGetProperty("storeState", out _));
}
/// <summary>
/// Verify thread serialization to JSON when the thread has messages.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithMessagesAsync()
{
// Arrange
var store = new InMemoryChatMessageStore();
store.Add(new ChatMessage(ChatRole.User, "TestContent") { AuthorName = "TestAuthor" });
var thread = new ChatClientAgentThread { MessageStore = store };
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out _));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("messages", out var messagesProperty));
Assert.Equal(JsonValueKind.Array, messagesProperty.ValueKind);
Assert.Single(messagesProperty.EnumerateArray());
var message = messagesProperty.EnumerateArray().First();
Assert.Equal("TestAuthor", message.GetProperty("authorName").GetString());
Assert.True(message.TryGetProperty("contents", out var contentsProperty));
Assert.Equal(JsonValueKind.Array, contentsProperty.ValueKind);
Assert.Single(contentsProperty.EnumerateArray());
var textContent = contentsProperty.EnumerateArray().First();
Assert.Equal("TestContent", textContent.GetProperty("text").GetString());
}
[Fact]
public async Task VerifyThreadSerializationWithWithAIContextProviderAsync()
{
// Arrange
Mock<AIContextProvider> mockProvider = new();
var providerStateElement = JsonSerializer.SerializeToElement(new[] { "CP1" }, TestJsonSerializerContext.Default.StringArray);
mockProvider
.Setup(m => m.SerializeAsync(It.IsAny<JsonSerializerOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(providerStateElement);
var thread = new ChatClientAgentThread();
thread.AIContextProvider = mockProvider.Object;
// Act
var json = await thread.SerializeAsync();
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("aiContextProviderState", out var providerStateProperty));
Assert.Equal(JsonValueKind.Array, providerStateProperty.ValueKind);
Assert.Single(providerStateProperty.EnumerateArray());
Assert.Equal("CP1", providerStateProperty.EnumerateArray().First().GetString());
mockProvider.Verify(m => m.SerializeAsync(It.IsAny<JsonSerializerOptions?>(), It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verify thread serialization to JSON with custom options.
/// </summary>
[Fact]
public async Task VerifyThreadSerializationWithCustomOptionsAsync()
{
// Arrange
var thread = new ChatClientAgentThread();
JsonSerializerOptions options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
var storeStateElement = JsonSerializer.SerializeToElement(
new Dictionary<string, object> { ["Key"] = "TestValue" },
TestJsonSerializerContext.Default.DictionaryStringObject);
var messageStoreMock = new Mock<IChatMessageStore>();
messageStoreMock
.Setup(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()))
.ReturnsAsync(storeStateElement);
thread.MessageStore = messageStoreMock.Object;
// Act
var json = await thread.SerializeAsync(options);
// Assert
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.False(json.TryGetProperty("conversationId", out var idProperty));
Assert.True(json.TryGetProperty("storeState", out var storeStateProperty));
Assert.Equal(JsonValueKind.Object, storeStateProperty.ValueKind);
Assert.True(storeStateProperty.TryGetProperty("Key", out var keyProperty));
Assert.Equal("TestValue", keyProperty.GetString());
messageStoreMock.Verify(m => m.SerializeStateAsync(options, It.IsAny<CancellationToken>()), Times.Once);
}
#endregion Serialize Tests
private sealed class MessageSendingAgent : AIAgent
{
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override AgentThread GetNewThread()
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task SendMessagesAsync(AgentThread thread, IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken);
}
}
@@ -380,6 +380,10 @@ public class OpenTelemetryAgentTests
.Build();
var mockAgent = CreateMockAgent(false);
var mockThread = new Mock<AgentThread>();
mockThread.Setup(t => t.GetService(typeof(AgentThreadMetadata), null))
.Returns(new AgentThreadMetadata("thread-123"));
using var telemetryAgent = new OpenTelemetryAgent(mockAgent.Object, sourceName: sourceName);
var messages = new List<ChatMessage>
@@ -387,10 +391,8 @@ public class OpenTelemetryAgentTests
new(ChatRole.User, "Hello")
};
var thread = new AgentThread { ConversationId = "thread-123" };
// Act
await telemetryAgent.RunAsync(messages, thread);
await telemetryAgent.RunAsync(messages, mockThread.Object);
// Assert
var activity = Assert.Single(activities);
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -11,4 +12,6 @@ namespace Microsoft.Extensions.AI.Agents.UnitTests;
UseStringEnumConverter = true)]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
internal sealed partial class TestJsonSerializerContext : JsonSerializerContext;
@@ -25,8 +25,9 @@ public class OpenAIAssistantFixture : IChatClientAgentFixture
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
{
var typedThread = (ChatClientAgentThread)thread;
List<ChatMessage> messages = [];
await foreach (var agentMessage in this._assistantClient!.GetMessagesAsync(thread.ConversationId, new() { Order = MessageCollectionOrder.Ascending }))
await foreach (var agentMessage in this._assistantClient!.GetMessagesAsync(typedThread.ConversationId, new() { Order = MessageCollectionOrder.Ascending }))
{
messages.Add(new()
{
@@ -69,9 +70,10 @@ public class OpenAIAssistantFixture : IChatClientAgentFixture
public Task DeleteThreadAsync(AgentThread thread)
{
if (thread?.ConversationId is not null)
var typedThread = (ChatClientAgentThread)thread;
if (typedThread?.ConversationId is not null)
{
return this._assistantClient!.DeleteThreadAsync(thread.ConversationId);
return this._assistantClient!.DeleteThreadAsync(typedThread.ConversationId);
}
return Task.CompletedTask;
@@ -28,8 +28,12 @@ public class OpenAIChatCompletionFixture : IChatClientAgentFixture
public IChatClient ChatClient => this._agent.ChatClient;
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread) =>
thread.MessageStore is null ? [] : (await thread.MessageStore.GetMessagesAsync()).ToList();
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
{
var typedThread = (ChatClientAgentThread)thread;
return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList();
}
public Task<ChatClientAgent> CreateChatClientAgentAsync(
string name = "HelpfulAssistant",
@@ -27,10 +27,12 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
public async Task<List<ChatMessage>> GetChatHistoryAsync(AgentThread thread)
{
var typedThread = (ChatClientAgentThread)thread;
if (store)
{
var inputItems = await this._openAIResponseClient.GetResponseInputItemsAsync(thread.ConversationId).ToListAsync();
var response = await this._openAIResponseClient.GetResponseAsync(thread.ConversationId);
var inputItems = await this._openAIResponseClient.GetResponseInputItemsAsync(typedThread.ConversationId).ToListAsync();
var response = await this._openAIResponseClient.GetResponseAsync(typedThread.ConversationId);
var responseItem = response.Value.OutputItems.FirstOrDefault()!;
// Take the messages that were the chat history leading up to the current response
@@ -48,7 +50,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
return [.. previousMessages, responseMessage];
}
return thread.MessageStore is null ? [] : (await thread.MessageStore.GetMessagesAsync()).ToList();
return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList();
}
private static ChatMessage ConvertToChatMessage(ResponseItem item)
@@ -75,7 +77,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
ChatOptions = new ChatOptions
{
Tools = aiTools,
RawRepresentationFactory = new Func<IChatClient, object>((_) => new ResponseCreationOptions() { StoredOutputEnabled = store })
RawRepresentationFactory = new Func<IChatClient, object>(_ => new ResponseCreationOptions() { StoredOutputEnabled = store })
},
});