.NET: [BREAKING] Change GetNewThread and DeserializeThread to async (#3152)

* Change GetNewThread and DeserializeThread plus ChatMessageStore and AIContextProvider Factories to async

* Merge fixes
This commit is contained in:
westey
2026-01-12 11:25:51 +00:00
committed by GitHub
parent 6e3bc219e0
commit bb6ecd9c71
113 changed files with 538 additions and 477 deletions
+11 -11
View File
@@ -52,27 +52,27 @@ public sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
public sealed override AgentThread GetNewThread()
=> new A2AAgentThread();
public sealed override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(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 };
/// <returns>A value task representing the asynchronous operation. The task result contains a new <see cref="AgentThread"/> instance.</returns>
public ValueTask<AgentThread> GetNewThreadAsync(string contextId)
=> new(new A2AAgentThread() { ContextId = contextId });
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new A2AAgentThread(serializedThread, jsonSerializerOptions);
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new A2AAgentThread(serializedThread, jsonSerializerOptions));
/// <inheritdoc/>
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
A2AAgentThread typedThread = this.GetA2AThread(thread, options);
A2AAgentThread typedThread = await this.GetA2AThreadAsync(thread, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
@@ -139,7 +139,7 @@ public sealed class A2AAgent : AIAgent
{
_ = Throw.IfNull(messages);
A2AAgentThread typedThread = this.GetA2AThread(thread, options);
A2AAgentThread typedThread = await this.GetA2AThreadAsync(thread, options, cancellationToken).ConfigureAwait(false);
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
@@ -211,7 +211,7 @@ public sealed class A2AAgent : AIAgent
/// <inheritdoc/>
public override string? Description => this._description;
private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options)
private async ValueTask<A2AAgentThread> GetA2AThreadAsync(AgentThread? thread, AgentRunOptions? options, CancellationToken cancellationToken)
{
// Aligning with other agent implementations that support background responses, where
// a thread is required for background responses to prevent inconsistent experience
@@ -221,7 +221,7 @@ public sealed class A2AAgent : AIAgent
throw new InvalidOperationException("A thread must be provided when AllowBackgroundResponses is enabled.");
}
thread ??= this.GetNewThread();
thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
if (thread is not A2AAgentThread typedThread)
{
@@ -105,7 +105,8 @@ public abstract class AIAgent
/// <summary>
/// Creates a new conversation thread that is compatible with this agent.
/// </summary>
/// <returns>A new <see cref="AgentThread"/> instance ready for use with this agent.</returns>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a new <see cref="AgentThread"/> instance ready for use with this agent.</returns>
/// <remarks>
/// <para>
/// This method creates a fresh conversation thread that can be used to maintain state
@@ -118,14 +119,15 @@ public abstract class AIAgent
/// may be deferred until first use to optimize performance.
/// </para>
/// </remarks>
public abstract AgentThread GetNewThread();
public abstract ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Deserializes an agent thread from its JSON serialized representation.
/// </summary>
/// <param name="serializedThread">A <see cref="JsonElement"/> containing the serialized thread state.</param>
/// <param name="jsonSerializerOptions">Optional settings to customize the deserialization process.</param>
/// <returns>A restored <see cref="AgentThread"/> instance with the state from <paramref name="serializedThread"/>.</returns>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a restored <see cref="AgentThread"/> instance with the state from <paramref name="serializedThread"/>.</returns>
/// <exception cref="ArgumentException">The <paramref name="serializedThread"/> is not in the expected format.</exception>
/// <exception cref="JsonException">The serialized data is invalid or cannot be deserialized.</exception>
/// <remarks>
@@ -133,7 +135,7 @@ public abstract class AIAgent
/// allowing conversations to resume across application restarts or be migrated between
/// different agent instances.
/// </remarks>
public abstract AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null);
public abstract ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default);
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
@@ -26,8 +26,8 @@ namespace Microsoft.Agents.AI;
/// <item><description>Chat history reduction, e.g. where messages needs to be summarized or truncated to reduce the size.</description></item>
/// </list>
/// An <see cref="AgentThread"/> is always constructed by an <see cref="AIAgent"/> so that the <see cref="AIAgent"/>
/// can attach any necessary behaviors to the <see cref="AgentThread"/>. See the <see cref="AIAgent.GetNewThread()"/>
/// and <see cref="AIAgent.DeserializeThread(JsonElement, JsonSerializerOptions?)"/> methods for more information.
/// can attach any necessary behaviors to the <see cref="AgentThread"/>. See the <see cref="AIAgent.GetNewThreadAsync(System.Threading.CancellationToken)"/>
/// and <see cref="AIAgent.DeserializeThreadAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> methods for more information.
/// </para>
/// <para>
/// Because of these behaviors, an <see cref="AgentThread"/> may not be reusable across different agents, since each agent
@@ -37,13 +37,13 @@ namespace Microsoft.Agents.AI;
/// To support conversations that may need to survive application restarts or separate service requests, an <see cref="AgentThread"/> can be serialized
/// and deserialized, so that it can be saved in a persistent store.
/// The <see cref="AgentThread"/> provides the <see cref="Serialize(JsonSerializerOptions?)"/> method to serialize the thread to a
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeThread(JsonElement, JsonSerializerOptions?)"/> method
/// <see cref="JsonElement"/> and the <see cref="AIAgent.DeserializeThreadAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/> method
/// can be used to deserialize the thread.
/// </para>
/// </remarks>
/// <seealso cref="AIAgent"/>
/// <seealso cref="AIAgent.GetNewThread()"/>
/// <seealso cref="AIAgent.DeserializeThread(JsonElement, JsonSerializerOptions?)"/>
/// <seealso cref="AIAgent.GetNewThreadAsync(System.Threading.CancellationToken)"/>
/// <seealso cref="AIAgent.DeserializeThreadAsync(JsonElement, JsonSerializerOptions?, System.Threading.CancellationToken)"/>
public abstract class AgentThread
{
/// <summary>
@@ -74,11 +74,11 @@ public abstract class DelegatingAIAgent : AIAgent
}
/// <inheritdoc />
public override AgentThread GetNewThread() => this.InnerAgent.GetNewThread();
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) => this.InnerAgent.GetNewThreadAsync(cancellationToken);
/// <inheritdoc />
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.DeserializeThreadAsync(serializedThread, jsonSerializerOptions, cancellationToken);
/// <inheritdoc />
protected override Task<AgentRunResponse> RunCoreAsync(
@@ -42,20 +42,20 @@ public class CopilotStudioAgent : AIAgent
}
/// <inheritdoc/>
public sealed override AgentThread GetNewThread()
=> new CopilotStudioAgentThread();
public sealed override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(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 };
public ValueTask<AgentThread> GetNewThreadAsync(string conversationId)
=> new(new CopilotStudioAgentThread() { ConversationId = conversationId });
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions);
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions));
/// <inheritdoc/>
protected override async Task<AgentRunResponse> RunCoreAsync(
@@ -68,7 +68,7 @@ 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 ??= await this.GetNewThreadAsync(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.");
@@ -106,7 +106,8 @@ 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 ??= await this.GetNewThreadAsync(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.");
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Azure.Cosmos;
@@ -35,7 +36,7 @@ public static class CosmosDBChatExtensions
throw new ArgumentNullException(nameof(options));
}
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(connectionString, databaseId, containerId);
options.ChatMessageStoreFactory = (context, ct) => new ValueTask<ChatMessageStore>(new CosmosChatMessageStore(connectionString, databaseId, containerId));
return options;
}
@@ -62,7 +63,7 @@ public static class CosmosDBChatExtensions
throw new ArgumentNullException(nameof(options));
}
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId);
options.ChatMessageStoreFactory = (context, ct) => new ValueTask<ChatMessageStore>(new CosmosChatMessageStore(accountEndpoint, new DefaultAzureCredential(), databaseId, containerId));
return options;
}
@@ -89,7 +90,7 @@ public static class CosmosDBChatExtensions
throw new ArgumentNullException(nameof(options));
}
options.ChatMessageStoreFactory = context => new CosmosChatMessageStore(cosmosClient, databaseId, containerId);
options.ChatMessageStoreFactory = (context, ct) => new ValueTask<ChatMessageStore>(new CosmosChatMessageStore(cosmosClient, databaseId, containerId));
return options;
}
}
@@ -67,7 +67,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
// Start the agent response stream
IAsyncEnumerable<AgentRunResponseUpdate> responseStream = agentWrapper.RunStreamingAsync(
this.State.Data.ConversationHistory.SelectMany(e => e.Messages).Select(m => m.ToChatMessage()),
agentWrapper.GetNewThread(),
await agentWrapper.GetNewThreadAsync(cancellationToken).ConfigureAwait(false),
options: null,
this._cancellationToken);
@@ -32,11 +32,12 @@ public sealed class DurableAIAgent : AIAgent
/// <summary>
/// Creates a new agent thread for this agent using a random session ID.
/// </summary>
/// <returns>A new agent thread.</returns>
public override AgentThread GetNewThread()
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains a new agent thread.</returns>
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
{
AgentSessionId sessionId = this._context.NewAgentSessionId(this._agentName);
return new DurableAgentThread(sessionId);
return ValueTask.FromResult<AgentThread>(new DurableAgentThread(sessionId));
}
/// <summary>
@@ -44,12 +45,13 @@ public sealed class DurableAIAgent : AIAgent
/// </summary>
/// <param name="serializedThread">The serialized thread data.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options.</param>
/// <returns>The deserialized agent thread.</returns>
public override AgentThread DeserializeThread(
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A value task that represents the asynchronous operation. The task result contains the deserialized agent thread.</returns>
public override ValueTask<AgentThread> DeserializeThreadAsync(
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
return ValueTask.FromResult<AgentThread>(DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions));
}
/// <summary>
@@ -74,12 +76,12 @@ public sealed class DurableAIAgent : AIAgent
throw new NotSupportedException("Cancellation is not supported for durable agents.");
}
thread ??= this.GetNewThread();
thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
if (thread is not DurableAgentThread durableThread)
{
throw new ArgumentException(
"The provided thread is not valid for a durable agent. " +
"Create a new thread using GetNewThread or provide a thread previously created by this agent.",
"Create a new thread using GetNewThreadAsync or provide a thread previously created by this agent.",
paramName: nameof(thread));
}
@@ -11,16 +11,16 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
public override string? Name { get; } = name;
public override AgentThread DeserializeThread(
public override ValueTask<AgentThread> DeserializeThreadAsync(
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null)
JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions);
return ValueTask.FromResult<AgentThread>(DurableAgentThread.Deserialize(serializedThread, jsonSerializerOptions));
}
public override AgentThread GetNewThread()
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
{
return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!));
return ValueTask.FromResult<AgentThread>(new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!)));
}
protected override async Task<AgentRunResponse> RunCoreAsync(
@@ -29,7 +29,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
thread ??= this.GetNewThread();
thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
if (thread is not DurableAgentThread durableThread)
{
throw new ArgumentException(
@@ -74,7 +74,7 @@ public static async Task<string> SpamDetectionOrchestration(
// Get the spam detection agent
DurableAIAgent spamDetectionAgent = context.GetAgent("SpamDetectionAgent");
AgentThread spamThread = spamDetectionAgent.GetNewThread();
AgentThread spamThread = await spamDetectionAgent.GetNewThreadAsync();
// Step 1: Check if the email is spam
AgentRunResponse<DetectionResult> spamDetectionResponse = await spamDetectionAgent.RunAsync<DetectionResult>(
@@ -97,7 +97,7 @@ public static async Task<string> SpamDetectionOrchestration(
{
// Generate and send response for legitimate email
DurableAIAgent emailAssistantAgent = context.GetAgent("EmailAssistantAgent");
AgentThread emailThread = emailAssistantAgent.GetNewThread();
AgentThread emailThread = await emailAssistantAgent.GetNewThreadAsync();
AgentRunResponse<EmailResponse> emailAssistantResponse = await emailAssistantAgent.RunAsync<EmailResponse>(
message:
@@ -38,15 +38,15 @@ public sealed class InMemoryAgentThreadStore : AgentThreadStore
}
/// <inheritdoc/>
public override ValueTask<AgentThread> GetThreadAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
public override async ValueTask<AgentThread> GetThreadAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
var key = GetKey(conversationId, agent.Id);
JsonElement? threadContent = this._threads.TryGetValue(key, out var existingThread) ? existingThread : null;
return threadContent switch
{
null => new ValueTask<AgentThread>(agent.GetNewThread()),
_ => new ValueTask<AgentThread>(agent.DeserializeThread(threadContent.Value)),
null => await agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false),
_ => await agent.DeserializeThreadAsync(threadContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false),
};
}
@@ -20,6 +20,6 @@ public sealed class NoopAgentThreadStore : AgentThreadStore
/// <inheritdoc/>
public override ValueTask<AgentThread> GetThreadAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
return new ValueTask<AgentThread>(agent.GetNewThread());
return agent.GetNewThreadAsync(cancellationToken);
}
}
@@ -30,15 +30,15 @@ internal class PurviewAgent : AIAgent, IDisposable
}
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
return this._innerAgent.DeserializeThreadAsync(serializedThread, jsonSerializerOptions, cancellationToken);
}
/// <inheritdoc/>
public override AgentThread GetNewThread()
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
{
return this._innerAgent.GetNewThread();
return this._innerAgent.GetNewThreadAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -20,8 +20,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
this._emitEvents = emitEvents;
}
private AgentThread EnsureThread(IWorkflowContext context) =>
this._thread ??= this._agent.GetNewThread();
private async Task<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
private const string ThreadStateKey = nameof(_thread);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
@@ -43,7 +43,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (threadValue.HasValue)
{
this._thread = this._agent.DeserializeThread(threadValue.Value);
this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
}
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
@@ -54,7 +54,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
if (emitEvents ?? this._emitEvents)
{
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentRunResponseUpdate> agentStream = this._agent.RunStreamingAsync(messages, this.EnsureThread(context), cancellationToken: cancellationToken);
IAsyncEnumerable<AgentRunResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken);
List<AgentRunResponseUpdate> updates = [];
@@ -74,7 +77,10 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
else
{
// Otherwise, run the agent in non-streaming mode.
AgentRunResponse response = await this._agent.RunAsync(messages, this.EnsureThread(context), cancellationToken: cancellationToken).ConfigureAwait(false);
AgentRunResponse response = await this._agent.RunAsync(
messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
@@ -63,14 +63,15 @@ internal sealed class WorkflowHostAgent : AIAgent
protocol.ThrowIfNotChatProtocol();
}
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails);
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails));
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions);
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions));
private ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
private async ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
{
thread ??= this.GetNewThread();
thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
if (thread is not WorkflowThread workflowThread)
{
@@ -80,7 +81,7 @@ internal sealed class WorkflowHostAgent : AIAgent
// For workflow threads, messages are added directly via the internal AddMessages method
// The MessageStore methods are used for agent invocation scenarios
workflowThread.MessageStore.AddMessages(messages);
return new ValueTask<WorkflowThread>(workflowThread);
return workflowThread;
}
protected override async
@@ -283,7 +283,7 @@ public sealed partial class ChatClientAgent : AIAgent
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
await this.UpdateThreadWithTypeAndConversationIdAsync(safeThread, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
@@ -302,19 +302,30 @@ public sealed partial class ChatClientAgent : AIAgent
: this.ChatClient.GetService(serviceType, serviceKey));
/// <inheritdoc/>
public override AgentThread GetNewThread()
=> new ChatClientAgentThread
public override async ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
{
ChatMessageStore? messageStore = this._agentOptions?.ChatMessageStoreFactory is not null
? await this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
: null;
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
: null;
return new ChatClientAgentThread
{
MessageStore = this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
MessageStore = messageStore,
AIContextProvider = contextProvider
};
}
/// <summary>
/// Creates a new agent thread instance using an existing conversation identifier to continue that conversation.
/// </summary>
/// <param name="conversationId">The identifier of an existing conversation to continue.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A new <see cref="AgentThread"/> instance configured to work with the specified conversation.
/// A value task representing the asynchronous operation. The task result contains a new <see cref="AgentThread"/> instance configured to work with the specified conversation.
/// </returns>
/// <remarks>
/// <para>
@@ -326,19 +337,26 @@ public sealed partial class ChatClientAgent : AIAgent
/// instances that support server-side conversation storage through their underlying <see cref="IChatClient"/>.
/// </para>
/// </remarks>
public AgentThread GetNewThread(string conversationId)
=> new ChatClientAgentThread()
public async ValueTask<AgentThread> GetNewThreadAsync(string conversationId, CancellationToken cancellationToken = default)
{
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
: null;
return new ChatClientAgentThread()
{
ConversationId = conversationId,
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
AIContextProvider = contextProvider
};
}
/// <summary>
/// Creates a new agent thread instance using an existing <see cref="ChatMessageStore"/> to continue a conversation.
/// </summary>
/// <param name="chatMessageStore">The <see cref="ChatMessageStore"/> instance to use for managing the conversation's message history.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
/// A new <see cref="AgentThread"/> instance configured to work with the provided <paramref name="chatMessageStore"/>.
/// A value task representing the asynchronous operation. The task result contains a new <see cref="AgentThread"/> instance configured to work with the provided <paramref name="chatMessageStore"/>.
/// </returns>
/// <remarks>
/// <para>
@@ -347,36 +365,43 @@ public sealed partial class ChatClientAgent : AIAgent
/// with a <see cref="ChatMessageStore"/> may not be compatible with these services.
/// </para>
/// <para>
/// Where a service requires server-side conversation storage, use <see cref="GetNewThread(string)"/>.
/// Where a service requires server-side conversation storage, use <see cref="GetNewThreadAsync(string, CancellationToken)"/>.
/// </para>
/// <para>
/// If the agent detects, during the first run, that the underlying AI service requires server-side conversation storage,
/// the thread will throw an exception to indicate that it cannot continue using the provided <see cref="ChatMessageStore"/>.
/// </para>
/// </remarks>
public AgentThread GetNewThread(ChatMessageStore chatMessageStore)
=> new ChatClientAgentThread()
public async ValueTask<AgentThread> GetNewThreadAsync(ChatMessageStore chatMessageStore, CancellationToken cancellationToken = default)
{
AIContextProvider? contextProvider = this._agentOptions?.AIContextProviderFactory is not null
? await this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
: null;
return new ChatClientAgentThread()
{
MessageStore = Throw.IfNull(chatMessageStore),
AIContextProvider = this._agentOptions?.AIContextProviderFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null })
AIContextProvider = contextProvider
};
}
/// <inheritdoc/>
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
public override async ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
Func<JsonElement, JsonSerializerOptions?, ChatMessageStore>? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ?
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatMessageStore>>? chatMessageStoreFactory = this._agentOptions?.ChatMessageStoreFactory is null ?
null :
(jse, jso) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
(jse, jso, ct) => this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }, ct);
Func<JsonElement, JsonSerializerOptions?, AIContextProvider>? aiContextProviderFactory = this._agentOptions?.AIContextProviderFactory is null ?
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<AIContextProvider>>? aiContextProviderFactory = this._agentOptions?.AIContextProviderFactory is null ?
null :
(jse, jso) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso });
(jse, jso, ct) => this._agentOptions.AIContextProviderFactory.Invoke(new() { SerializedState = jse, JsonSerializerOptions = jso }, ct);
return new ChatClientAgentThread(
return await ChatClientAgentThread.DeserializeAsync(
serializedThread,
jsonSerializerOptions,
chatMessageStoreFactory,
aiContextProviderFactory);
aiContextProviderFactory,
cancellationToken).ConfigureAwait(false);
}
#region Private
@@ -426,7 +451,7 @@ public sealed partial class ChatClientAgent : AIAgent
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
await this.UpdateThreadWithTypeAndConversationIdAsync(safeThread, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false);
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
@@ -653,7 +678,7 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("A thread must be provided when continuing a background response with a continuation token.");
}
thread ??= this.GetNewThread();
thread ??= await this.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
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.");
@@ -735,13 +760,13 @@ public sealed partial class ChatClientAgent : AIAgent
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages, continuationToken);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
private async Task UpdateThreadWithTypeAndConversationIdAsync(ChatClientAgentThread thread, string? responseConversationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(responseConversationId) && !string.IsNullOrWhiteSpace(thread.ConversationId))
{
// We were passed a thread that is service managed, but we got no conversation id back from the chat client,
// meaning the service doesn't support service managed threads, so the thread cannot be used with this service.
throw new InvalidOperationException("Service did not return a valid conversation id when using a service managed thread.");
// We were passed an AgentThread that has an id for service managed chat history, but we got no conversation id back from the chat client,
// meaning the service doesn't support service managed chat history, so the thread cannot be used with this service.
throw new InvalidOperationException("Service did not return a valid conversation id when using an AgentThread with service managed chat history.");
}
if (!string.IsNullOrWhiteSpace(responseConversationId))
@@ -752,10 +777,12 @@ public sealed partial class ChatClientAgent : AIAgent
}
else
{
// If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
// If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and
// the thread has no MessageStore yet, we should update the thread with the custom MessageStore or
// default InMemoryMessageStore so that it has somewhere to store the chat history.
thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory?.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }) ?? new InMemoryChatMessageStore();
thread.MessageStore ??= this._agentOptions?.ChatMessageStoreFactory is not null
? await this._agentOptions.ChatMessageStoreFactory.Invoke(new() { SerializedState = default, JsonSerializerOptions = null }, cancellationToken).ConfigureAwait(false)
: new InMemoryChatMessageStore();
}
}
@@ -2,6 +2,8 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -40,14 +42,14 @@ public sealed class ChatClientAgentOptions
/// Gets or sets a factory function to create an instance of <see cref="ChatMessageStore"/>
/// which will be used to store chat messages for this agent.
/// </summary>
public Func<ChatMessageStoreFactoryContext, ChatMessageStore>? ChatMessageStoreFactory { get; set; }
public Func<ChatMessageStoreFactoryContext, CancellationToken, ValueTask<ChatMessageStore>>? 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<AIContextProviderFactoryContext, AIContextProvider>? AIContextProviderFactory { get; set; }
public Func<AIContextProviderFactoryContext, CancellationToken, ValueTask<AIContextProvider>>? AIContextProviderFactory { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to use the provided <see cref="IChatClient"/> instance as is,
@@ -3,6 +3,8 @@
using System;
using System.Diagnostics;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -22,48 +24,6 @@ public sealed class ChatClientAgentThread : AgentThread
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentThread"/> class from previously 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="ChatMessageStore"/> from its serialized state.
/// If not provided, the default in-memory message store will be used.
/// </param>
/// <param name="aiContextProviderFactory">
/// An optional factory function to create a custom <see cref="AIContextProvider"/> from its serialized state.
/// If not provided, no context provider will be configured.
/// </param>
internal ChatClientAgentThread(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, ChatMessageStore>? 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 = serializedThreadState.Deserialize(
AgentJsonUtilities.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) ??
new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); // default to an in-memory store
}
/// <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>
@@ -152,6 +112,58 @@ public sealed class ChatClientAgentThread : AgentThread
/// </summary>
public AIContextProvider? AIContextProvider { get; internal set; }
/// <summary>
/// Creates a new instance of the <see cref="ChatClientAgentThread"/> class from previously 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="ChatMessageStore"/> from its serialized state.
/// If not provided, the default in-memory message store will be used.
/// </param>
/// <param name="aiContextProviderFactory">
/// An optional factory function to create a custom <see cref="AIContextProvider"/> from its serialized state.
/// If not provided, no context provider will be configured.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation. The task result contains the deserialized <see cref="ChatClientAgentThread"/>.</returns>
internal static async Task<ChatClientAgentThread> DeserializeAsync(
JsonElement serializedThreadState,
JsonSerializerOptions? jsonSerializerOptions = null,
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<ChatMessageStore>>? chatMessageStoreFactory = null,
Func<JsonElement, JsonSerializerOptions?, CancellationToken, ValueTask<AIContextProvider>>? aiContextProviderFactory = null,
CancellationToken cancellationToken = default)
{
if (serializedThreadState.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState));
}
var state = serializedThreadState.Deserialize(
AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState;
var thread = new ChatClientAgentThread();
thread.AIContextProvider = aiContextProviderFactory is not null
? await aiContextProviderFactory.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions, cancellationToken).ConfigureAwait(false)
: null;
if (state?.ConversationId is string threadId)
{
thread.ConversationId = threadId;
// Since we have an ID, we should not have a chat message store and we can return here.
return thread;
}
thread._messageStore =
chatMessageStoreFactory is not null
? await chatMessageStoreFactory.Invoke(state?.StoreState ?? default, jsonSerializerOptions, cancellationToken).ConfigureAwait(false)
: new InMemoryChatMessageStore(state?.StoreState ?? default, jsonSerializerOptions); // default to an in-memory store
return thread;
}
/// <inheritdoc/>
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{