Add MapAGUI hosting overloads with IHostedAgentBuilder and agent name support

This adds the same hosting patterns from A2A and OpenAI to AGUI:
- MapAGUI(IHostedAgentBuilder) and MapAGUI(IHostedAgentBuilder, string? path)
- MapAGUI(string agentName) and MapAGUI(string agentName, string? path)
- MapAGUI(AIAgent) and MapAGUI(AIAgent, string? path)
- ValidateAgentName for URL-safe validation
- Updated namespace to Microsoft.AspNetCore.Builder
- Renamed class to MicrosoftAgentAIHostingAGUIEndpointRouteBuilderExtensions
- Added comprehensive unit tests
This commit is contained in:
Javier Calvarro Nelson
2025-12-01 13:07:20 +01:00
parent 4ce2804db0
commit 18d05e8824
2 changed files with 269 additions and 0 deletions
@@ -32,6 +32,21 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
/// </remarks>
public static class AGUIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps an AG-UI agent endpoint using an agent registered in dependency injection via <see cref="IHostedAgentBuilder"/>.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentBuilder">The hosted agent builder that identifies the agent registration.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
IHostedAgentBuilder agentBuilder)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapAGUIServer(agentBuilder.Name);
}
/// <summary>
/// Maps an AG-UI agent endpoint using an agent registered in dependency injection via <see cref="IHostedAgentBuilder"/>.
/// </summary>
@@ -49,6 +64,23 @@ public static class AGUIEndpointRouteBuilderExtensions
return endpoints.MapAGUIServer(agentBuilder.Name, pattern);
}
/// <summary>
/// Maps an AG-UI agent endpoint using a named agent registered in dependency injection.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentName">The name of the keyed agent registration to resolve from dependency injection.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
string agentName)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapAGUIServer(agent);
}
/// <summary>
/// Maps an AG-UI agent endpoint using a named agent registered in dependency injection.
/// </summary>
@@ -68,6 +100,24 @@ public static class AGUIEndpointRouteBuilderExtensions
return endpoints.MapAGUIServer(pattern, agent);
}
/// <summary>
/// Maps an AG-UI agent endpoint using a route derived from the agent name.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="aiAgent">The agent instance.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUIServer(
this IEndpointRouteBuilder endpoints,
AIAgent aiAgent)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(aiAgent);
ArgumentException.ThrowIfNullOrWhiteSpace(aiAgent.Name, nameof(aiAgent.Name));
ValidateAgentName(aiAgent.Name);
return endpoints.MapAGUIServer($"/{aiAgent.Name}/agui", aiAgent);
}
/// <summary>
/// Maps an AG-UI agent endpoint.
/// </summary>
@@ -186,4 +236,13 @@ public static class AGUIEndpointRouteBuilderExtensions
await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false);
}
private static void ValidateAgentName([NotNull] string agentName)
{
var 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));
}
}
}
@@ -0,0 +1,210 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the agent-name-derived <c>MapAGUIServer</c> overloads.
/// </summary>
public sealed class MapAGUIEndpointRouteBuilderExtensionsTests
{
[Fact]
public void MapAGUIServer_WithAgentBuilder_MapsNameDerivedRoute()
{
// Arrange
using WebApplication app = CreateApp("test-agent");
Mock<IHostedAgentBuilder> agentBuilder = new();
agentBuilder.SetupGet(builder => builder.Name).Returns("test-agent");
// Act
app.MapAGUIServer(agentBuilder.Object);
// Assert
Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui");
}
[Fact]
public void MapAGUIServer_WithAgentName_MapsNameDerivedRoute()
{
// Arrange
using WebApplication app = CreateApp("test-agent");
// Act
app.MapAGUIServer("test-agent");
// Assert
Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui");
}
[Fact]
public void MapAGUIServer_WithAgent_MapsNameDerivedRoute()
{
// Arrange
using WebApplication app = CreateApp();
AIAgent agent = new TestAgent("test-agent");
// Act
app.MapAGUIServer(agent);
// Assert
Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui");
}
[Fact]
public void MapAGUIServer_WithNullEndpoints_ThrowsArgumentNullException()
{
// Arrange
IEndpointRouteBuilder endpoints = null!;
AIAgent agent = new TestAgent("test-agent");
// Act
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() => endpoints.MapAGUIServer(agent));
// Assert
Assert.Equal("endpoints", exception.ParamName);
}
[Fact]
public void MapAGUIServer_WithNullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
using WebApplication app = CreateApp();
// Act
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() => app.MapAGUIServer((IHostedAgentBuilder)null!));
// Assert
Assert.Equal("agentBuilder", exception.ParamName);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MapAGUIServer_WithNullOrWhitespaceAgentName_ThrowsArgumentException(string? agentName)
{
// Arrange
using WebApplication app = CreateApp();
// Act
ArgumentException exception = Assert.ThrowsAny<ArgumentException>(() => app.MapAGUIServer(agentName!));
// Assert
Assert.Equal("agentName", exception.ParamName);
}
[Fact]
public void MapAGUIServer_WithNullAgent_ThrowsArgumentNullException()
{
// Arrange
using WebApplication app = CreateApp();
// Act
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() => app.MapAGUIServer((AIAgent)null!));
// Assert
Assert.Equal("aiAgent", exception.ParamName);
}
[Theory]
[InlineData("agent with spaces")]
[InlineData("agent<script>")]
[InlineData("agent?query")]
[InlineData("agent#fragment")]
public void MapAGUIServer_WithInvalidAgentName_ThrowsArgumentException(string agentName)
{
// Arrange
using WebApplication app = CreateApp();
AIAgent agent = new TestAgent(agentName);
// Act
ArgumentException exception = Assert.Throws<ArgumentException>(() => app.MapAGUIServer(agent));
// Assert
Assert.Equal("agentName", exception.ParamName);
}
[Theory]
[InlineData("agent-name")]
[InlineData("agent_name")]
[InlineData("agent.name")]
[InlineData("agent123")]
public void MapAGUIServer_WithValidAgentName_MapsNameDerivedRoute(string agentName)
{
// Arrange
using WebApplication app = CreateApp();
AIAgent agent = new TestAgent(agentName);
// Act
app.MapAGUIServer(agent);
// Assert
Assert.Contains(GetRoutePatterns(app), pattern => pattern == $"/{agentName}/agui");
}
private static WebApplication CreateApp(string? keyedAgentName = null)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUIServer();
if (keyedAgentName is not null)
{
builder.Services.AddKeyedSingleton<AIAgent>(keyedAgentName, new TestAgent(keyedAgentName));
}
return builder.Build();
}
private static IEnumerable<string?> GetRoutePatterns(WebApplication app) =>
app.DataSources
.SelectMany(dataSource => dataSource.Endpoints)
.OfType<RouteEndpoint>()
.Select(endpoint => endpoint.RoutePattern.RawText);
private sealed class TestAgent(string? name) : AIAgent
{
protected override string? IdCore => name;
public override string? Name => name;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
JsonElement serializedState,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
AgentSession session,
JsonSerializerOptions? jsonSerializerOptions = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
}