diff --git a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj index bcff11f17c..a053b9e33b 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj @@ -1,4 +1,4 @@ - + Exe @@ -9,7 +9,6 @@ - @@ -17,6 +16,9 @@ + + + diff --git a/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs index 49662f4d05..81fd24c595 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -4,7 +4,6 @@ using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.A2A; using Microsoft.Extensions.AI; using OpenAI; @@ -12,7 +11,7 @@ namespace A2AServer; internal static class HostAgentFactory { - internal static async Task CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList? tools = null) { var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); PersistentAgent persistentAgent = await persistentAgentsClient.Administration.GetAgentAsync(assistantId); @@ -28,10 +27,10 @@ internal static async Task CreateFoundryHostAgentAsync(string agen _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; - return new A2AHostAgent(agent, agentCard); + return new(agent, agentCard); } - internal static async Task CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null) { AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(model) @@ -45,7 +44,7 @@ internal static async Task CreateChatCompletionHostAgentAsync(stri _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; - return new A2AHostAgent(agent, agentCard); + return new(agent, agentCard); } #region private diff --git a/dotnet/samples/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/A2AClientServer/A2AServer/Program.cs index b5dbb363e3..e4f619dc54 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/A2AClientServer/A2AServer/Program.cs @@ -2,7 +2,7 @@ using A2A; using A2A.AspNetCore; using A2AServer; -using Microsoft.Agents.AI.A2A; +using Microsoft.Agents.AI; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -47,10 +47,12 @@ AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId) ]; -A2AHostAgent? hostAgent = null; +AIAgent hostA2AAgent; +AgentCard hostA2AAgentCard; + if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentId)) { - hostAgent = agentType.ToUpperInvariant() switch + (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentId, tools), "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentId), @@ -60,7 +62,7 @@ } else if (!string.IsNullOrEmpty(apiKey)) { - hostAgent = agentType.ToUpperInvariant() switch + (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { "INVOICE" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "InvoiceAgent", @@ -102,7 +104,7 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentId must be provided"); } -app.MapA2A(hostAgent!.TaskManager!, "/"); -app.MapWellKnownAgentCard(hostAgent!.TaskManager!, "/"); +var a2aTaskManager = app.MapA2A(hostA2AAgent, path: "/", agentCard: hostA2AAgentCard); +app.MapWellKnownAgentCard(a2aTaskManager, "/"); await app.RunAsync(); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 8fce124db0..da27df46e8 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -5,7 +5,6 @@ using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.A2A.AspNetCore; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AHostAgent.cs deleted file mode 100644 index 34fc058d38..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AHostAgent.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.A2A; - -/// -/// Host which will attach an to a -/// -/// -/// This implementation only handles: -/// -/// TaskManager.OnMessageReceived -/// TaskManager.OnAgentCardQuery -/// -/// Support for task management will be added later as part of the long-running task execution work. -/// -public sealed class A2AHostAgent -{ - /// - /// Initializes a new instance of the class. - /// - /// The to host. - /// The for the hosted agent. - /// The for handling agent tasks. - public A2AHostAgent(AIAgent agent, AgentCard agentCard, TaskManager? taskManager = null) - { - Throw.IfNull(agent); - Throw.IfNull(agentCard); - - this.Agent = agent; - this._agentCard = agentCard; - - this.Attach(taskManager ?? new TaskManager()); - } - - /// - /// Gets the associated . - /// - public AIAgent? Agent { get; } - - /// - /// Gets the associated for handling agent tasks. - /// - public TaskManager? TaskManager { get; private set; } - - /// - /// Attaches the to the provided . - /// - /// The to attach to. - public void Attach(TaskManager taskManager) - { - Throw.IfNull(taskManager); - - this.TaskManager = taskManager; - taskManager.OnMessageReceived = this.OnMessageReceivedAsync; - taskManager.OnAgentCardQuery = this.GetAgentCardAsync; - } - - /// - /// Handles a received message. - /// - /// The to handle. - /// The to monitor for cancellation requests. The default is . - public async Task OnMessageReceivedAsync(MessageSendParams messageSend, CancellationToken cancellationToken = default) - { - Throw.IfNull(messageSend); - Throw.IfNull(this.Agent); - - if (this.TaskManager is null) - { - throw new InvalidOperationException("TaskManager must be attached before handling an agent message."); - } - - // Get message from the user - var userMessage = messageSend.Message.ToChatMessage(); - - // Get the response from the agent - var message = new AgentMessage(); - var agentResponse = await this.Agent.RunAsync(userMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - foreach (var chatMessage in agentResponse.Messages) - { - var content = chatMessage.Text; - message.Parts.Add(new TextPart() { Text = content! }); - } - - return message; - } - - /// - /// Gets the associated with this hosted agent. - /// - /// Current URL for the agent. - /// The to monitor for cancellation requests. The default is . - public Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken = default) - { - // Ensure the URL is in the correct format - Uri uri = new(agentUrl); - agentUrl = $"{uri.Scheme}://{uri.Host}:{uri.Port}/"; - - this._agentCard.Url = agentUrl; - return Task.FromResult(this._agentCard); - } - - #region private - private readonly AgentCard _agentCard; - #endregion -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..85ef1e3e2c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using A2A; +using A2A.AspNetCore; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.A2A; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder. +/// +public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions +{ + /// + /// 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. + /// Configured for A2A integration. + public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) + { + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return 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 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. + /// 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 ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) + { + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return 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. + /// Configured for A2A integration. + public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + { + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); + var taskManager = agent.MapA2A(loggerFactory: loggerFactory); + return endpoints.MapA2A(taskManager, 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. + /// Agent card info to return on query. + /// 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 ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) + { + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); + var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory); + return endpoints.MapA2A(taskManager, path); + } + + /// + /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. + /// TaskManager should be preconfigured before calling this method. + /// + /// The to add the A2A endpoints to. + /// 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) + { + // 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs deleted file mode 100644 index fadc78ed93..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/WebApplicationExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using A2A; -using A2A.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.AI.Hosting.A2A.AspNetCore; - -/// -/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder. -/// -public static class WebApplicationExtensions -{ - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The web application used to configure the pipeline and routes. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - public static void MapA2A(this WebApplication app, string agentName, string path) - { - var agent = app.Services.GetRequiredKeyedService(agentName); - var loggerFactory = app.Services.GetRequiredService(); - - var taskManager = agent.MapA2A(loggerFactory: loggerFactory); - app.MapA2A(taskManager, path); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The web application used to configure the pipeline and routes. - /// 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. - public static void MapA2A( - this WebApplication app, - string agentName, - string path, - AgentCard agentCard) - { - var agent = app.Services.GetRequiredKeyedService(agentName); - var loggerFactory = app.Services.GetRequiredService(); - - var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory); - app.MapA2A(taskManager, path); - } - - /// - /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. - /// TaskManager should be preconfigured before calling this method. - /// - /// The web application used to configure the pipeline and routes. - /// Pre-configured A2A TaskManager to use for A2A endpoints handling. - /// The route group to use for A2A endpoints. - public static void MapA2A(this WebApplication app, TaskManager 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(app, taskManager, path); - - app.MapHttpA2A(taskManager, path); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs new file mode 100644 index 0000000000..3c5a5b84c2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for configuring AI agents in a service collection. +/// +public static class AgentHostingServiceCollectionExtensions +{ + /// + /// Adds an AI agent to the service collection using only a name and instructions, resolving the chat client from dependency injection. + /// + /// The service collection to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions) + { + Throw.IfNull(services); + Throw.IfNullOrEmpty(name); + return services.AddAIAgent(name, (sp, key) => + { + var chatClient = sp.GetRequiredService(); + return new ChatClientAgent(chatClient, instructions, key); + }); + } + + /// + /// Adds an AI agent to the service collection with a provided chat client instance. + /// + /// The service collection to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The chat client which the agent will use for inference. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, IChatClient chatClient) + { + Throw.IfNull(services); + Throw.IfNullOrEmpty(name); + return services.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key)); + } + + /// + /// Adds an AI agent to the service collection using a chat client resolved by an optional keyed service. + /// + /// The service collection to configure. + /// The name of the agent. + /// The instructions for the agent. + /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, object? chatClientServiceKey) + { + Throw.IfNull(services); + Throw.IfNullOrEmpty(name); + return services.AddAIAgent(name, (sp, key) => + { + var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); + return new ChatClientAgent(chatClient, instructions, key); + }); + } + + /// + /// Adds an AI agent to the service collection using a chat client (optionally keyed) and a description. + /// + /// The service collection to configure. + /// The name of the agent. + /// The instructions for the agent. + /// A description of the agent. + /// The key to use when resolving the chat client from the service provider. If , a non-keyed service will be resolved. + /// The same instance so that additional calls can be chained. + /// Thrown when or is . + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, string? instructions, string? description, object? chatClientServiceKey) + { + Throw.IfNull(services); + Throw.IfNullOrEmpty(name); + return services.AddAIAgent(name, (sp, key) => + { + var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); + return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description); + }); + } + + /// + /// Adds an AI agent to the service collection using a custom factory delegate. + /// + /// The service collection to configure. + /// The name of the agent. + /// A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters. + /// The same instance so that additional calls can be chained. + /// Thrown when , , or is . + /// Thrown when the agent factory delegate returns or an agent whose does not match . + public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func createAgentDelegate) + { + Throw.IfNull(services); + Throw.IfNull(name); + Throw.IfNull(createAgentDelegate); + services.AddKeyedSingleton(name, (sp, key) => + { + Throw.IfNull(key); + var keyString = key as string; + Throw.IfNullOrEmpty(keyString); + var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'."); + if (!string.Equals(agent.Name, keyString, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'."); + } + + return agent; + }); + + // Register the agent by name for discovery. + var agentHostBuilder = GetAgentRegistry(services); + agentHostBuilder.AgentNames.Add(name); + + return new HostedAgentBuilder(name, services); + } + + private static LocalAgentRegistry GetAgentRegistry(IServiceCollection services) + { + var descriptor = services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry))); + if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance) + { + instance = new LocalAgentRegistry(); + ConfigureHostBuilder(services, instance); + } + + return instance; + } + + private static void ConfigureHostBuilder(IServiceCollection services, LocalAgentRegistry agentHostBuilderContext) + { + services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); + services.AddSingleton(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs index d45046a8de..434024866a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Linq; -using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Shared.Diagnostics; @@ -26,8 +23,7 @@ public static class HostApplicationBuilderAgentExtensions public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions) { Throw.IfNull(builder); - Throw.IfNullOrEmpty(name); - return builder.AddAIAgent(name, instructions, chatClientServiceKey: null); + return builder.Services.AddAIAgent(name, instructions); } /// @@ -43,7 +39,7 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); - return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key)); + return builder.Services.AddAIAgent(name, instructions, chatClient); } /// @@ -60,11 +56,7 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); - return builder.AddAIAgent(name, (sp, key) => - { - var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description); - }); + return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey); } /// @@ -79,12 +71,7 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey) { Throw.IfNull(builder); - Throw.IfNullOrEmpty(name); - return builder.AddAIAgent(name, (sp, key) => - { - var chatClient = chatClientServiceKey is null ? sp.GetRequiredService() : sp.GetRequiredKeyedService(chatClientServiceKey); - return new ChatClientAgent(chatClient, instructions, key); - }); + return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey); } /// @@ -99,44 +86,6 @@ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builde public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) { Throw.IfNull(builder); - Throw.IfNull(name); - Throw.IfNull(createAgentDelegate); - builder.Services.AddKeyedSingleton(name, (sp, key) => - { - Throw.IfNull(key); - var keyString = key as string; - Throw.IfNullOrEmpty(keyString); - var agent = createAgentDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(AIAgent)} instance for key '{keyString}'."); - if (!string.Equals(agent.Name, keyString, StringComparison.Ordinal)) - { - throw new InvalidOperationException($"The agent factory returned an agent with name '{agent.Name}', but the expected name is '{keyString}'."); - } - - return agent; - }); - - // Register the agent by name for discovery. - var agentHostBuilder = GetAgentRegistry(builder); - agentHostBuilder.AgentNames.Add(name); - - return new HostedAgentBuilder(name, builder); - } - - private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder) - { - var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry))); - if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance) - { - instance = new LocalAgentRegistry(); - ConfigureHostBuilder(builder, instance); - } - - return instance; - } - - private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalAgentRegistry agentHostBuilderContext) - { - builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); - builder.Services.AddSingleton(); + return builder.Services.AddAIAgent(name, createAgentDelegate); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs index 82e0997c7a..89bf096b62 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Microsoft.Agents.AI.Hosting; @@ -7,11 +8,16 @@ namespace Microsoft.Agents.AI.Hosting; internal sealed class HostedAgentBuilder : IHostedAgentBuilder { public string Name { get; } - public IHostApplicationBuilder HostApplicationBuilder { get; } + public IServiceCollection ServiceCollection { get; } - public HostedAgentBuilder(string name, IHostApplicationBuilder hostApplicationBuilder) + public HostedAgentBuilder(string name, IHostApplicationBuilder builder) + : this(name, builder.Services) + { + } + + public HostedAgentBuilder(string name, IServiceCollection serviceCollection) { this.Name = name; - this.HostApplicationBuilder = hostApplicationBuilder; + this.ServiceCollection = serviceCollection; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs index 14070bb671..f67f4eb7cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.Hosting; @@ -15,7 +15,7 @@ public interface IHostedAgentBuilder string Name { get; } /// - /// Gets the application host builder for configuring additional services. + /// Gets the service collection for configuration. /// - IHostApplicationBuilder HostApplicationBuilder { get; } + IServiceCollection ServiceCollection { get; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..3d96567e85 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +public class AgentHostingServiceCollectionExtensionsTests +{ + /// + /// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException. + /// + [Fact] + public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() => Assert.Throws( + () => AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agent", "instructions")); + + /// + /// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgent_NullName_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + + var exception = Assert.Throws(() => services.AddAIAgent(null!, "instructions")); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent without chat client key allows null instructions. + /// + [Fact] + public void AddAIAgent_NullInstructions_AllowsNull() + { + var services = new ServiceCollection(); + var result = services.AddAIAgent("agentName", (string)null!); + Assert.NotNull(result); + } + + /// + /// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + var exception = Assert.Throws(() => services.AddAIAgent(null!, "instructions", "key")); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with chat client key allows null instructions. + /// + [Fact] + public void AddAIAgentWithKey_NullInstructions_AllowsNull() + { + var services = new ServiceCollection(); + var result = services.AddAIAgent("agentName", null!, "key"); + Assert.NotNull(result); + } + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder. + /// + [Fact] + public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException() => + Assert.Throws(() => + AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agentName", (sp, key) => new Mock().Object)); + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name. + /// + [Fact] + public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + var exception = Assert.Throws(() => services.AddAIAgent(null!, (sp, key) => new Mock().Object)); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory. + /// + [Fact] + public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + var exception = Assert.Throws(() => services.AddAIAgent("agentName", (Func)null!)); + Assert.Equal("createAgentDelegate", exception.ParamName); + } + + /// + /// Verifies that AddAIAgent with factory delegate returns the same builder instance. + /// + [Fact] + public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() + { + var services = new ServiceCollection(); + var mockAgent = new Mock(); + var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object); + Assert.NotNull(result); + } + + /// + /// Verifies that AddAIAgent registers the agent as a keyed singleton service. + /// + [Fact] + public void AddAIAgent_RegistersKeyedSingleton() + { + var services = new ServiceCollection(); + var mockAgent = new Mock(); + const string AgentName = "testAgent"; + + services.AddAIAgent(AgentName, (sp, key) => mockAgent.Object); + + var descriptor = services.FirstOrDefault( + d => (d.ServiceKey as string) == AgentName && + d.ServiceType == typeof(AIAgent)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + /// + /// Verifies that AddAIAgent can be called multiple times with different agent names. + /// + [Fact] + public void AddAIAgent_MultipleCalls_RegistersMultipleAgents() + { + var services = new ServiceCollection(); + + services.AddAIAgent("agent1", "instructions1"); + services.AddAIAgent("agent2", "instructions2"); + services.AddAIAgent("agent3", "instructions3"); + + var agentDescriptors = services + .Where(d => d.ServiceType == typeof(AIAgent) && d.ServiceKey is string) + .ToList(); + + Assert.Equal(3, agentDescriptors.Count); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent1"); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent2"); + Assert.Contains(agentDescriptors, d => (string)d.ServiceKey! == "agent3"); + } + + /// + /// Verifies that AddAIAgent handles empty strings for name. + /// + [Fact] + public void AddAIAgent_EmptyName_ThrowsArgumentException() + { + var services = new ServiceCollection(); + Assert.Throws(() => services.AddAIAgent("", "instructions")); + } + + /// + /// Verifies that AddAIAgent allows empty strings for instructions. + /// + [Fact] + public void AddAIAgent_EmptyInstructions_Succeeds() + { + var services = new ServiceCollection(); + var result = services.AddAIAgent("agentName", ""); + Assert.NotNull(result); + } + /// + /// Verifies that AddAIAgent without chat client key calls the overload with null key. + /// + [Fact] + public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey() + { + var builder = new HostApplicationBuilder(); + var result = builder.AddAIAgent("agentName", "instructions"); + + // The agent should be registered (proving the method chain worked) + var descriptor = builder.Services.FirstOrDefault( + d => d.ServiceKey is "agentName" && + d.ServiceType == typeof(AIAgent)); + Assert.NotNull(descriptor); + } + + /// + /// Verifies that AddAIAgent with special characters in name works correctly for valid names. + /// + [Theory] + [InlineData("agent_name")] // underscore is allowed + [InlineData("Agent123")] // alphanumeric is allowed + [InlineData("_agent")] // can start with underscore + [InlineData("agent-name")] // dash is allowed + [InlineData("agent.name")] // period is allowed + [InlineData("agent:type")] // colon is allowed + [InlineData("my.agent_1:type-name")] // complex valid name + public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) + { + var builder = new HostApplicationBuilder(); + var result = builder.AddAIAgent(name, "instructions"); + + var descriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == name && + d.ServiceType == typeof(AIAgent)); + Assert.NotNull(descriptor); + } +}