// Copyright (c) Microsoft. All rights reserved. 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 serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and /// falls back to proxying from the first configured backend. It 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 aggregator.DisposeAsync().ConfigureAwait(false); 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; } }