diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index eb20c40aa..138a2e5cb 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -26,7 +26,8 @@ "pirate", instructions: "You are a pirate. Speak like a pirate", description: "An agent that speaks like a pirate.", - chatClientServiceKey: "chat-model"); + chatClientServiceKey: "chat-model") + .WithInMemoryThreadStore(); builder.AddAIAgent("knights-and-knaves", (sp, key) => { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs index fadc78ed9..2b32e2a98 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs @@ -22,9 +22,10 @@ public static class WebApplicationExtensions public static void MapA2A(this WebApplication app, string agentName, string path) { var agent = app.Services.GetRequiredKeyedService(agentName); + var agentThreadStore = app.Services.GetKeyedService(agentName); var loggerFactory = app.Services.GetRequiredService(); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentThreadStore: agentThreadStore); app.MapA2A(taskManager, path); } @@ -42,9 +43,10 @@ public static void MapA2A( AgentCard agentCard) { var agent = app.Services.GetRequiredKeyedService(agentName); + var agentThreadStore = app.Services.GetKeyedService(agentName); var loggerFactory = app.Services.GetRequiredService(); - var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory); + var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory, agentThreadStore: agentThreadStore); app.MapA2A(taskManager, path); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 81d33b550..e95850ab0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -20,29 +20,37 @@ public static class AIAgentExtensions /// Agent to attach A2A messaging processing capabilities to. /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. + /// The store to store thread contents and metadata. /// The configured . public static TaskManager MapA2A( this AIAgent agent, TaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IAgentThreadStore? agentThreadStore = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); - taskManager ??= new(); + var hostAgent = new AIHostAgent( + innerAgent: agent, + threadStore: agentThreadStore ?? new NoContextAgentThreadStore()); + taskManager ??= new(); taskManager.OnMessageReceived += OnMessageReceivedAsync; - return taskManager; async Task OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) { - var response = await agent.RunAsync( + var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); + var thread = await hostAgent.RestoreThreadAsync(contextId, cancellationToken).ConfigureAwait(false); + + var response = await hostAgent.RunAsync( messageSendParams.ToChatMessages(), + thread: thread, cancellationToken: cancellationToken).ConfigureAwait(false); - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var parts = response.Messages.ToParts(); + await hostAgent.SaveThreadAsync(contextId, thread!, cancellationToken).ConfigureAwait(false); + var parts = response.Messages.ToParts(); return new AgentMessage { MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), @@ -60,14 +68,16 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara /// The agent card to return on query. /// Instance of to configure for A2A messaging. New instance will be created if not passed. /// The logger factory to use for creating instances. + /// The store to store thread contents and metadata. /// The configured . public static TaskManager MapA2A( this AIAgent agent, AgentCard agentCard, TaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null) + ILoggerFactory? loggerFactory = null, + IAgentThreadStore? agentThreadStore = null) { - taskManager = agent.MapA2A(taskManager, loggerFactory); + taskManager = agent.MapA2A(taskManager, loggerFactory, agentThreadStore); taskManager.OnAgentCardQuery += (context, query) => { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs new file mode 100644 index 000000000..c92f4b5da --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides a hosting wrapper around an that adds thread persistence capabilities +/// for server-hosted scenarios where conversations need to be restored across requests. +/// +/// +/// +/// wraps an existing agent implementation and adds the ability to +/// persist and restore conversation threads using an . +/// +/// +/// This wrapper enables thread persistence without requiring type-specific knowledge of the thread type, +/// as all thread operations work through the base abstraction. +/// +/// +public class AIHostAgent : AIAgent +{ + private readonly AIAgent _innerAgent; + private readonly IAgentThreadStore _threadStore; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent implementation to wrap. + /// The thread store to use for persisting conversation state. + /// + /// or is . + /// + public AIHostAgent(AIAgent innerAgent, IAgentThreadStore threadStore) + { + this._innerAgent = Throw.IfNull(innerAgent); + this._threadStore = Throw.IfNull(threadStore); + } + + /// + public override string Id => this._innerAgent.Id; + + /// + public override string? Name => this._innerAgent.Name; + + /// + public override string? Description => this._innerAgent.Description; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + => base.GetService(serviceType, serviceKey) ?? this._innerAgent.GetService(serviceType, serviceKey); + + /// + public override AgentThread GetNewThread() => this._innerAgent.GetNewThread(); + + /// + public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + => this._innerAgent.DeserializeThread(serializedThread, jsonSerializerOptions); + + /// + /// Restores a conversation thread from the thread store using the specified conversation ID. + /// + /// The unique identifier of the conversation to restore. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the restored thread, + /// or if no thread with the given ID exists in the store. + /// + /// is null or whitespace. + public async ValueTask RestoreThreadAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrWhitespace(conversationId); + + var serializedThread = await this._threadStore.GetOrCreateThreadAsync( + conversationId, + this.Id, + cancellationToken).ConfigureAwait(false); + + if (serializedThread is null) + { + return null; + } + + return this._innerAgent.DeserializeThread(serializedThread.Value); + } + + /// + /// Persists a conversation thread to the thread store. + /// + /// The unique identifier for the conversation. + /// The thread to persist. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + /// is null or whitespace. + /// is . + public async ValueTask SaveThreadAsync( + string conversationId, + AgentThread thread, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(thread); + + await this._threadStore.SaveThreadAsync( + conversationId, + this.Id, + thread, + cancellationToken).ConfigureAwait(false); + } + + /// + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this._innerAgent.RunAsync(messages, thread, options, cancellationToken); + + /// + public override IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this._innerAgent.RunStreamingAsync(messages, thread, options, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs new file mode 100644 index 000000000..ecd09ab8b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for configuring . +/// +public static class HostedAgentBuilderExtensions +{ + /// + /// Configures the host agent builder to use an in-memory thread store for agent thread management. + /// + /// The host agent builder to configure with the in-memory thread store. + /// The same instance, configured to use an in-memory thread store. + public static IHostedAgentBuilder WithInMemoryThreadStore(this IHostedAgentBuilder builder) + { + builder.HostApplicationBuilder.Services.AddKeyedSingleton(builder.Name, new InMemoryAgentThreadStore()); + return builder; + } + + /// + /// Registers the specified agent thread store with the host agent builder, enabling thread-specific storage for + /// agent operations. + /// + /// The host agent builder to configure with the thread store. Cannot be null. + /// The agent thread store instance to register. Cannot be null. + /// The same host agent builder instance, allowing for method chaining. + public static IHostedAgentBuilder WithThreadStore(this IHostedAgentBuilder builder, IAgentThreadStore store) + { + builder.HostApplicationBuilder.Services.AddKeyedSingleton(builder.Name, store); + return builder; + } + + /// + /// Configures the host agent builder to use a custom thread store implementation for agent threads. + /// + /// The host agent builder to configure. + /// A factory function that creates an agent thread store instance using the provided service provider and agent + /// name. + /// The same host agent builder instance, enabling further configuration. + public static IHostedAgentBuilder WithThreadStore(this IHostedAgentBuilder builder, Func createAgentThreadStore) + { + builder.HostApplicationBuilder.Services.AddKeyedSingleton(builder.Name, (sp, key) => + { + Throw.IfNull(key); + var keyString = key as string; + Throw.IfNullOrEmpty(keyString); + var store = createAgentThreadStore(sp, keyString); + if (store is null) + { + throw new InvalidOperationException($"The agent thread store factory did not return a valid {nameof(IAgentThreadStore)} instance for key '{keyString}'."); + } + + return store; + }); + return builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IAgentThreadStore.cs new file mode 100644 index 000000000..c8ba2fcd4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IAgentThreadStore.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Defines the contract for storing and retrieving agent conversation threads. +/// +/// +/// Implementations of this interface enable persistent storage of conversation threads, +/// allowing conversations to be resumed across HTTP requests, application restarts, +/// or different service instances in hosted scenarios. +/// +public interface IAgentThreadStore +{ + /// + /// Saves a serialized agent thread to persistent storage. + /// + /// The unique identifier for the conversation/thread. + /// The identifier of the agent that owns this thread. + /// The thread to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + /// + /// The is used as the primary key for thread lookup. + /// The provides additional scoping to prevent cross-agent thread access. + /// + ValueTask SaveThreadAsync( + string conversationId, + string agentId, + AgentThread thread, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a serialized agent thread from persistent storage. + /// + /// The unique identifier for the conversation/thread to retrieve. + /// The identifier of the agent that owns this thread. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous retrieval operation. + /// The task result contains the serialized thread state, or if not found. + /// + ValueTask GetOrCreateThreadAsync( + string conversationId, + string agentId, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentThreadStore.cs new file mode 100644 index 000000000..2f61227b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentThreadStore.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides an in-memory implementation of for development and testing scenarios. +/// +/// +/// +/// This implementation stores threads in memory using a concurrent dictionary and is suitable for: +/// +/// Single-instance development scenarios +/// Testing and prototyping +/// Scenarios where thread persistence across restarts is not required +/// +/// +/// +/// Warning: All stored threads will be lost when the application restarts. +/// For production use with multiple instances or persistence across restarts, use a durable storage implementation +/// such as Redis, SQL Server, or Azure Cosmos DB. +/// +/// +public sealed class InMemoryAgentThreadStore : IAgentThreadStore +{ + private readonly ConcurrentDictionary _threads = new(); + + /// + public ValueTask SaveThreadAsync( + string conversationId, + string agentId, + AgentThread thread, + CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agentId); + this._threads[key] = thread.Serialize(); + return default; + } + + /// + public ValueTask GetOrCreateThreadAsync( + string conversationId, + string agentId, + CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agentId); + var threadContent = this._threads.GetOrAdd(key, value: JsonDocument.Parse("{}").RootElement); + return new ValueTask(threadContent); + } + + private static string GetKey(string conversationId, string agentId) + => $"{agentId}:{conversationId}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoContextAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoContextAgentThreadStore.cs new file mode 100644 index 000000000..3700b6304 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoContextAgentThreadStore.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// This store implementation does not have any store under the hood and operates with empty threads. +/// It the "noop" store, and could be used if you are keeping the thread contents on the client side for example. +/// +public sealed class NoContextAgentThreadStore : IAgentThreadStore +{ + /// + public ValueTask GetOrCreateThreadAsync(string conversationId, string agentId, CancellationToken cancellationToken = default) + { + // this is OK, Agents should be prepared to handle null threads. + return new ValueTask(result: null!); + } + + /// + public ValueTask SaveThreadAsync(string conversationId, string agentId, AgentThread thread, CancellationToken cancellationToken = default) + { + return new(); + } +}