diff --git a/dotnet/samples/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/A2AClientServer/A2AServer/Program.cs index e4f619dc54..bd344c46c8 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/A2AClientServer/A2AServer/Program.cs @@ -104,7 +104,10 @@ else throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentId must be provided"); } -var a2aTaskManager = app.MapA2A(hostA2AAgent, path: "/", agentCard: hostA2AAgentCard); -app.MapWellKnownAgentCard(a2aTaskManager, "/"); +var a2aTaskManager = app.MapA2A( + hostA2AAgent, + path: "/", + agentCard: hostA2AAgentCard, + taskManager => app.MapWellKnownAgentCard(taskManager, "/")); await app.RunAsync(); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index da27df46e8..571b07b1d5 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -24,7 +24,8 @@ builder.AddAIAgent( "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/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index 85ef1e3e2c..cae9801148 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using A2A; using A2A.AspNetCore; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.A2A; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -23,10 +25,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// Configured for A2A integration. - public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) + => endpoints.MapA2A(agentName, path, _ => { }); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// The callback to configure . + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager) { var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path); + return endpoints.MapA2A(agent, path, configureTaskManager); } /// @@ -42,10 +55,27 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) + => endpoints.MapA2A(agentName, path, agentCard, _ => { }); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// The callback to configure . + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) { var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard); + return endpoints.MapA2A(agent, path, agentCard, configureTaskManager); } /// @@ -55,11 +85,26 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// Configured for A2A integration. - public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + => endpoints.MapA2A(agent, path, _ => { }); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// The callback to configure . + /// Configured for A2A integration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) { var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory); - return endpoints.MapA2A(taskManager, path); + var agentThreadStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentThreadStore: agentThreadStore); + var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); + + configureTaskManager(taskManager); + return endpointConventionBuilder; } /// @@ -75,11 +120,33 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// - public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) + => endpoints.MapA2A(agent, path, agentCard, _ => { }); + + /// + /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route group to use for A2A endpoints. + /// Agent card info to return on query. + /// The callback to configure . + /// Configured for A2A integration. + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) { var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory); - return endpoints.MapA2A(taskManager, path); + var agentThreadStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); + var taskManager = agent.MapA2A(agentCard: agentCard, agentThreadStore: agentThreadStore, loggerFactory: loggerFactory); + var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); + + configureTaskManager(taskManager); + + return endpointConventionBuilder; } /// @@ -90,14 +157,12 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// Pre-configured A2A TaskManager to use for A2A endpoints handling. /// The route group to use for A2A endpoints. /// Configured for A2A integration. - public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, TaskManager taskManager, string path) + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path) { // note: current SDK version registers multiple `.well-known/agent.json` handlers here. // it makes app return HTTP 500, but will be fixed once new A2A SDK is released. // see https://github.com/microsoft/agent-framework/issues/476 for details A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path); - endpoints.MapHttpA2A(taskManager, path); - - return taskManager; + return endpoints.MapHttpA2A(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 81d33b5506..43376d8fb2 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( + public static ITaskManager MapA2A( this AIAgent agent, - TaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null) + ITaskManager? taskManager = null, + ILoggerFactory? loggerFactory = null, + AgentThreadStore? agentThreadStore = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); - taskManager ??= new(); + var hostAgent = new AIHostAgent( + innerAgent: agent, + threadStore: agentThreadStore ?? new NoopAgentThreadStore()); + taskManager ??= new TaskManager(); taskManager.OnMessageReceived += OnMessageReceivedAsync; - return taskManager; async Task OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) { - var response = await agent.RunAsync( - messageSendParams.ToChatMessages(), - cancellationToken: cancellationToken).ConfigureAwait(false); var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var parts = response.Messages.ToParts(); + var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false); + var response = await hostAgent.RunAsync( + messageSendParams.ToChatMessages(), + thread: thread, + cancellationToken: cancellationToken).ConfigureAwait(false); + + 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 @@ public static class AIAgentExtensions /// 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( + public static ITaskManager MapA2A( this AIAgent agent, AgentCard agentCard, - TaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null) + ITaskManager? taskManager = null, + ILoggerFactory? loggerFactory = null, + AgentThreadStore? 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 0000000000..c11a630ffe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +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 : DelegatingAIAgent +{ + private readonly AgentThreadStore _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, AgentThreadStore threadStore) + : base(innerAgent) + { + this._threadStore = Throw.IfNull(threadStore); + } + + /// + /// Gets an existing agent thread for the specified conversation, or creates a new one if none exists. + /// + /// The unique identifier of the conversation for which to retrieve or create the agent thread. Cannot be null, + /// empty, or consist only of white-space characters. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task that represents the asynchronous operation. The task result contains the agent thread associated with the + /// specified conversation. If no thread exists, a new thread is created and returned. + public ValueTask GetOrCreateThreadAsync(string conversationId, CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrWhitespace(conversationId); + + return this._threadStore.GetThreadAsync(this.InnerAgent, conversationId, cancellationToken); + } + + /// + /// 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 ValueTask SaveThreadAsync(string conversationId, AgentThread thread, CancellationToken cancellationToken = default) + { + _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(thread); + + return this._threadStore.SaveThreadAsync(this.InnerAgent, conversationId, thread, 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 0000000000..902c54ebe9 --- /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.ServiceCollection.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, AgentThreadStore store) + { + builder.ServiceCollection.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.ServiceCollection.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(AgentThreadStore)} 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 0000000000..f95999dde3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IAgentThreadStore.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +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 abstract class AgentThreadStore +{ + /// + /// Saves a serialized agent thread to persistent storage. + /// + /// The agent that owns this thread. + /// The unique identifier for the conversation/thread. + /// The thread to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveThreadAsync( + AIAgent agent, + string conversationId, + AgentThread thread, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a serialized agent thread from persistent storage. + /// + /// The agent that owns this thread. + /// The unique identifier for the conversation/thread to retrieve. + /// 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. + /// + public abstract ValueTask GetThreadAsync( + AIAgent agent, + string conversationId, + 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 0000000000..74bbe279fb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentThreadStore.cs @@ -0,0 +1,54 @@ +// 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 : AgentThreadStore +{ + private readonly ConcurrentDictionary _threads = new(); + + /// + public override ValueTask SaveThreadAsync(AIAgent agent, string conversationId, AgentThread thread, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + this._threads[key] = thread.Serialize(); + return default; + } + + /// + public override ValueTask 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(agent.GetNewThread()), + _ => new ValueTask(agent.DeserializeThread(threadContent.Value)), + }; + } + + private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentThreadStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentThreadStore.cs new file mode 100644 index 0000000000..c94489d0b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentThreadStore.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +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 is the "noop" store, and could be used if you are keeping the thread contents on the client side for example. +/// +public sealed class NoopAgentThreadStore : AgentThreadStore +{ + /// + public override ValueTask SaveThreadAsync(AIAgent agent, string conversationId, AgentThread thread, CancellationToken cancellationToken = default) + { + return new ValueTask(); + } + + /// + public override ValueTask GetThreadAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + return new ValueTask(agent.GetNewThread()); + } +}