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