diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index e20d1ab448..1c3891e54b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -14,40 +17,106 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; +namespace Microsoft.AspNetCore.Builder; /// /// Provides extension methods for mapping AG-UI agents to ASP.NET Core endpoints. /// -public static class AGUIEndpointRouteBuilderExtensions +public static class MicrosoftAgentAIHostingAGUIEndpointRouteBuilderExtensions { /// - /// Maps an AG-UI agent endpoint. + /// Maps AG-UI endpoints to the specified for the given . /// - /// The endpoint route builder. - /// The URL pattern for the endpoint. - /// The agent instance. + /// The to add the AG-UI endpoints to. + /// The builder for to map the AG-UI endpoints for. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder) + => MapAGUI(endpoints, agentBuilder, path: null); + + /// + /// Maps AG-UI endpoints to the specified for the given . + /// + /// The to add the AG-UI endpoints to. + /// The builder for to map the AG-UI endpoints for. + /// Custom route path for the AG-UI endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, [StringSyntax("Route")] string? path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + + AIAgent agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); + return MapAGUI(endpoints, agent, path); + } + + /// + /// Maps AG-UI endpoints to the specified for the given agent name. + /// + /// The to add the AG-UI endpoints to. + /// The name of the agent to map the AG-UI endpoints for. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, string agentName) + => MapAGUI(endpoints, agentName, path: null); + + /// + /// Maps AG-UI endpoints to the specified for the given agent name. + /// + /// The to add the AG-UI endpoints to. + /// The name of the agent to map the AG-UI endpoints for. + /// Custom route path for the AG-UI endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, string agentName, [StringSyntax("Route")] string? path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + + AIAgent agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return MapAGUI(endpoints, agent, path); + } + + /// + /// Maps AG-UI endpoints to the specified for the given . + /// + /// The to add the AG-UI endpoints to. + /// The instance to map the AG-UI endpoints for. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, AIAgent agent) + => MapAGUI(endpoints, agent, path: null); + + /// + /// Maps AG-UI endpoints to the specified for the given . + /// + /// The to add the AG-UI endpoints to. + /// The instance to map the AG-UI endpoints for. + /// Custom route path for the AG-UI endpoint. /// An for the mapped endpoint. public static IEndpointConventionBuilder MapAGUI( this IEndpointRouteBuilder endpoints, - [StringSyntax("route")] string pattern, - AIAgent aiAgent) + AIAgent agent, + [StringSyntax("Route")] string? path) { - return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name)); + ValidateAgentName(agent.Name); + + path ??= $"/{agent.Name}/agui"; + + return endpoints.MapPost(path, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => { if (input is null) { return Results.BadRequest(); } - var jsonOptions = context.RequestServices.GetRequiredService>(); - var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; + IOptions jsonOptions = context.RequestServices.GetRequiredService>(); + System.Text.Json.JsonSerializerOptions jsonSerializerOptions = jsonOptions.Value.SerializerOptions; - var messages = input.Messages.AsChatMessages(jsonSerializerOptions); - var clientTools = input.Tools?.AsAITools().ToList(); + IEnumerable messages = input.Messages.AsChatMessages(jsonSerializerOptions); + List? clientTools = input.Tools?.AsAITools().ToList(); // Create run options with AG-UI context in AdditionalProperties - var runOptions = new ChatClientAgentRunOptions + ChatClientAgentRunOptions runOptions = new() { ChatOptions = new ChatOptions { @@ -64,7 +133,7 @@ public static class AGUIEndpointRouteBuilderExtensions }; // Run the agent and convert to AG-UI events - var events = aiAgent.RunStreamingAsync( + IAsyncEnumerable events = agent.RunStreamingAsync( messages, options: runOptions, cancellationToken: cancellationToken) @@ -76,8 +145,17 @@ public static class AGUIEndpointRouteBuilderExtensions jsonSerializerOptions, cancellationToken); - var sseLogger = context.RequestServices.GetRequiredService>(); + ILogger sseLogger = context.RequestServices.GetRequiredService>(); return new AGUIServerSentEventsResult(events, sseLogger); }); } + + private static void ValidateAgentName([NotNull] string agentName) + { + string escaped = Uri.EscapeDataString(agentName); + if (!string.Equals(escaped, agentName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Agent name '{agentName}' contains characters invalid for URL routes.", nameof(agentName)); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 78a3048747..ed42c4d96d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -20,7 +20,7 @@ using Moq; namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; /// -/// Unit tests for the class. +/// Unit tests for the class. /// public sealed class AGUIEndpointRouteBuilderExtensionsTests { @@ -38,7 +38,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests AIAgent agent = new TestAgent(); // Act - IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(Pattern, agent); + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agent, Pattern); // Assert Assert.NotNull(result); @@ -423,6 +423,8 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests { public override string Id => "multi-response-agent"; + public override string Name => "multi-response-agent"; + public override string? Description => "Agent that produces multiple text chunks"; public override AgentThread GetNewThread() => new TestInMemoryAgentThread(); @@ -512,6 +514,8 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests { public override string Id => "test-agent"; + public override string Name => "test-agent"; + public override string? Description => "Test agent"; public override AgentThread GetNewThread() => new TestInMemoryAgentThread(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 0000000000..750958b912 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,479 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Unit tests for the MapAGUI extension methods. +/// +public sealed class MapAGUIEndpointRouteBuilderExtensionsTests +{ + /// + /// Verifies that MapAGUI throws ArgumentNullException for null endpoints when using IHostedAgentBuilder. + /// + [Fact] + public void MapAGUI_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + IEndpointRouteBuilder endpoints = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapAGUI(agentBuilder)); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapAGUI throws ArgumentNullException for null agentBuilder. + /// + [Fact] + public void MapAGUI_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddAGUI(); + using WebApplication app = builder.Build(); + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapAGUI(agentBuilder)); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapAGUI with IHostedAgentBuilder correctly maps the agent. + /// + [Fact] + public void MapAGUI_WithAgentBuilder_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddAGUI(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapAGUI(agentBuilder); + Assert.NotNull(app); + } + + /// + /// Verifies that MapAGUI with IHostedAgentBuilder and custom path works correctly. + /// + [Fact] + public void MapAGUI_WithAgentBuilder_CustomPath_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("my-agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddAGUI(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapAGUI(agentBuilder, path: "/agents/my-agent/agui"); + Assert.NotNull(app); + } + + /// + /// Verifies that multiple agents can be mapped using IHostedAgentBuilder. + /// + [Fact] + public void MapAGUI_WithAgentBuilder_MultipleAgents_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new SimpleMockChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + builder.Services.AddAGUI(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapAGUI(agent1Builder); + app.MapAGUI(agent2Builder); + Assert.NotNull(app); + } + + /// + /// Verifies that IHostedAgentBuilder overload validates agent name characters. + /// + [Theory] + [InlineData("agent with spaces")] + [InlineData("agent