From 3c952911ceb02a7608bb7af679c7a9304f069109 Mon Sep 17 00:00:00 2001 From: tommasodotnet Date: Mon, 9 Feb 2026 19:17:52 +0100 Subject: [PATCH] adds devui integration and samples --- dotnet/Directory.Packages.props | 36 +- dotnet/agent-framework-dotnet.slnx | 10 + .../AgentEntityInfo.cs | 38 + .../AgentFrameworkBuilderExtensions.cs | 183 +++++ .../AgentServiceAnnotation.cs | 65 ++ ...Aspire.Hosting.AgentFramework.DevUI.csproj | 21 + .../DevUIAggregatorHostedService.cs | 659 ++++++++++++++++++ .../DevUIResource.cs | 46 ++ .../README.md | 104 +++ .../DevUIIntegration/.aspire/settings.json | 3 + dotnet/samples/DevUIIntegration/.gitignore | 1 + .../DevUIIntegration.AppHost.csproj | 27 + .../DevUIIntegration.AppHost/Program.cs | 33 + .../Properties/launchSettings.json | 34 + .../DevUIIntegration.AppHost/appsettings.json | 14 + .../DevUIIntegration.ServiceDefaults.csproj | 23 + .../Extensions.cs | 128 ++++ .../EditorAgent/EditorAgent.csproj | 22 + .../DevUIIntegration/EditorAgent/Program.cs | 49 ++ .../Properties/launchSettings.json | 14 + .../DevUIIntegration/WriterAgent/Program.cs | 30 + .../Properties/launchSettings.json | 14 + .../WriterAgent/WriterAgent.csproj | 22 + 23 files changed, 1560 insertions(+), 16 deletions(-) create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs create mode 100644 dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md create mode 100644 dotnet/samples/DevUIIntegration/.aspire/settings.json create mode 100644 dotnet/samples/DevUIIntegration/.gitignore create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj create mode 100644 dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs create mode 100644 dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj create mode 100644 dotnet/samples/DevUIIntegration/EditorAgent/Program.cs create mode 100644 dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json create mode 100644 dotnet/samples/DevUIIntegration/WriterAgent/Program.cs create mode 100644 dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json create mode 100644 dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 98c7376aaf..76f1a395eb 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,18 +7,22 @@ - 13.0.2 + 13.1.0 + + + + @@ -48,12 +52,12 @@ - - - - - - + + + + + + @@ -65,18 +69,18 @@ - - - - - - + + + + + + - + - + - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a52e026a8f..62bcc6f2dc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -4,6 +4,10 @@ + + + + @@ -23,6 +27,12 @@ + + + + + + diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs new file mode 100644 index 0000000000..e447e957af --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI. +/// +/// +/// +/// When added via , +/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the +/// entity listing without querying each backend's /v1/entities endpoint. +/// +/// +/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints +/// (MapOpenAIResponses and MapOpenAIConversations), not a custom discovery endpoint. +/// +/// +/// The unique identifier for the agent, typically matching the name passed to AddAIAgent. +/// A short description of the agent's capabilities. +public record AgentEntityInfo(string Id, string? Description = null) +{ + /// + /// Gets the display name for the agent. Defaults to if not specified. + /// + public string Name { get; init; } = Id; + + /// + /// Gets the entity type. Defaults to "agent". + /// + public string Type { get; init; } = "agent"; + + /// + /// Gets the framework identifier. Defaults to "agent_framework". + /// + public string Framework { get; init; } = "agent_framework"; +} diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs new file mode 100644 index 0000000000..218e892053 --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Aspire.Hosting.AgentFramework; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Agent Framework DevUI resources to the application model. +/// +public static class AgentFrameworkBuilderExtensions +{ + /// + /// Adds a DevUI resource for testing AI agents in a distributed application. + /// + /// + /// + /// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol. + /// When configured with , it aggregates agents from multiple backend services + /// and provides a unified testing interface. + /// + /// + /// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image. + /// It proxies the DevUI frontend from the first configured backend and aggregates entity listings from all backends. + /// + /// + /// This resource is excluded from the deployment manifest as it is intended for development use only. + /// + /// + /// The . + /// The name to give the resource. + /// The host port for the DevUI web interface. If not specified, a random port will be assigned. + /// A reference to the for chaining. + /// + /// + /// var devui = builder.AddDevUI("devui") + /// .WithAgentService(dotnetAgent) + /// .WithAgentService(pythonAgent); + /// + /// + public static IResourceBuilder AddDevUI( + this IDistributedApplicationBuilder builder, + string name, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var resource = new DevUIResource(name, port); + + var resourceBuilder = builder.AddResource(resource) + .ExcludeFromManifest(); // DevUI is a dev-only tool + + // Initialize the in-process aggregator when the resource is initialized by the orchestrator + builder.Eventing.Subscribe(resource, async (e, ct) => + { + var logger = e.Logger; + var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService().CreateLogger()); + + try + { + // Wait for dependencies (e.g. agent service backends) before starting. + // Custom resources must manually publish BeforeResourceStartedEvent to trigger + // the orchestrator's WaitFor mechanism. + await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false); + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Starting + }).ConfigureAwait(false); + + await aggregator.StartAsync(ct).ConfigureAwait(false); + + // Allocate the endpoint so the URL appears in the Aspire dashboard + var endpointAnnotation = resource.Annotations + .OfType() + .First(ea => ea.Name == DevUIResource.PrimaryEndpointName); + + endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint( + endpointAnnotation, "localhost", aggregator.AllocatedPort); + + var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/"; + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Running, + Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)] + }).ConfigureAwait(false); + + // Shut down the aggregator when the app stops + var lifetime = e.Services.GetRequiredService(); + lifetime.ApplicationStopping.Register(() => + { + e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Finished + }).GetAwaiter().GetResult(); + + aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult(); + aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult(); + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start DevUI aggregator"); + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }).ConfigureAwait(false); + } + }); + + return resourceBuilder; + } + + /// + /// Configures DevUI to connect to an agent service backend. + /// + /// + /// + /// Each agent service should expose the OpenAI Responses and Conversations API endpoints + /// (via MapOpenAIResponses and MapOpenAIConversations). + /// + /// + /// When is provided, the aggregator builds the entity listing from + /// these declarations without querying the backend. When not provided, a single agent named + /// after the service resource is assumed. Agent services don't need a /v1/entities endpoint. + /// + /// + /// The type of the agent service resource. + /// The DevUI resource builder. + /// The agent service resource to connect to. + /// + /// Optional list of agents declared by this backend. When provided, the aggregator uses these + /// declarations directly. When not provided, defaults to a single agent named after the + /// resource. The backend doesn't need to expose a + /// /v1/entities endpoint in either case. + /// + /// + /// An optional prefix to add to entity IDs from this backend. + /// If not specified, the resource name will be used as the prefix. + /// + /// A reference to the for chaining. + /// + /// + /// var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent"); + /// var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent"); + /// + /// builder.AddDevUI("devui") + /// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")]) + /// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]) + /// .WaitFor(writerAgent) + /// .WaitFor(editorAgent); + /// + /// + public static IResourceBuilder WithAgentService( + this IResourceBuilder builder, + IResourceBuilder agentService, + IReadOnlyList? agents = null, + string? entityIdPrefix = null) + where TSource : IResourceWithEndpoints + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(agentService); + + // Default to a single agent named after the service resource + agents ??= [new AgentEntityInfo(agentService.Resource.Name)]; + + builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents)); + builder.WithRelationship(agentService.Resource, "agent-backend"); + + return builder; + } +} diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs new file mode 100644 index 0000000000..ccdaf0ca04 --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Aspire.Hosting.AgentFramework; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// An annotation that tracks an agent service backend referenced by a DevUI resource. +/// +/// +/// This annotation is used to configure DevUI to aggregate entities from multiple +/// agent service backends. Each annotation represents one backend that DevUI should +/// connect to for entity discovery and request routing. +/// +public class AgentServiceAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent service resource. + /// + /// An optional prefix to add to entity IDs from this backend to avoid conflicts. + /// If not specified, the resource name will be used as the prefix. + /// + /// + /// Optional list of agents declared by this backend. When provided, the aggregator builds the entity + /// listing directly from these declarations instead of querying the backend's /v1/entities endpoint. + /// + public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList? agents = null) + { + ArgumentNullException.ThrowIfNull(agentService); + + AgentService = agentService; + EntityIdPrefix = entityIdPrefix; + Agents = agents ?? []; + } + + /// + /// Gets the agent service resource that exposes AI agents. + /// + public IResource AgentService { get; } + + /// + /// Gets the prefix to use for entity IDs from this backend. + /// + /// + /// When null, the resource name will be used as the prefix. + /// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness + /// across multiple agent backends. + /// + public string? EntityIdPrefix { get; } + + /// + /// Gets the list of agents declared by this backend. + /// + /// + /// When non-empty, the DevUI aggregator uses these declarations to build the entity listing + /// without querying the backend. When empty, the aggregator falls back to calling + /// GET /v1/entities on the backend for discovery. + /// + public IReadOnlyList Agents { get; } +} diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj new file mode 100644 index 0000000000..5428eb6a5f --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + + true + aspire integration hosting agent-framework devui ai agents + Microsoft Agent Framework DevUI support for Aspire. + + $(NoWarn);CA1873;RCS1061;VSTHRD002 + + + + + + + + + + + diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs new file mode 100644 index 0000000000..0d72ddbc7a --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs @@ -0,0 +1,659 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends. +/// Serves the DevUI frontend directly from the Microsoft.Agents.AI.DevUI assembly's embedded +/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing. +/// +internal sealed class DevUIAggregatorHostedService : IAsyncDisposable +{ + private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new(); + + private WebApplication? _app; + private readonly DevUIResource _resource; + private readonly ILogger _logger; + private int _allocatedPort; + + // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) + private readonly Dictionary? _frontendResources; + + // Lazily resolved and cached backend map: prefix → base URL + private Dictionary? _cachedBackends; + + public DevUIAggregatorHostedService( + DevUIResource resource, + ILogger logger) + { + _resource = resource; + _logger = logger; + _frontendResources = LoadFrontendResources(logger); + } + + /// + /// Gets the port the aggregator is listening on, available after . + /// + internal int AllocatedPort => _allocatedPort; + + public async Task StartAsync(CancellationToken cancellationToken) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + + builder.Services.AddHttpClient("devui-proxy") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false + }); + + _app = builder.Build(); + _app.Urls.Add("http://127.0.0.1:0"); + + MapRoutes(_app); + + await _app.StartAsync(cancellationToken).ConfigureAwait(false); + + var serverAddresses = _app.Services.GetRequiredService() + .Features.Get(); + + if (serverAddresses is not null) + { + var address = serverAddresses.Addresses.First(); + var uri = new Uri(address); + _allocatedPort = uri.Port; + _logger.LogInformation("DevUI aggregator started on port {Port}", _allocatedPort); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_app is not null) + { + await _app.StopAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync().ConfigureAwait(false); + _app = null; + } + } + + /// + /// Loads the DevUI frontend resources from the Microsoft.Agents.AI.DevUI assembly. + /// The assembly embeds the Vite SPA build output as manifest resources. + /// Returns null if the assembly is not available. + /// + private static Dictionary? LoadFrontendResources(ILogger logger) + { + Assembly assembly; + try + { + assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends."); + return null; + } + + var prefix = $"{assembly.GetName().Name}.resources."; + var resources = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var name in assembly.GetManifestResourceNames()) + { + if (!name.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + // The DevUI middleware maps resource names by replacing dots with slashes. + // Both the key and lookup use the same transform, so they match. + var key = name[prefix.Length..].Replace('.', '/'); + s_contentTypeProvider.TryGetContentType(name, out var contentType); + resources[key] = (name, contentType ?? "application/octet-stream"); + } + + if (resources.Count == 0) + { + logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources"); + return null; + } + + logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count); + return resources; + } + + /// + /// Serves the DevUI frontend. Uses embedded assembly resources if available, + /// otherwise falls back to proxying from the first backend agent service. + /// + private async Task ServeDevUIFrontendAsync(HttpContext context, string? path) + { + // Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly + if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/')) + { + var redirect = reqPath + "/"; + if (context.Request.QueryString.HasValue) + { + redirect += context.Request.QueryString.Value; + } + + context.Response.StatusCode = StatusCodes.Status301MovedPermanently; + context.Response.Headers.Location = redirect; + return; + } + + // Try embedded resources first + if (_frontendResources is not null) + { + var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path; + + if (await TryServeResourceAsync(context, resourcePath).ConfigureAwait(false)) + { + return; + } + + // SPA fallback: serve index.html for paths without a file extension (client-side routing) + if (!resourcePath.Contains('.', StringComparison.Ordinal)) + { + if (await TryServeResourceAsync(context, "index.html").ConfigureAwait(false)) + { + return; + } + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Fallback: proxy from the first backend that serves /devui + var backends = ResolveBackends(); + var firstBackendUrl = backends.Values.FirstOrDefault(); + + if (firstBackendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync( + "DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false); + return; + } + + var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}"; + await ProxyRequestAsync( + context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false); + } + + private async Task TryServeResourceAsync(HttpContext context, string resourcePath) + { + if (_frontendResources is null) + { + return false; + } + + var key = resourcePath.Replace('.', '/'); + + if (!_frontendResources.TryGetValue(key, out var entry)) + { + return false; + } + + Assembly assembly; + try + { + assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); + } + catch + { + return false; + } + + using var stream = assembly.GetManifestResourceStream(entry.ResourceName); + + if (stream is null) + { + return false; + } + + context.Response.ContentType = entry.ContentType; + context.Response.Headers.CacheControl = "no-cache, no-store"; + await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + return true; + } + + private static IResult GetMeta() + { + return Results.Json(new + { + ui_mode = "developer", + version = "0.1.0", + framework = "agent_framework", + runtime = "dotnet", + capabilities = new Dictionary + { + ["tracing"] = false, + ["openai_proxy"] = false, + ["deployment"] = false + }, + auth_required = false + }); + } + + private void MapRoutes(WebApplication app) + { + app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); + + // Intercept API calls for multi-backend aggregation and routing + app.MapGet("/v1/entities", (Delegate)AggregateEntitiesAsync); + app.MapGet("/v1/entities/{**entityPath}", RouteEntityInfoAsync); + app.MapPost("/v1/responses", RouteResponsesAsync); + app.Map("/v1/conversations/{**path}", ProxyConversationsAsync); + app.MapGet("/meta", GetMeta); + + // Serve the DevUI frontend from embedded assembly resources + app.Map("/devui/{**path}", ServeDevUIFrontendAsync); + } + + /// + /// Resolves backend URLs from the resource's annotations. + /// Results are cached after first successful resolution of at least one backend. + /// + private Dictionary ResolveBackends() + { + if (_cachedBackends is not null) + { + return _cachedBackends; + } + + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var annotation in _resource.Annotations.OfType()) + { + if (annotation.AgentService is not IResourceWithEndpoints rwe) + { + continue; + } + + var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; + + try + { + var endpoint = rwe.GetEndpoint("http"); + if (endpoint.IsAllocated) + { + result[prefix] = endpoint.Url; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix); + } + } + + // Only cache if we resolved at least one backend + if (result.Count > 0) + { + _cachedBackends = result; + } + + return result; + } + + private async Task AggregateEntitiesAsync(HttpContext context) + { + var backends = ResolveBackends(); + var allEntities = new JsonArray(); + + foreach (var annotation in _resource.Annotations.OfType()) + { + var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; + + if (annotation.Agents.Count > 0) + { + // Build entities from AppHost-declared metadata — no backend call needed + foreach (var agent in annotation.Agents) + { + allEntities.Add(new JsonObject + { + ["id"] = $"{prefix}/{agent.Id}", + ["type"] = agent.Type, + ["name"] = agent.Name, + ["description"] = agent.Description, + ["framework"] = agent.Framework, + ["_original_id"] = agent.Id, + ["_backend"] = prefix + }); + } + + continue; + } + + // Fallback: query backend /v1/entities for discovery + if (!backends.TryGetValue(prefix, out var baseUrl)) + { + continue; + } + + try + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + var response = await client.GetAsync( + new Uri(new Uri(baseUrl), "/v1/entities"), + context.RequestAborted).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}", + prefix, baseUrl, response.StatusCode); + continue; + } + + var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false); + var doc = JsonNode.Parse(json); + var entities = doc?["entities"]?.AsArray(); + + if (entities is null) + { + continue; + } + + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + var cloned = entity.DeepClone(); + var id = cloned["id"]?.GetValue() ?? cloned["name"]?.GetValue(); + + if (id is not null) + { + cloned["id"] = $"{prefix}/{id}"; + cloned["_original_id"] = id; + cloned["_backend"] = prefix; + } + + allEntities.Add(cloned); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl); + } + } + + return Results.Json(new { entities = allEntities }); + } + + private async Task RouteEntityInfoAsync(HttpContext context, string entityPath) + { + var (backendUrl, actualPath) = ResolveBackend(entityPath); + + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}"); + + using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false); + await CopyResponseAsync(response, context).ConfigureAwait(false); + } + + private async Task RouteResponsesAsync(HttpContext context) + { + var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); + var json = JsonNode.Parse(bodyBytes); + var entityId = json?["metadata"]?["entity_id"]?.GetValue(); + + if (entityId is null) + { + var firstBackend = ResolveBackends().Values.FirstOrDefault(); + if (firstBackend is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false); + return; + } + + var (backendUrl, actualEntityId) = ResolveBackend(entityId); + + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsJsonAsync( + new { error = $"No backend found for entity '{entityId}'" }, + context.RequestAborted).ConfigureAwait(false); + return; + } + + // Rewrite entity_id to the un-prefixed original value + json!["metadata"]!["entity_id"] = actualEntityId; + var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json); + + await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false); + } + + private async Task ProxyConversationsAsync(HttpContext context, string? path) + { + // Try to determine the backend from agent_id query param or request body + string? backendUrl = null; + + var agentId = context.Request.Query["agent_id"].FirstOrDefault(); + if (agentId is not null) + { + (backendUrl, _) = ResolveBackend(agentId); + } + + if (backendUrl is null && context.Request.ContentLength > 0) + { + var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); + var json = JsonNode.Parse(bodyBytes); + var entityId = json?["metadata"]?["entity_id"]?.GetValue() + ?? json?["metadata"]?["agent_id"]?.GetValue(); + + if (entityId is not null) + { + string actualId; + (backendUrl, actualId) = ResolveBackend(entityId); + + if (backendUrl is not null) + { + // Rewrite the entity/agent id to the un-prefixed value + if (json?["metadata"]?["entity_id"] is not null) + { + json!["metadata"]!["entity_id"] = actualId; + } + + if (json?["metadata"]?["agent_id"] is not null) + { + json!["metadata"]!["agent_id"] = actualId; + } + + var rewritten = JsonSerializer.SerializeToUtf8Bytes(json); + var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + await ProxyRequestAsync( + context, backendUrl, targetPath + context.Request.QueryString, rewritten).ConfigureAwait(false); + return; + } + } + + // Couldn't determine backend from body; proxy raw bytes to first backend + backendUrl = ResolveBackends().Values.FirstOrDefault(); + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + await ProxyRequestAsync( + context, backendUrl, targetPathFallback + context.Request.QueryString, bodyBytes).ConfigureAwait(false); + return; + } + + // No body and no query param — route to first backend + backendUrl ??= ResolveBackends().Values.FirstOrDefault(); + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + await ProxyRequestAsync( + context, backendUrl, convPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false); + } + + private static async Task ProxyRequestAsync( + HttpContext context, + string backendUrl, + string path, + byte[]? bodyBytes, + bool streaming = false) + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + + var targetUri = new Uri(new Uri(backendUrl), path); + using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri); + + foreach (var header in context.Request.Headers) + { + if (IsHopByHopHeader(header.Key)) + { + continue; + } + + request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + + if (bodyBytes is not null) + { + request.Content = new ByteArrayContent(bodyBytes); + if (context.Request.ContentType is not null) + { + request.Content.Headers.ContentType = + System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType); + } + } + + var completionOption = streaming + ? HttpCompletionOption.ResponseHeadersRead + : HttpCompletionOption.ResponseContentRead; + + using var response = await client.SendAsync( + request, completionOption, context.RequestAborted).ConfigureAwait(false); + + if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream") + { + context.Response.StatusCode = (int)response.StatusCode; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + + using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false); + await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } + else + { + await CopyResponseAsync(response, context).ConfigureAwait(false); + } + } + + private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId) + { + var backends = ResolveBackends(); + var slashIndex = prefixedId.IndexOf('/'); + + if (slashIndex > 0) + { + var prefix = prefixedId[..slashIndex]; + var rest = prefixedId[(slashIndex + 1)..]; + + if (backends.TryGetValue(prefix, out var url)) + { + return (url, rest); + } + } + + // Fallback: check all prefixes + foreach (var (prefix, url) in backends) + { + if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal)) + { + return (url, prefixedId[(prefix.Length + 1)..]); + } + } + + return (null, prefixedId); + } + + private static async Task ReadRequestBodyAsync(HttpRequest request) + { + using var ms = new MemoryStream(); + await request.Body.CopyToAsync(ms).ConfigureAwait(false); + return ms.ToArray(); + } + + private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context) + { + context.Response.StatusCode = (int)response.StatusCode; + + foreach (var header in response.Headers) + { + if (!IsHopByHopHeader(header.Key)) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + } + + foreach (var header in response.Content.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false); + } + + private static bool IsHopByHopHeader(string headerName) + { + return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Host", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs new file mode 100644 index 0000000000..180562edbf --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a DevUI resource for testing AI agents in a distributed application. +/// +/// +/// DevUI aggregates agents from multiple backend services and provides a unified +/// web interface for testing and debugging AI agents using the OpenAI Responses protocol. +/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no +/// external container image. +/// +/// The name of the DevUI resource. +public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport +{ + internal const string PrimaryEndpointName = "http"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Initializes a new instance of the class with endpoint annotations. + /// + /// The name of the resource. + /// An optional fixed port. If null, a dynamic port is assigned. + internal DevUIResource(string name, int? port) : this(name) + { + Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + name: PrimaryEndpointName, + port: port, + isProxied: false) + { + TargetHost = "localhost" + }); + } + + /// + /// Gets the primary HTTP endpoint for the DevUI web interface. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); +} diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md new file mode 100644 index 0000000000..22e746022e --- /dev/null +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md @@ -0,0 +1,104 @@ +# Aspire.Hosting.AgentFramework library + +Provides extension methods and resource definitions for an Aspire AppHost to configure a DevUI resource for testing and debugging AI agents built with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework). + +## Getting started + +### Prerequisites + +Agent services must expose the OpenAI Responses and Conversations API endpoints. This is compatible with services using [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with `MapOpenAIResponses()` and `MapOpenAIConversations()` mapped. + +### Install the package + +In your AppHost project, install the Aspire Agent Framework Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.AgentFramework +``` + +## Usage example + +Then, in the _AppHost.cs_ file of `AppHost`, add a DevUI resource and connect it to your agent services using the following methods: + +```csharp +var writerAgent = builder.AddProject("writer-agent") + .WithHttpHealthCheck("/health"); + +var editorAgent = builder.AddProject("editor-agent") + .WithHttpHealthCheck("/health"); + +var devui = builder.AddDevUI("devui") + .WithAgentService(writerAgent) + .WithAgentService(editorAgent) + .WaitFor(writerAgent) + .WaitFor(editorAgent); +``` + +Each agent service only needs to map the standard OpenAI API endpoints — no custom discovery endpoints are required: + +```csharp +// In the agent service's Program.cs +builder.AddAIAgent("writer", "You write short stories."); +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); +``` + +## How it works + +`AddDevUI` starts an **in-process aggregator** inside the AppHost — no external container image is needed. The aggregator is a lightweight Kestrel server that: + +1. **Serves the DevUI frontend** from the `Microsoft.Agents.AI.DevUI` assembly's embedded resources (loaded at runtime). If the assembly is not available, it falls back to proxying the frontend from the first backend. +2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`). +3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service. +4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time. + +The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link. + +## Agent discovery + +By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents: + +```csharp +builder.AddDevUI("devui") + .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")]) + .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]); +``` + +Agent metadata is declared at the AppHost level so the aggregator builds the entity listing directly — agent services don't need a `/v1/entities` endpoint. + +## Configuration + +### Custom entity ID prefix + +By default, entity IDs are prefixed with the Aspire resource name. You can specify a custom prefix: + +```csharp +builder.AddDevUI("devui") + .WithAgentService(myService, entityIdPrefix: "custom-prefix"); +``` + +### Custom port + +You can specify a fixed host port for the DevUI web interface: + +```csharp +builder.AddDevUI("devui", port: 8090); +``` + +### DevUI frontend assembly + +To serve the DevUI frontend directly from the aggregator (instead of proxying from a backend), add the `Microsoft.Agents.AI.DevUI` NuGet package to your AppHost project. The aggregator loads its embedded resources at runtime via `Assembly.Load`. + +## Additional documentation + +* https://github.com/microsoft/agent-framework +* https://github.com/microsoft/agent-framework/tree/main/dotnet/src/Microsoft.Agents.AI.DevUI + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/dotnet/samples/DevUIIntegration/.aspire/settings.json b/dotnet/samples/DevUIIntegration/.aspire/settings.json new file mode 100644 index 0000000000..842d8f7ce6 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" +} \ No newline at end of file diff --git a/dotnet/samples/DevUIIntegration/.gitignore b/dotnet/samples/DevUIIntegration/.gitignore new file mode 100644 index 0000000000..bdc7d02918 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/.gitignore @@ -0,0 +1 @@ +**/**/*.Development.json diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj new file mode 100644 index 0000000000..c8627ac93d --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj @@ -0,0 +1,27 @@ + + + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs new file mode 100644 index 0000000000..f7efd54e92 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +var tenantId = builder.AddParameterFromConfiguration("tenant", "Azure:TenantId"); +var existingFoundryName = builder.AddParameter("existingFoundryName") + .WithDescription("The name of the existing Azure Foundry resource."); +var existingFoundryResourceGroup = builder.AddParameter("existingFoundryResourceGroup") + .WithDescription("The resource group of the existing Azure Foundry resource."); + +var foundry = builder.AddAzureAIFoundry("foundry") + .AsExisting(existingFoundryName, existingFoundryResourceGroup); + +// Add the writer agent service +var writerAgent = builder.AddProject("writer-agent") + .WithHttpHealthCheck("/health") + .WithReference(foundry).WaitFor(foundry); + +// Add the editor agent service +var editorAgent = builder.AddProject("editor-agent") + .WithHttpHealthCheck("/health") + .WithReference(foundry).WaitFor(foundry); + +// Add DevUI integration that aggregates agents from all agent services. +// Agent metadata is declared here so backends don't need a /v1/entities endpoint. +var devui = builder.AddDevUI("devui") + .WithAgentService(writerAgent, agents: [new("writer")]) + .WithAgentService(editorAgent, agents: [new("editor")]) + .WaitFor(writerAgent) + .WaitFor(editorAgent); + +builder.Build().Run(); diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..1012f97aa1 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16500;http://localhost:16501", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17250", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17250", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16501", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17251", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17251", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json new file mode 100644 index 0000000000..bfe8cb0cde --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json @@ -0,0 +1,14 @@ +{ + "Azure": { + "TenantId": "", + "SubscriptionId": "", + "AllowResourceGroupCreation": true, + "ResourceGroup": "", + "Location": "", + "CredentialSource": "AzureCli" + }, + "Parameters": { + "existingFoundryName": "", + "existingFoundryResourceGroup": "" + } +} diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj new file mode 100644 index 0000000000..0ba7cf1e7d --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + + enable + enable + true + + + + + + + + + + + + + + + diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..168008cfd9 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +#pragma warning disable CA1724 // Type name 'Extensions' conflicts with namespace - acceptable for Aspire pattern +public static class Extensions +#pragma warning restore CA1724 +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj new file mode 100644 index 0000000000..ed726520a3 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + + enable + enable + b2c3d4e5-f6a7-8901-bcde-f12345678901 + + + + + + + + + + + + + + diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs b/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs new file mode 100644 index 0000000000..1a6e042273 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs @@ -0,0 +1,49 @@ +using System.ComponentModel; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureChatCompletionsClient(connectionName: "foundry", + configureSettings: settings => + { + settings.TokenCredential = new DefaultAzureCredential(); + settings.EnableSensitiveTelemetryData = true; + }) + .AddChatClient("gpt-4.1"); + +builder.AddAIAgent("editor", (sp, key) => +{ + var chatClient = sp.GetRequiredService(); + return new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] + ); +}); + +// Register services for OpenAI responses and conversations +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +// Map OpenAI API endpoints — DevUI aggregator routes requests here +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +app.MapDefaultEndpoints(); + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +static string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..3ad5a6f098 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs b/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs new file mode 100644 index 0000000000..19cfc08768 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs @@ -0,0 +1,30 @@ +using Azure.Identity; +using Microsoft.Agents.AI.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureChatCompletionsClient(connectionName: "foundry", + configureSettings: settings => + { + settings.TokenCredential = new DefaultAzureCredential(); + settings.EnableSensitiveTelemetryData = true; + }) + .AddChatClient("gpt-4.1"); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +// Register services for OpenAI responses and conversations +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +// Map OpenAI API endpoints — DevUI aggregator routes requests here +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..5220475800 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5280", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj new file mode 100644 index 0000000000..f149138ce0 --- /dev/null +++ b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + + enable + enable + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + + + + + + + + + + + + +