diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index c7dad1acbd..9db3d9efb6 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -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)], diff --git a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs index e28eb3a81a..1ee48514f8 100644 --- a/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs @@ -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) diff --git a/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs b/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs index d39cb0c793..33e8965375 100644 --- a/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs +++ b/dotnet/samples/GettingStarted/AgentOrchestration/AgentSample.cs @@ -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); } } diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundry/Program.cs index 430ae48ae8..86f3ea25db 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundry/Program.cs @@ -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); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs index 04f8cc8b24..fe3e3ba0b2 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_CustomImplementation/Program.cs @@ -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 RunAsync(IEnumerable 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; }); + + /// + /// A thread type for our custom agent that only supports in memory storage of messages. + /// + internal sealed class CustomAgentThread : InMemoryAgentThread + { + internal CustomAgentThread() + : base() { } + + internal CustomAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThreadState, jsonSerializerOptions) { } + } } } diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Program.cs index 989c5663ac..35689f2a92 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIAssistants/Program.cs @@ -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); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index cc449ce177..fa1639543a 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -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); } }); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs index b9354a7511..c00ac4073b 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step13_Memory/Program.cs @@ -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(jsonSerializerOptions) ?? new UserInfo(); + + this.UserInfo = serializedState.ValueKind == JsonValueKind.Object ? + serializedState.Deserialize(jsonSerializerOptions)! : + new UserInfo(); } public UserInfo UserInfo { get; set; } diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs index 45ea4e6a10..9e9249a9e9 100644 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs @@ -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); } diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs index 14acfac819..026ab9689e 100644 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs @@ -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); } diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs index b3526c9742..0cf6a564db 100644 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs @@ -93,6 +93,9 @@ async Task AFAgentAsync() // Clean up var azureAgentClient = serviceProvider.GetRequiredService(); - await azureAgentClient.Threads.DeleteThreadAsync(thread.ConversationId); + if (thread is ChatClientAgentThread chatThread) + { + await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); + } await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); } diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs index eeaaa98bf7..d1cef9ffea 100644 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs @@ -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); } diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs index bec48cee6a..dc896d89f2 100644 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs @@ -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); } diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs index 581e24c6de..9b14f80bc2 100644 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs @@ -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); } diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs index ef9d75da06..81dea6381d 100644 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs @@ -90,6 +90,9 @@ async Task AFAgentAsync() // Clean up var assistantClient = serviceProvider.GetRequiredService(); - await assistantClient.DeleteThreadAsync(thread.ConversationId); + if (thread is ChatClientAgentThread chatThread) + { + await assistantClient.DeleteThreadAsync(chatThread.ConversationId); + } await assistantClient.DeleteAssistantAsync(agent.Id); } diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs index ee955cceb5..99213d52e4 100644 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs @@ -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); } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs index 29d03fe384..8dcaac3edb 100644 --- a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgent.cs @@ -63,6 +63,14 @@ public abstract partial class OrchestratingAgent : AIAgent /// public Func? StreamingResponseCallback { get; set; } + /// + public override AgentThread GetNewThread() + => new OrchestratingAgentThread(); + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + => new OrchestratingAgentThread(serializedThread, jsonSerializerOptions); + /// public sealed override async Task RunAsync( IEnumerable 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 messagesList = (await thread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)).ToList(); + List messagesList = (await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false)).ToList(); messagesList.AddRange(messages); messages = messagesList; } diff --git a/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs new file mode 100644 index 0000000000..05db5d566d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Orchestration/OrchestratingAgentThread.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.Extensions.AI.Agents; + +namespace Microsoft.Agents.Orchestration; + +/// +/// The thread implementation used by . +/// +internal sealed class OrchestratingAgentThread : InMemoryAgentThread +{ + internal OrchestratingAgentThread() + : base() { } + + internal OrchestratingAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) + : base(serializedThreadState, jsonSerializerOptions) { } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index fdecde8518..f696cda9d5 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -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 agentUpdates = inputMessages is not null ? agent.RunStreamingAsync([.. inputMessages.ToChatMessages()], agentThread, options, cancellationToken) : diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index b0b41edeff..cdcb9293b3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -56,7 +56,7 @@ internal sealed class AIAgentHostExecutor : Executor JsonElement? threadValue = await context.ReadStateAsync(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(PendingMessagesStateKey).ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs index 091260946f..a445417ef7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs @@ -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 diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs index bbfb3302f5..90fe97e222 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowThread.cs @@ -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 } /// - public new WorkflowMessageStore MessageStore { get; } + public WorkflowMessageStore MessageStore { get; } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs index 802744bf9a..1f2aa615af 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs @@ -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(); } + /// + public override sealed AgentThread GetNewThread() + => new A2AAgentThread(); + + /// + /// Get a new instance using an existing context id, to continue that conversation. + /// + /// The context id to continue. + /// A new instance. + public AgentThread GetNewThread(string contextId) + => new A2AAgentThread() { ContextId = contextId }; + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + => new A2AAgentThread(serializedThread, jsonSerializerOptions); + /// public override async Task RunAsync(IEnumerable 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; } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentThread.cs new file mode 100644 index 0000000000..c9696f2283 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentThread.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Thread for A2A based agents. +/// +public sealed class A2AAgentThread : ServiceIdAgentThread +{ + internal A2AAgentThread() + { + } + + internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) + { + } + + /// + /// Gets the ID for the current conversation with the A2A agent. + /// + public string? ContextId + { + get { return this.ServiceThreadId; } + internal set { this.ServiceThreadId = value; } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs index fabcddba53..b3fffb486e 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AIAgent.cs @@ -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. /// /// - public virtual AgentThread GetNewThread() => new(); + public abstract AgentThread GetNewThread(); /// /// Deserialize the thread from JSON. /// /// The representing the thread state. /// Optional to use for deserializing the thread state. - /// The to monitor for cancellation requests. The default is . /// The deserialized instance. - public virtual AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => new(serializedThread, jsonSerializerOptions); + public abstract AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null); /// /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread. diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs index 4f86e66994..77d184dd02 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentAbstractionsJsonUtilities.cs @@ -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] diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs index 814d515ea8..eae957500d 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThread.cs @@ -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. /// -public class AgentThread +public abstract class AgentThread { - private string? _conversationId; - private IChatMessageStore? _messageStore; - /// /// Initializes a new instance of the class. /// - public AgentThread() + protected AgentThread() { } - /// - /// Initializes a new instance of the class from serialized state. - /// - /// A representing the serialized state of the thread. - /// Optional settings for customizing the JSON deserialization process. - /// An optional factory function to create a custom . - /// An optional factory function to create a custom . - public AgentThread( - JsonElement serializedThreadState, - JsonSerializerOptions? jsonSerializerOptions = null, - Func? chatMessageStoreFactory = null, - Func? 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); - } - - /// - /// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service. - /// - /// - /// - /// Note that either or may be set, but not both. - /// If is not null, and is set, - /// will be reverted to null, and vice versa. - /// - /// - /// This property may be null in the following cases: - /// - /// The thread stores messages via the and not in the agent service. - /// This thread object is new and a server managed thread has not yet been created in the agent service. - /// - /// - /// - /// The id may also change over time where the id is pointing at a - /// agent service managed thread, and the default behavior of a service is - /// to fork the thread with each iteration. - /// - /// - public string? ConversationId - { - get => this._conversationId; - set - { - if (string.IsNullOrWhiteSpace(this._conversationId) && string.IsNullOrWhiteSpace(value)) - { - return; - } - - if (this._messageStore is not null) - { - // If we have a message store already, we shouldn't switch the thread to use a conversation id - // since it means that the thread contents will essentially be deleted, and the thread will not work - // with the original agent anymore. - throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported."); - } - - this._conversationId = Throw.IfNullOrWhitespace(value); - } - } - - /// - /// Gets or sets the used by this thread, for cases where messages should be stored in a custom location. - /// - /// - /// - /// Note that either or may be set, but not both. - /// If is not null, and is set, - /// will be reverted to null, and vice versa. - /// - /// - /// This property may be null in the following cases: - /// - /// The thread stores messages in the agent service and just has an id to the remove thread, instead of in an . - /// This thread object is new it is not yet clear whether it will be backed by a server managed thread or an . - /// - /// - /// - public IChatMessageStore? MessageStore - { - get => this._messageStore; - set - { - if (this._messageStore is null && value is null) - { - return; - } - - if (!string.IsNullOrWhiteSpace(this._conversationId)) - { - // If we have a conversation id already, we shouldn't switch the thread to use a message store - // since it means that the thread will not work with the original agent anymore. - throw new InvalidOperationException("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported."); - } - - this._messageStore = Throw.IfNull(value); - } - } - - /// - /// Gets or sets the used by this thread to provide additional context to the AI model before each invocation. - /// - public AIContextProvider? AIContextProvider { get; set; } - /// /// Serializes the current object's state to a using the specified serialization options. /// /// The JSON serialization options to use. /// The to monitor for cancellation requests. The default is . /// A representation of the object's state. - public virtual async Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - { - var storeState = this._messageStore is null ? - null : - await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false); - - var 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 SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => Task.FromResult(default(JsonElement)); /// /// This method is called when new messages have been contributed to the chat by any participant. @@ -186,38 +41,36 @@ public class AgentThread /// The to monitor for cancellation requests. The default is . /// A task that completes when the context has been updated. /// The thread has been deleted. - protected internal virtual async Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + protected internal virtual Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// including itself or any services it might be wrapping. For example, to access the for the instance, + /// may be used to request it. + /// + 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; } - } + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThreadMetadata.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThreadMetadata.cs new file mode 100644 index 0000000000..39b1bf7665 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/AgentThreadMetadata.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.AI.Agents; + +/// Provides metadata about an . +public class AgentThreadMetadata +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for the conversation, if available. + public AgentThreadMetadata(string? conversationId) + { + ConversationId = conversationId; + } + + /// + /// Gets the unique identifier for the conversation, if available. + /// + /// + /// The meaning of this ID may vary depending on the agent implementation. + /// + public string? ConversationId { get; } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs index d79cf03914..c597530230 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/DelegatingAIAgent.cs @@ -54,8 +54,8 @@ public class DelegatingAIAgent : AIAgent public override AgentThread GetNewThread() => this.InnerAgent.GetNewThread(); /// - 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); /// public override Task RunAsync( diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs new file mode 100644 index 0000000000..97654b2c0e --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/InMemoryAgentThread.cs @@ -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; + +/// +/// A base class for agent threads that operate entirely in memory without external storage. +/// +public abstract class InMemoryAgentThread : AgentThread +{ + /// + /// Initializes a new instance of the class. + /// + /// An optional to use for storing chat messages. If null, a new instance will be created. + protected InMemoryAgentThread(InMemoryChatMessageStore? messageStore = null) + { + this.MessageStore = messageStore ?? new InMemoryChatMessageStore(); + } + + /// + /// Initializes a new instance of the class with the specified initial messages. + /// + /// The messages to initialize the thread with. + protected InMemoryAgentThread(IEnumerable messages) + { + this.MessageStore = new InMemoryChatMessageStore(); + foreach (var message in messages) + { + this.MessageStore.Add(message); + } + } + + /// + /// Initializes a new instance of the class from serialized state. + /// + /// A representing the serialized state of the thread. + /// Optional settings for customizing the JSON deserialization process. + /// A factory function to create the from its serialized state. + /// The is not a JSON object. + /// The is invalid or cannot be deserialized to the expected type. + protected InMemoryAgentThread( + JsonElement serializedThreadState, + JsonSerializerOptions? jsonSerializerOptions = null, + Func? 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); + } + + /// + /// Gets or sets the used by this thread. + /// + public InMemoryChatMessageStore MessageStore { get; } + + /// + /// Serializes the current object's state to a using the specified serialization options. + /// + /// The JSON serialization options to use. + /// The to monitor for cancellation requests. The default is . + /// A representation of the object's state. + public override async Task 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))); + } + + /// + protected internal override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + => this.MessageStore.AddMessagesAsync(newMessages, cancellationToken); + + internal sealed class InMemoryAgentThreadState + { + public JsonElement? StoreState { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs new file mode 100644 index 0000000000..da15502732 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Abstractions/ServiceIdAgentThread.cs @@ -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; + +/// +/// A base class for agent threads that always store conversation state in the service, and only keep an ID reference in the . +/// +public abstract class ServiceIdAgentThread : AgentThread +{ + /// + /// Initializes a new instance of the class. + /// + protected ServiceIdAgentThread() + { + } + + /// + /// Initializes a new instance of the class with the specified service thread ID. + /// + /// The ID that the conversation state is stored under in the service. + protected ServiceIdAgentThread(string serviceThreadId) + { + this.ServiceThreadId = Throw.IfNullOrEmpty(serviceThreadId); + } + + /// + /// Initializes a new instance of the class from serialized state. + /// + /// A representing the serialized state of the thread. + /// Optional settings for customizing the JSON deserialization process. + /// The is not a JSON object. + /// The is invalid or cannot be deserialized to the expected type. + 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; + } + } + + /// + /// Gets the ID that the conversation state is stored under in the service. + /// + protected string? ServiceThreadId { get; set; } + + /// + /// Serializes the current object's state to a using the specified serialization options. + /// + /// The JSON serialization options to use. + /// The to monitor for cancellation requests. The default is . + /// A representation of the object's state. + public override async Task 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; } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs index b742d71f15..39152876d3 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgent.cs @@ -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(); } + /// + public override sealed AgentThread GetNewThread() + => new CopilotStudioAgentThread(); + + /// + /// Get a new instance using an existing conversation id, to continue that conversation. + /// + /// The conversation id to continue. + /// A new instance. + public AgentThread GetNewThread(string conversationId) + => new CopilotStudioAgentThread() { ConversationId = conversationId }; + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + => new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions); + /// public override async Task RunAsync( IEnumerable 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(); 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)) diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs new file mode 100644 index 0000000000..cdd63d9aba --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.CopilotStudio/CopilotStudioAgentThread.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Extensions.AI.Agents.CopilotStudio; + +/// +/// Thread for CopilotStudio based agents. +/// +public sealed class CopilotStudioAgentThread : ServiceIdAgentThread +{ + internal CopilotStudioAgentThread() + { + } + + internal CopilotStudioAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) + { + } + + /// + /// Gets the ID for the current conversation with the Copilot Studio agent. + /// + public string? ConversationId + { + get { return this.ServiceThreadId; } + internal set { this.ServiceThreadId = value; } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs index 68de921262..778192be26 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentActor.cs @@ -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; } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs index 3544cdb53f..fc4d55c0ce 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxy.cs @@ -37,15 +37,15 @@ public sealed class AgentProxy : AIAgent public override AgentThread GetNewThread() => new AgentProxyThread(); /// - 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); /// - /// Gets a thread by its . + /// Gets a thread by its . /// /// The thread identifier. /// The thread. - public AgentThread GetThread(string conversationId) => new AgentProxyThread(conversationId); + public AgentThread GetNewThread(string conversationId) => new AgentProxyThread(conversationId); /// public override async Task RunAsync( diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs index d401016aba..94ca780383 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.Hosting/AgentProxyThread.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.AI.Agents.Hosting; /// /// Represents an agent thread for a . /// -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 class with the specified identifier. /// /// The unique identifier for the agent proxy thread. - public AgentProxyThread(string id) + internal AgentProxyThread(string id) { Throw.IfNullOrEmpty(id); ValidateId(id); @@ -52,7 +52,7 @@ internal sealed partial class AgentProxyThread : AgentThread /// /// Initializes a new instance of the class with the specified identifier. /// - public AgentProxyThread() : this(CreateId()) + internal AgentProxyThread() : this(CreateId()) { } @@ -61,11 +61,20 @@ internal sealed partial class AgentProxyThread : AgentThread /// /// A representing the serialized state of the thread. /// Optional settings for customizing the JSON deserialization process. - public AgentProxyThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) + internal AgentProxyThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) { } + /// + /// Gets the ID that the conversation state is stored under for the agent. + /// + public string? ConversationId + { + get => this.ServiceThreadId; + private set => this.ServiceThreadId = value; + } + internal static string CreateId() => Guid.NewGuid().ToString("N"); /// diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs index 4e1faaaa18..81ca766938 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.OpenAI/OpenAIChatClientAgent.cs @@ -84,8 +84,8 @@ public class OpenAIChatClientAgent : AIAgent => this._chatClientAgent.GetNewThread(); /// - 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); /// public sealed override Task RunAsync( diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentJsonUtilities.cs new file mode 100644 index 0000000000..647ab1d6b4 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/AgentJsonUtilities.cs @@ -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; + +/// Provides a collection of utility methods for working with JSON data in the context of agents. +public static partial class AgentJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in this library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates default options to use for agents-related serialization. + /// + /// The configured options. + [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; +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs index b0ef7141a3..d60553e187 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgent.cs @@ -108,7 +108,7 @@ public sealed class ChatClientAgent : AIAgent { var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection ?? messages.ToList(); - (AgentThread safeThread, ChatOptions? chatOptions, List threadMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List 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 ?? messages.ToList(); - (AgentThread safeThread, ChatOptions? chatOptions, List threadMessages) = + (ChatClientAgentThread safeThread, ChatOptions? chatOptions, List 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)); /// - 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 }) + }; + + /// + /// Get a new instance using an existing conversation id, to continue that conversation. + /// + /// The conversation id to continue. + /// A new instance. + /// + /// Note that any created with this method will only work with instances that support storing + /// chat history in the underlying service provided by the . + /// + public AgentThread GetNewThread(string conversationId) + => new ChatClientAgentThread() + { + ConversationId = conversationId, + AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) }; /// - 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? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ? + null : + (jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }); + + Func? 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 /// /// Notify the when an agent run succeeded, if there is an . /// - private static async Task NotifyAIContextProviderOfSuccessAsync(AgentThread thread, IEnumerable inputMessages, IEnumerable responseMessages, CancellationToken cancellationToken) + private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable inputMessages, IEnumerable responseMessages, CancellationToken cancellationToken) { if (thread.AIContextProvider is not null) { @@ -264,7 +294,7 @@ public sealed class ChatClientAgent : AIAgent /// /// Notify the of any failure during an agent run, if there is an . /// - private static async Task NotifyAIContextProviderOfFailureAsync(AgentThread thread, Exception ex, IEnumerable inputMessages, CancellationToken cancellationToken) + private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable inputMessages, CancellationToken cancellationToken) { if (thread.AIContextProvider is not null) { @@ -396,7 +426,7 @@ public sealed class ChatClientAgent : AIAgent /// Optional parameters for agent invocation. /// The cancellation token. /// A tuple containing the thread, chat options, and thread messages. - private async Task<(AgentThread AgentThread, ChatOptions? ChatOptions, List ThreadMessages)> PrepareThreadAndMessagesAsync( + private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List ThreadMessages)> PrepareThreadAndMessagesAsync( AgentThread? thread, IEnumerable 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 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 }); } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs index 2b3807864d..5fd73a2f86 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentOptions.cs @@ -79,14 +79,14 @@ public class ChatClientAgentOptions /// Gets or sets a factory function to create an instance of /// which will be used to store chat messages for this agent. /// - public Func? ChatMessageStoreFactory { get; set; } + public Func? ChatMessageStoreFactory { get; set; } /// /// Gets or sets a factory function to create an instance of /// which will be used to create a context provider for each new thread, and can then /// provide additional context for each agent run. /// - public Func? AIContextProviderFactory { get; set; } + public Func? AIContextProviderFactory { get; set; } /// /// Gets or sets a value indicating whether to use the provided instance as is, @@ -116,4 +116,38 @@ public class ChatClientAgentOptions ChatMessageStoreFactory = this.ChatMessageStoreFactory, AIContextProviderFactory = this.AIContextProviderFactory, }; + + /// + /// Context object passed to the to create a new instance of . + /// + public class AIContextProviderFactoryContext + { + /// + /// Gets or sets the serialized state of the , if any. + /// + /// if there is no state, e.g. when the is first created. + public JsonElement SerializedState { get; set; } + + /// + /// Gets or sets the JSON serialization options to use when deserializing the . + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + } + + /// + /// Context object passed to the to create a new instance of . + /// + public class ChatMessageStoreFactoryContext + { + /// + /// Gets or sets the serialized state of the chat message store, if any. + /// + /// if there is no state, e.g. when the is first created. + public JsonElement SerializedState { get; set; } + + /// + /// Gets or sets the JSON serialization options to use when deserializing the . + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + } } diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs new file mode 100644 index 0000000000..1543bc0196 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/ChatCompletion/ChatClientAgentThread.cs @@ -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; + +/// +/// Thread for ChatClient based agents. +/// +public class ChatClientAgentThread : AgentThread +{ + private string? _conversationId; + private IChatMessageStore? _messageStore; + + /// + /// Initializes a new instance of the class. + /// + internal ChatClientAgentThread() + { + } + + /// + /// Initializes a new instance of the class from serialized state. + /// + /// A representing the serialized state of the thread. + /// Optional settings for customizing the JSON deserialization process. + /// An optional factory function to create a custom . + /// An optional factory function to create a custom . + internal ChatClientAgentThread( + JsonElement serializedThreadState, + JsonSerializerOptions? jsonSerializerOptions = null, + Func? chatMessageStoreFactory = null, + Func? 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); + } + } + + /// + /// Gets or sets the ID of the underlying service thread to support cases where the chat history is stored by the agent service. + /// + /// + /// + /// Note that either or may be set, but not both. + /// If is not null, and is set, + /// will be reverted to null, and vice versa. + /// + /// + /// This property may be null in the following cases: + /// + /// The thread stores messages via the and not in the agent service. + /// This thread object is new and a server managed thread has not yet been created in the agent service. + /// + /// + /// + /// The id may also change over time where the id is pointing at a + /// agent service managed thread, and the default behavior of a service is + /// to fork the thread with each iteration. + /// + /// + public string? ConversationId + { + get => this._conversationId; + 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); + } + } + + /// + /// Gets or sets the used by this thread, for cases where messages should be stored in a custom location. + /// + /// + /// + /// Note that either or may be set, but not both. + /// If is not null, and is set, + /// will be reverted to null, and vice versa. + /// + /// + /// This property may be null in the following cases: + /// + /// The thread stores messages in the agent service and just has an id to the remove thread, instead of in an . + /// This thread object is new it is not yet clear whether it will be backed by a server managed thread or an . + /// + /// + /// + public IChatMessageStore? MessageStore + { + get => this._messageStore; + 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); + } + } + + /// + /// Gets or sets the used by this thread to provide additional context to the AI model before each invocation. + /// + public AIContextProvider? AIContextProvider { get; internal set; } + + /// + /// Serializes the current object's state to a using the specified serialization options. + /// + /// The JSON serialization options to use. + /// The to monitor for cancellation requests. The default is . + /// A representation of the object's state. + public override async Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + var storeState = this._messageStore is null ? + null : + await this._messageStore.SerializeStateAsync(jsonSerializerOptions, cancellationToken).ConfigureAwait(false); + + var 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 override object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceType == typeof(AgentThreadMetadata) ? + new AgentThreadMetadata(this.ConversationId) : + base.GetService(serviceType, serviceKey); + } + + /// + protected override async Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + { + switch (this) + { + case { ConversationId: not null }: + // If the thread messages are stored in the service + // there is nothing to do here, since invoking the + // service should already update the thread. + break; + + case { MessageStore: null }: + // If there is no conversation id, and no store we can createa a default in memory store and add messages to it. + this._messageStore = new InMemoryChatMessageStore(); + await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); + break; + + case { MessageStore: not null }: + // If a store has been provided, we need to add the messages to the store. + await this._messageStore!.AddMessagesAsync(newMessages, cancellationToken).ConfigureAwait(false); + break; + + default: + throw new UnreachableException(); + } + } + + internal sealed class ThreadState + { + public string? ConversationId { get; set; } + + public JsonElement? StoreState { get; set; } + + public JsonElement? AIContextProviderState { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs index d035471707..beed8d7c21 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents/OpenTelemetryAgent.cs @@ -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(); + 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) diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs index cb6ac9663b..f398e8025a 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentFixture.cs @@ -27,9 +27,10 @@ public class AzureAIAgentsPersistentFixture : IChatClientAgentFixture public async Task> GetChatHistoryAsync(AgentThread thread) { List messages = []; + var typedThread = (ChatClientAgentThread)thread; await foreach (var threadMessage in (AsyncPageable)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; diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs index 514a420104..f8c7f74c38 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/MockAgent.cs @@ -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().Object; + + public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread, System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) + => new Mock().Object; public override Task RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs index 70c1601425..b400cecd63 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/OrchestrationResultTests.cs @@ -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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 77b355bc9e..62e2b8a4af 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -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 RunAsync( IEnumerable 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() { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs index 71b91d6e12..c7167a9ac0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs @@ -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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index 0a0b32fd3e..b57e072ea3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { IEnumerable 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { IEnumerable 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 _messages = []; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index 5705c47ce9..ac43f8874a 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -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> Updates { get; } = []; diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs index 9b1afe00e6..96ac774bec 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs @@ -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(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(() => 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 { diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIAgentTests.cs index 3fa0139f85..756c46759a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIAgentTests.cs @@ -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 { 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 messages, CancellationToken cancellationToken) => AIAgent.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken); + public static new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IEnumerable 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 RunAsync( IEnumerable messages, diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs index ebeec30917..3b26743969 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AIContextProviderTests.cs @@ -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(() => new AIContextProvider.InvokingContext(null!)); + } + + [Fact] + public void InvokedContext_Constructor_ThrowsForNullMessages() + { + Assert.Throws(() => new AIContextProvider.InvokedContext(null!)); + } + private sealed class TestAIContextProvider : AIContextProvider { public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs index b32acf61fe..e424ca2d6b 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentThreadTests.cs @@ -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; +/// +/// Tests for +/// 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 { 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(() => 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(() => 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 - { - 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 - { - 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 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(() => new AgentThread(invalidJson)); - } - - #endregion Deserialize Tests - - #region Serialize Tests + #region GetService Method Tests /// - /// Verify thread serialization to JSON when the thread has an id. + /// Verify that GetService returns the thread itself when requesting the exact thread type. /// [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); } /// - /// Verify thread serialization to JSON when the thread has messages. + /// Verify that GetService returns the thread itself when requesting the base AgentThread type. /// [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 mockProvider = new(); - var providerStateElement = JsonSerializer.SerializeToElement(["CP1"], TestJsonSerializerContext.Default.StringArray); - mockProvider - .Setup(m => m.SerializeAsync(It.IsAny(), It.IsAny())) - .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(), It.IsAny()), Times.Once); + Assert.NotNull(result); + Assert.Same(thread, result); } /// - /// Verify thread serialization to JSON with custom options. + /// Verify that GetService returns null when requesting an unrelated type. /// [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 { ["Key"] = "TestValue" }, - TestJsonSerializerContext.Default.DictionaryStringObject); - - var messageStoreMock = new Mock(); - messageStoreMock - .Setup(m => m.SerializeStateAsync(options, It.IsAny())) - .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()), Times.Once); + Assert.Null(result); } - #endregion Serialize Tests + /// + /// Verify that GetService returns null when a service key is provided, even for matching types. + /// + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + // Arrange + var thread = new TestAgentThread(); + + // Act + var result = thread.GetService(typeof(TestAgentThread), "some-key"); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService throws ArgumentNullException when serviceType is null. + /// + [Fact] + public void GetService_WithNullServiceType_ThrowsArgumentNullException() + { + // Arrange + var thread = new TestAgentThread(); + + // Act & Assert + Assert.Throws(() => thread.GetService(null!)); + } + + /// + /// Verify that GetService generic method works correctly. + /// + [Fact] + public void GetService_Generic_ReturnsCorrectType() + { + // Arrange + var thread = new TestAgentThread(); + + // Act + var result = thread.GetService(); + + // Assert + Assert.NotNull(result); + Assert.Same(thread, result); + } + + /// + /// Verify that GetService generic method returns null for unrelated types. + /// + [Fact] + public void GetService_Generic_ReturnsNullForUnrelatedType() + { + // Arrange + var thread = new TestAgentThread(); + + // Act + var result = thread.GetService(); + + // Assert + Assert.Null(result); + } + + #endregion + + private sealed class TestAgentThread : AgentThread + { + protected internal override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + => base.MessagesReceivedAsync(newMessages, cancellationToken); + + public override Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => base.SerializeAsync(jsonSerializerOptions, cancellationToken); + } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs index 98e50a9802..85c26adb59 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/DelegatingAIAgentTests.cs @@ -27,7 +27,7 @@ public class DelegatingAIAgentTests this._innerAgentMock = new Mock(); 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(); @@ -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 } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs new file mode 100644 index 0000000000..2e46bc67fa --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryAgentThreadTests.cs @@ -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; + +/// +/// Contains tests for . +/// +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 { 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(() => 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 messages) : base(messages) { } + public TestInMemoryAgentThread(JsonElement serializedThreadState) : base(serializedThreadState) { } + public InMemoryChatMessageStore GetMessageStore() => this.MessageStore; + public override Task SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs index 41c1982dd6..e1fec9bd19 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/InMemoryChatMessageStoreTests.cs @@ -149,7 +149,7 @@ public class InMemoryChatMessageStoreTests { // Arrange var stateWithEmptyMessages = JsonSerializer.SerializeToElement( - new Dictionary { ["Messages"] = new List() }, + new Dictionary { ["messages"] = new List() }, TestJsonSerializerContext.Default.IDictionaryStringObject); // Act @@ -164,7 +164,7 @@ public class InMemoryChatMessageStoreTests { // Arrange var stateWithNullMessages = JsonSerializer.SerializeToElement( - new Dictionary { ["Messages"] = null! }, + new Dictionary { ["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 { ["Messages"] = messages }; + var state = new Dictionary { ["messages"] = messages }; var serializedState = JsonSerializer.SerializeToElement( state, TestJsonSerializerContext.Default.DictionaryStringObject); diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs new file mode 100644 index 0000000000..4a5e28a41e --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/ServiceIdAgentThreadTests.cs @@ -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; + +/// +/// Tests for . +/// +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(() => 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 SerializeAsync(JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => base.SerializeAsync(jsonSerializerOptions, cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs index d6eeb63b7a..fb1266ea9a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentActorTests.cs @@ -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(); var mockAgent = new Mock(); - mockAgent.Setup(a => a.GetNewThread()).Returns(expectedThread); + mockAgent.Setup(a => a.GetNewThread()).Returns(mockExpectedThread.Object); var mockContext = new Mock(); 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(); var testAgent = new TestAgent(); + testAgent.ThreadForCreate = mockThread.Object; var mockContext = new Mock(); 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); } /// @@ -172,9 +175,22 @@ public class AgentActorTests /// 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { this.ThreadUsedInRunStreamingAsync = thread; diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs index 04f08289e6..4de95fa044 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/AgentProxyTests.cs @@ -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(() => @@ -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(() => @@ -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(() => @@ -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(); @@ -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(); @@ -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(); @@ -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(async () => @@ -568,7 +568,7 @@ public class AgentProxyTests var proxy = new AgentProxy("agentName", mockClient.Object); var messages = new List { new(ChatRole.User, "test") }; - var thread = proxy.GetThread(ThreadId); + var thread = proxy.GetNewThread(ThreadId); // Act & Assert await Assert.ThrowsAsync(() => @@ -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 { 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 { 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 { new(ChatRole.User, "test") }; // Act diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/AgentExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/AgentExtensionsTests.cs index c51d5f2af9..f0373a386a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/AgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/AgentExtensionsTests.cs @@ -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; } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs index 2e9192a56a..56ffbc6435 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentOptionsTests.cs @@ -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 { AIFunctionFactory.Create(() => "test") }; - static IChatMessageStore ChatMessageStoreFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock().Object; - static AIContextProvider AIContextProviderFactory(JsonElement jse, JsonSerializerOptions? jso) => new Mock().Object; + static IChatMessageStore ChatMessageStoreFactory(ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock().Object; + static AIContextProvider AIContextProviderFactory(ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => new Mock().Object; var original = new ChatClientAgentOptions(Instructions, Name, Description, tools) { diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs index 589e7055e9..cbc7fe941b 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentTests.cs @@ -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(() => 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(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); @@ -410,7 +410,7 @@ public class ChatClientAgentTests It.IsAny(), It.IsAny())).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(), It.IsAny())) .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(), It.IsAny())) .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(() => agent.RunAsync(requestMessages)); @@ -548,7 +548,7 @@ public class ChatClientAgentTests .Setup(p => p.InvokingAsync(It.IsAny(), It.IsAny())) .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 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(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(thread); + var typedThread = (ChatClientAgentThread)thread; + Assert.Same(mockContextProvider.Object, typedThread.AIContextProvider); } #endregion diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs new file mode 100644 index 0000000000..ddd8453844 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs @@ -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(() => 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(() => 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 + { + 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 + { + 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 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(() => new ChatClientAgentThread(invalidJson)); + } + + #endregion Deserialize Tests + + #region Serialize Tests + + /// + /// Verify thread serialization to JSON when the thread has an id. + /// + [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 _)); + } + + /// + /// Verify thread serialization to JSON when the thread has messages. + /// + [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 mockProvider = new(); + var providerStateElement = JsonSerializer.SerializeToElement(new[] { "CP1" }, TestJsonSerializerContext.Default.StringArray); + mockProvider + .Setup(m => m.SerializeAsync(It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Once); + } + + /// + /// Verify thread serialization to JSON with custom options. + /// + [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 { ["Key"] = "TestValue" }, + TestJsonSerializerContext.Default.DictionaryStringObject); + + var messageStoreMock = new Mock(); + messageStoreMock + .Setup(m => m.SerializeStateAsync(options, It.IsAny())) + .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()), 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 RunAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public override IAsyncEnumerable RunStreamingAsync(IEnumerable messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotImplementedException(); + + public Task SendMessagesAsync(AgentThread thread, IEnumerable messages, CancellationToken cancellationToken = default) + => NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs index b6b711f9fb..0229aea1f8 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/OpenTelemetryAgentTests.cs @@ -380,6 +380,10 @@ public class OpenTelemetryAgentTests .Build(); var mockAgent = CreateMockAgent(false); + var mockThread = new Mock(); + 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 @@ -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); diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/TestJsonSerializerContext.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/TestJsonSerializerContext.cs index d3d1f00118..3aacda0c9a 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/TestJsonSerializerContext.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/TestJsonSerializerContext.cs @@ -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))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantFixture.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantFixture.cs index 432a56f677..27a257a555 100644 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantFixture.cs +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantFixture.cs @@ -25,8 +25,9 @@ public class OpenAIAssistantFixture : IChatClientAgentFixture public async Task> GetChatHistoryAsync(AgentThread thread) { + var typedThread = (ChatClientAgentThread)thread; List 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; diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs index 29e4d14fa9..d341b80a77 100644 --- a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionFixture.cs @@ -28,8 +28,12 @@ public class OpenAIChatCompletionFixture : IChatClientAgentFixture public IChatClient ChatClient => this._agent.ChatClient; - public async Task> GetChatHistoryAsync(AgentThread thread) => - thread.MessageStore is null ? [] : (await thread.MessageStore.GetMessagesAsync()).ToList(); + public async Task> GetChatHistoryAsync(AgentThread thread) + { + var typedThread = (ChatClientAgentThread)thread; + + return typedThread.MessageStore is null ? [] : (await typedThread.MessageStore.GetMessagesAsync()).ToList(); + } public Task CreateChatClientAgentAsync( string name = "HelpfulAssistant", diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index 8df106e3aa..ed095bb8c1 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -27,10 +27,12 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture public async Task> 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((_) => new ResponseCreationOptions() { StoredOutputEnabled = store }) + RawRepresentationFactory = new Func(_ => new ResponseCreationOptions() { StoredOutputEnabled = store }) }, });