.NET: Refactor A2A and AIAgent hosting extensions (#1625)

* a2a reformat

* and refactor extensions on serviceCollection

* units

* fix build

* add remark for agentcard overloads
This commit is contained in:
Korolev Dmitry
2025-10-23 19:43:48 +02:00
committed by GitHub
Unverified
parent 905e730dc2
commit 72c391bc08
12 changed files with 487 additions and 256 deletions
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -9,7 +9,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
@@ -17,6 +16,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
@@ -4,7 +4,6 @@ using A2A;
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<A2AHostAgent> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList<AITool>? tools = null)
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList<AITool>? tools = null)
{
var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential());
PersistentAgent persistentAgent = await persistentAgentsClient.Administration.GetAgentAsync(assistantId);
@@ -28,10 +27,10 @@ internal static class HostAgentFactory
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new A2AHostAgent(agent, agentCard);
return new(agent, agentCard);
}
internal static async Task<A2AHostAgent> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList<AITool>? tools = null)
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList<AITool>? tools = null)
{
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
@@ -45,7 +44,7 @@ internal static class HostAgentFactory
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new A2AHostAgent(agent, agentCard);
return new(agent, agentCard);
}
#region private
@@ -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 @@ IList<AITool> tools =
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 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentId))
}
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 @@ else
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();
@@ -5,7 +5,6 @@ using AgentWebChat.AgentHost;
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;
@@ -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;
/// <summary>
/// Host which will attach an <see cref="AIAgent"/> to a <see cref="ITaskManager"/>
/// </summary>
/// <remarks>
/// This implementation only handles:
/// <list type="bullet">
/// <item><description>TaskManager.OnMessageReceived</description></item>
/// <item><description>TaskManager.OnAgentCardQuery</description></item>
/// </list>
/// Support for task management will be added later as part of the long-running task execution work.
/// </remarks>
public sealed class A2AHostAgent
{
/// <summary>
/// Initializes a new instance of the <see cref="A2AHostAgent"/> class.
/// </summary>
/// <param name="agent">The <see cref="AIAgent"/> to host.</param>
/// <param name="agentCard">The <see cref="AgentCard"/> for the hosted agent.</param>
/// <param name="taskManager">The <see cref="ITaskManager"/> for handling agent tasks.</param>
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());
}
/// <summary>
/// Gets the associated <see cref="AIAgent"/>.
/// </summary>
public AIAgent? Agent { get; }
/// <summary>
/// Gets the associated <see cref="ITaskManager"/> for handling agent tasks.
/// </summary>
public TaskManager? TaskManager { get; private set; }
/// <summary>
/// Attaches the <see cref="A2AAgent"/> to the provided <see cref="ITaskManager"/>.
/// </summary>
/// <param name="taskManager">The <see cref="ITaskManager"/> to attach to.</param>
public void Attach(TaskManager taskManager)
{
Throw.IfNull(taskManager);
this.TaskManager = taskManager;
taskManager.OnMessageReceived = this.OnMessageReceivedAsync;
taskManager.OnAgentCardQuery = this.GetAgentCardAsync;
}
/// <summary>
/// Handles a received message.
/// </summary>
/// <param name="messageSend">The <see cref="MessageSendParams"/> to handle.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public async Task<A2AResponse> 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;
}
/// <summary>
/// Gets the <see cref="AgentCard"/> associated with this hosted agent.
/// </summary>
/// <param name="agentUrl">Current URL for the agent.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public Task<AgentCard> 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
}
@@ -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;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
{
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var taskManager = agent.MapA2A(loggerFactory: loggerFactory);
return endpoints.MapA2A(taskManager, path);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static ITaskManager MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
{
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory);
return endpoints.MapA2A(taskManager, path);
}
/// <summary>
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
/// TaskManager should be preconfigured before calling this method.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
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;
}
}
@@ -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;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
public static class WebApplicationExtensions
{
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="app">The web application used to configure the pipeline and routes.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
public static void MapA2A(this WebApplication app, string agentName, string path)
{
var agent = app.Services.GetRequiredKeyedService<AIAgent>(agentName);
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var taskManager = agent.MapA2A(loggerFactory: loggerFactory);
app.MapA2A(taskManager, path);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="app">The web application used to configure the pipeline and routes.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
public static void MapA2A(
this WebApplication app,
string agentName,
string path,
AgentCard agentCard)
{
var agent = app.Services.GetRequiredKeyedService<AIAgent>(agentName);
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var taskManager = agent.MapA2A(agentCard: agentCard, loggerFactory: loggerFactory);
app.MapA2A(taskManager, path);
}
/// <summary>
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
/// TaskManager should be preconfigured before calling this method.
/// </summary>
/// <param name="app">The web application used to configure the pipeline and routes.</param>
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
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);
}
}
@@ -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;
/// <summary>
/// Provides extension methods for configuring AI agents in a service collection.
/// </summary>
public static class AgentHostingServiceCollectionExtensions
{
/// <summary>
/// Adds an AI agent to the service collection using only a name and instructions, resolving the chat client from dependency injection.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
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<IChatClient>();
return new ChatClientAgent(chatClient, instructions, key);
});
}
/// <summary>
/// Adds an AI agent to the service collection with a provided chat client instance.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
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));
}
/// <summary>
/// Adds an AI agent to the service collection using a chat client resolved by an optional keyed service.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions, key);
});
}
/// <summary>
/// Adds an AI agent to the service collection using a chat client (optionally keyed) and a description.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="description">A description of the agent.</param>
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If <see langword="null"/>, a non-keyed service will be resolved.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
});
}
/// <summary>
/// Adds an AI agent to the service collection using a custom factory delegate.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="createAgentDelegate">A factory delegate that creates the AI agent instance. The delegate receives the service provider and agent key as parameters.</param>
/// <returns>The same <see cref="IServiceCollection"/> instance so that additional calls can be chained.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="services"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns <see langword="null"/> or an agent whose <see cref="AIAgent.Name"/> does not match <paramref name="name"/>.</exception>
public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, string name, Func<IServiceProvider, string, AIAgent> 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<AgentCatalog, LocalAgentCatalog>();
}
}
@@ -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);
}
/// <summary>
@@ -43,7 +39,7 @@ public static class HostApplicationBuilderAgentExtensions
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.AddAIAgent(name, (sp, key) => new ChatClientAgent(chatClient, instructions, key));
return builder.Services.AddAIAgent(name, instructions, chatClient);
}
/// <summary>
@@ -60,11 +56,7 @@ public static class HostApplicationBuilderAgentExtensions
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
return builder.AddAIAgent(name, (sp, key) =>
{
var chatClient = chatClientServiceKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions: instructions, name: key, description: description);
});
return builder.Services.AddAIAgent(name, instructions, description, chatClientServiceKey);
}
/// <summary>
@@ -79,12 +71,7 @@ public static class HostApplicationBuilderAgentExtensions
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<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientServiceKey);
return new ChatClientAgent(chatClient, instructions, key);
});
return builder.Services.AddAIAgent(name, instructions, chatClientServiceKey);
}
/// <summary>
@@ -99,44 +86,6 @@ public static class HostApplicationBuilderAgentExtensions
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> 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<AgentCatalog, LocalAgentCatalog>();
return builder.Services.AddAIAgent(name, createAgentDelegate);
}
}
@@ -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;
}
}
@@ -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; }
/// <summary>
/// Gets the application host builder for configuring additional services.
/// Gets the service collection for configuration.
/// </summary>
IHostApplicationBuilder HostApplicationBuilder { get; }
IServiceCollection ServiceCollection { get; }
}
@@ -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
{
/// <summary>
/// Verifies that providing a null builder to AddAIAgent throws an ArgumentNullException.
/// </summary>
[Fact]
public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() => Assert.Throws<ArgumentNullException>(
() => AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agent", "instructions"));
/// <summary>
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddAIAgent_NullName_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, "instructions"));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddAIAgent without chat client key allows null instructions.
/// </summary>
[Fact]
public void AddAIAgent_NullInstructions_AllowsNull()
{
var services = new ServiceCollection();
var result = services.AddAIAgent("agentName", (string)null!);
Assert.NotNull(result);
}
/// <summary>
/// Verifies that AddAIAgent with chat client key throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, "instructions", "key"));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddAIAgent with chat client key allows null instructions.
/// </summary>
[Fact]
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
{
var services = new ServiceCollection();
var result = services.AddAIAgent("agentName", null!, "key");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null builder.
/// </summary>
[Fact]
public void AddAIAgentWithFactory_NullBuilder_ThrowsArgumentNullException() =>
Assert.Throws<ArgumentNullException>(() =>
AgentHostingServiceCollectionExtensions.AddAIAgent(null!, "agentName", (sp, key) => new Mock<AIAgent>().Object));
/// <summary>
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddAIAgentWithFactory_NullName_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent(null!, (sp, key) => new Mock<AIAgent>().Object));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddAIAgent with factory delegate throws ArgumentNullException for null factory.
/// </summary>
[Fact]
public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException()
{
var services = new ServiceCollection();
var exception = Assert.Throws<ArgumentNullException>(() => services.AddAIAgent("agentName", (Func<IServiceProvider, string, AIAgent>)null!));
Assert.Equal("createAgentDelegate", exception.ParamName);
}
/// <summary>
/// Verifies that AddAIAgent with factory delegate returns the same builder instance.
/// </summary>
[Fact]
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
{
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
var result = services.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
Assert.NotNull(result);
}
/// <summary>
/// Verifies that AddAIAgent registers the agent as a keyed singleton service.
/// </summary>
[Fact]
public void AddAIAgent_RegistersKeyedSingleton()
{
var services = new ServiceCollection();
var mockAgent = new Mock<AIAgent>();
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);
}
/// <summary>
/// Verifies that AddAIAgent can be called multiple times with different agent names.
/// </summary>
[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");
}
/// <summary>
/// Verifies that AddAIAgent handles empty strings for name.
/// </summary>
[Fact]
public void AddAIAgent_EmptyName_ThrowsArgumentException()
{
var services = new ServiceCollection();
Assert.Throws<ArgumentException>(() => services.AddAIAgent("", "instructions"));
}
/// <summary>
/// Verifies that AddAIAgent allows empty strings for instructions.
/// </summary>
[Fact]
public void AddAIAgent_EmptyInstructions_Succeeds()
{
var services = new ServiceCollection();
var result = services.AddAIAgent("agentName", "");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
/// </summary>
[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);
}
/// <summary>
/// Verifies that AddAIAgent with special characters in name works correctly for valid names.
/// </summary>
[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);
}
}