adds devui integration and samples

This commit is contained in:
tommasodotnet
2026-02-09 19:17:52 +01:00
Unverified
parent ad0dac3c86
commit 3c952911ce
23 changed files with 1560 additions and 16 deletions
+20 -16
View File
@@ -7,18 +7,22 @@
</PropertyGroup>
<PropertyGroup>
<!-- Aspire -->
<AspireAppHostSdkVersion>13.0.2</AspireAppHostSdkVersion>
<AspireAppHostSdkVersion>13.1.0</AspireAppHostSdkVersion>
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.3.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.4.1" />
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
<PackageVersion Include="Aspire.Hosting.Azure.AIFoundry" Version="13.1.0-preview.1.25616.3" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.Core" Version="1.50.0" />
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
@@ -48,12 +52,12 @@
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
@@ -65,18 +69,18 @@
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.2.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.2.0-preview.1.26063.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
<!-- Vector Stores -->
+10
View File
@@ -4,6 +4,10 @@
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/aspire-integration/" />
<Folder Name="/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/">
<Project Path="aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
</Folder>
<Folder Name="/Samples/">
<File Path="samples/README.md" />
</Folder>
@@ -23,6 +27,12 @@
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/DevUIIntegration/">
<Project Path="samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" />
<Project Path="samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj" />
<Project Path="samples/DevUIIntegration/EditorAgent/EditorAgent.csproj" />
<Project Path="samples/DevUIIntegration/WriterAgent/WriterAgent.csproj" />
</Folder>
<Folder Name="/Samples/Durable/" />
<Folder Name="/Samples/Durable/Agents/" />
<Folder Name="/Samples/Durable/Agents/AzureFunctions/">
@@ -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;
/// <summary>
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
/// </summary>
/// <remarks>
/// <para>
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
/// </para>
/// <para>
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
/// </para>
/// </remarks>
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
/// <param name="Description">A short description of the agent's capabilities.</param>
public record AgentEntityInfo(string Id, string? Description = null)
{
/// <summary>
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
/// </summary>
public string Name { get; init; } = Id;
/// <summary>
/// Gets the entity type. Defaults to <c>"agent"</c>.
/// </summary>
public string Type { get; init; } = "agent";
/// <summary>
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
/// </summary>
public string Framework { get; init; } = "agent_framework";
}
@@ -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;
/// <summary>
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
/// </summary>
public static class AgentFrameworkBuilderExtensions
{
/// <summary>
/// Adds a DevUI resource for testing AI agents in a distributed application.
/// </summary>
/// <remarks>
/// <para>
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
/// and provides a unified testing interface.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// This resource is excluded from the deployment manifest as it is intended for development use only.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name to give the resource.</param>
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var devui = builder.AddDevUI("devui")
/// .WithAgentService(dotnetAgent)
/// .WithAgentService(pythonAgent);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> 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<InitializeResourceEvent>(resource, async (e, ct) =>
{
var logger = e.Logger;
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
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<EndpointAnnotation>()
.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<IHostApplicationLifetime>();
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;
}
/// <summary>
/// Configures DevUI to connect to an agent service backend.
/// </summary>
/// <remarks>
/// <para>
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
/// </para>
/// <para>
/// When <paramref name="agents"/> 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 <c>/v1/entities</c> endpoint.
/// </para>
/// </remarks>
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
/// <param name="builder">The DevUI resource builder.</param>
/// <param name="agentService">The agent service resource to connect to.</param>
/// <param name="agents">
/// 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
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
/// <c>/v1/entities</c> endpoint in either case.
/// </param>
/// <param name="entityIdPrefix">
/// An optional prefix to add to entity IDs from this backend.
/// If not specified, the resource name will be used as the prefix.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <example>
/// <code>
/// var writerAgent = builder.AddProject&lt;Projects.WriterAgent&gt;("writer-agent");
/// var editorAgent = builder.AddProject&lt;Projects.EditorAgent&gt;("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);
/// </code>
/// </example>
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
this IResourceBuilder<DevUIResource> builder,
IResourceBuilder<TSource> agentService,
IReadOnlyList<AgentEntityInfo>? 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;
}
}
@@ -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;
/// <summary>
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class AgentServiceAnnotation : IResourceAnnotation
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
/// </summary>
/// <param name="agentService">The agent service resource.</param>
/// <param name="entityIdPrefix">
/// 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.
/// </param>
/// <param name="agents">
/// 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 <c>/v1/entities</c> endpoint.
/// </param>
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
{
ArgumentNullException.ThrowIfNull(agentService);
AgentService = agentService;
EntityIdPrefix = entityIdPrefix;
Agents = agents ?? [];
}
/// <summary>
/// Gets the agent service resource that exposes AI agents.
/// </summary>
public IResource AgentService { get; }
/// <summary>
/// Gets the prefix to use for entity IDs from this backend.
/// </summary>
/// <remarks>
/// When <c>null</c>, the resource name will be used as the prefix.
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
/// across multiple agent backends.
/// </remarks>
public string? EntityIdPrefix { get; }
/// <summary>
/// Gets the list of agents declared by this backend.
/// </summary>
/// <remarks>
/// 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
/// <c>GET /v1/entities</c> on the backend for discovery.
/// </remarks>
public IReadOnlyList<AgentEntityInfo> Agents { get; }
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<!-- Suppress analyzer warnings for Aspire integration code -->
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002</NoWarn>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends.
/// Serves the DevUI frontend directly from the <c>Microsoft.Agents.AI.DevUI</c> assembly's embedded
/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing.
/// </summary>
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<string, (string ResourceName, string ContentType)>? _frontendResources;
// Lazily resolved and cached backend map: prefix → base URL
private Dictionary<string, string>? _cachedBackends;
public DevUIAggregatorHostedService(
DevUIResource resource,
ILogger logger)
{
_resource = resource;
_logger = logger;
_frontendResources = LoadFrontendResources(logger);
}
/// <summary>
/// Gets the port the aggregator is listening on, available after <see cref="StartAsync"/>.
/// </summary>
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<IServer>()
.Features.Get<IServerAddressesFeature>();
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;
}
}
/// <summary>
/// Loads the DevUI frontend resources from the <c>Microsoft.Agents.AI.DevUI</c> assembly.
/// The assembly embeds the Vite SPA build output as manifest resources.
/// Returns null if the assembly is not available.
/// </summary>
private static Dictionary<string, (string ResourceName, string ContentType)>? 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<string, (string, string)>(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;
}
/// <summary>
/// Serves the DevUI frontend. Uses embedded assembly resources if available,
/// otherwise falls back to proxying from the first backend agent service.
/// </summary>
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<bool> 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<string, bool>
{
["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);
}
/// <summary>
/// Resolves backend URLs from the resource's <see cref="AgentServiceAnnotation"/> annotations.
/// Results are cached after first successful resolution of at least one backend.
/// </summary>
private Dictionary<string, string> ResolveBackends()
{
if (_cachedBackends is not null)
{
return _cachedBackends;
}
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var annotation in _resource.Annotations.OfType<AgentServiceAnnotation>())
{
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<IResult> AggregateEntitiesAsync(HttpContext context)
{
var backends = ResolveBackends();
var allEntities = new JsonArray();
foreach (var annotation in _resource.Annotations.OfType<AgentServiceAnnotation>())
{
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<IHttpClientFactory>();
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<string>() ?? cloned["name"]?.GetValue<string>();
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<IHttpClientFactory>();
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<string>();
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<string>()
?? json?["metadata"]?["agent_id"]?.GetValue<string>();
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<IHttpClientFactory>();
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<byte[]> 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);
}
}
@@ -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;
/// <summary>
/// Represents a DevUI resource for testing AI agents in a distributed application.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="name">The name of the DevUI resource.</param>
public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
{
internal const string PrimaryEndpointName = "http";
private EndpointReference? _primaryEndpoint;
/// <summary>
/// Initializes a new instance of the <see cref="DevUIResource"/> class with endpoint annotations.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="port">An optional fixed port. If <c>null</c>, a dynamic port is assigned.</param>
internal DevUIResource(string name, int? port) : this(name)
{
Annotations.Add(new EndpointAnnotation(
ProtocolType.Tcp,
uriScheme: "http",
name: PrimaryEndpointName,
port: port,
isProxied: false)
{
TargetHost = "localhost"
});
}
/// <summary>
/// Gets the primary HTTP endpoint for the DevUI web interface.
/// </summary>
public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName);
}
@@ -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<Projects.WriterAgent>("writer-agent")
.WithHttpHealthCheck("/health");
var editorAgent = builder.AddProject<Projects.EditorAgent>("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
@@ -0,0 +1,3 @@
{
"appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
}
@@ -0,0 +1 @@
**/**/*.Development.json
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="$(AspireAppHostSdkVersion)" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
<PackageReference Include="Aspire.Hosting.Azure.AIFoundry" />
<PackageReference Include="OpenAI" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\..\..\aspire-integration\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\WriterAgent\WriterAgent.csproj" />
<ProjectReference Include="..\EditorAgent\EditorAgent.csproj" />
</ItemGroup>
</Project>
@@ -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<Projects.WriterAgent>("writer-agent")
.WithHttpHealthCheck("/health")
.WithReference(foundry).WaitFor(foundry);
// Add the editor agent service
var editorAgent = builder.AddProject<Projects.EditorAgent>("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();
@@ -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"
}
}
}
}
@@ -0,0 +1,14 @@
{
"Azure": {
"TenantId": "",
"SubscriptionId": "",
"AllowResourceGroupCreation": true,
"ResourceGroup": "",
"Location": "",
"CredentialSource": "AzureCli"
},
"Parameters": {
"existingFoundryName": "",
"existingFoundryResourceGroup": ""
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -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<TBuilder>(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<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(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<TBuilder>(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<TBuilder>(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;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>b2c3d4e5-f6a7-8901-bcde-f12345678901</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.AI.Inference" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -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<IChatClient>();
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}
""";
@@ -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"
}
}
}
}
@@ -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();
@@ -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"
}
}
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks></TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.AI.Inference" />
<PackageReference Include="Azure.Core" />
<PackageReference Include="Azure.Identity" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
</ItemGroup>
</Project>