.NET: Add DevUI package for .NET (#1603)

* Implement DevUI

* Review feedback

* Fix build
This commit is contained in:
Reuben Bond
2025-11-05 12:02:48 -08:00
committed by GitHub
Unverified
parent 94a5ba3448
commit 8855bfb065
36 changed files with 2733 additions and 1176 deletions
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Provides helper methods for configuring the Microsoft Agents AI DevUI in ASP.NET applications.
/// </summary>
public static class DevUIExtensions
{
/// <summary>
/// Adds the necessary services for the DevUI to the application builder.
/// </summary>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIConversations();
builder.Services.AddOpenAIResponses();
return builder;
}
/// <summary>
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapEntities();
group.MapOpenAIConversations();
group.MapOpenAIResponses();
return group;
}
/// <summary>
/// Maps an endpoint that serves the DevUI.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <param name="pattern">
/// The route pattern for the endpoint (e.g., "/devui", "/agent-ui").
/// Defaults to "/devui" if not specified. This is the path where DevUI will be accessible.
/// </param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="endpoints"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="pattern"/> is null or whitespace.</exception>
internal static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints,
[StringSyntax("Route")] string pattern = "/devui")
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
// Ensure the pattern doesn't end with a slash for consistency
var cleanPattern = pattern.TrimEnd('/');
// Create the DevUI handler
var logger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIMiddleware>>();
var devUIHandler = new DevUIMiddleware(logger, cleanPattern);
return endpoints.MapGet($"{cleanPattern}/{{*path}}", devUIHandler.HandleRequestAsync)
.WithName($"DevUI at {cleanPattern}")
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
}
}
@@ -0,0 +1,236 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Frozen;
using System.IO.Compression;
using System.Reflection;
using System.Security.Cryptography;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Handler that serves embedded DevUI resource files from the 'resources' directory.
/// </summary>
internal sealed class DevUIMiddleware
{
private const string GZipEncodingValue = "gzip";
private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue);
private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly;
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
private static readonly StringValues s_cacheControl = new(new CacheControlHeaderValue()
{
NoCache = true,
NoStore = true,
}.ToString());
private readonly ILogger<DevUIMiddleware> _logger;
private readonly FrozenDictionary<string, ResourceEntry> _resourceCache;
private readonly string _basePath;
/// <summary>
/// Initializes a new instance of the <see cref="DevUIMiddleware"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="basePath">The base path where DevUI is mounted.</param>
public DevUIMiddleware(ILogger<DevUIMiddleware> logger, string basePath)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentException.ThrowIfNullOrEmpty(basePath);
this._logger = logger;
this._basePath = basePath.TrimEnd('/');
// Build resource cache
var resourceNamePrefix = $"{s_assembly.GetName().Name}.resources.";
this._resourceCache = s_assembly
.GetManifestResourceNames()
.Where(p => p.StartsWith(resourceNamePrefix, StringComparison.Ordinal))
.ToFrozenDictionary(
p => p[resourceNamePrefix.Length..].Replace('.', '/'),
CreateResourceEntry,
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Handles an HTTP request for DevUI resources.
/// </summary>
/// <param name="context">The HTTP context.</param>
public async Task HandleRequestAsync(HttpContext context)
{
var path = context.Request.Path.Value;
if (path == null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
// If requesting the base path without a trailing slash, redirect to include it
// This ensures relative URLs in the HTML work correctly
if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/'))
{
var redirectUrl = $"{path}/";
if (context.Request.QueryString.HasValue)
{
redirectUrl += context.Request.QueryString.Value;
}
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
context.Response.Headers.Location = redirectUrl;
this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", path, redirectUrl);
return;
}
// Remove the base path to get the resource path
var resourcePath = path.StartsWith(this._basePath, StringComparison.OrdinalIgnoreCase)
? path.Substring(this._basePath.Length).TrimStart('/')
: path.TrimStart('/');
// If requesting the base path, serve index.html
if (string.IsNullOrEmpty(resourcePath))
{
resourcePath = "index.html";
}
// Try to serve the embedded resource
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
{
return;
}
// If resource not found, try serving index.html for client-side routing
if (!resourcePath.Contains('.', StringComparison.Ordinal) || resourcePath.EndsWith('/'))
{
if (await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
{
return;
}
}
// Resource not found
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
{
try
{
if (!this._resourceCache.TryGetValue(resourcePath.Replace('.', '/'), out var cacheEntry))
{
this._logger.LogDebug("Embedded resource not found: {ResourcePath}", resourcePath);
return false;
}
var response = context.Response;
// Check if client has cached version
if (context.Request.Headers.IfNoneMatch == cacheEntry.ETag)
{
response.StatusCode = StatusCodes.Status304NotModified;
this._logger.LogDebug("Resource not modified (304): {ResourcePath}", resourcePath);
return true;
}
var responseHeaders = response.Headers;
byte[] content;
bool serveCompressed;
if (cacheEntry.CompressedContent is not null && IsGZipAccepted(context.Request))
{
serveCompressed = true;
responseHeaders.ContentEncoding = s_gzipEncodingHeader;
responseHeaders.ContentLength = cacheEntry.CompressedContent.Length;
content = cacheEntry.CompressedContent;
}
else
{
serveCompressed = false;
responseHeaders.ContentLength = cacheEntry.DecompressedContent!.Length;
content = cacheEntry.DecompressedContent;
}
responseHeaders.CacheControl = s_cacheControl;
responseHeaders.ContentType = cacheEntry.ContentType;
responseHeaders.ETag = cacheEntry.ETag;
await response.Body.WriteAsync(content, context.RequestAborted).ConfigureAwait(false);
this._logger.LogDebug("Served embedded resource: {ResourcePath} (compressed: {Compressed})", resourcePath, serveCompressed);
return true;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error serving embedded resource: {ResourcePath}", resourcePath);
return false;
}
}
private static bool IsGZipAccepted(HttpRequest httpRequest)
{
if (httpRequest.GetTypedHeaders().AcceptEncoding is not { Count: > 0 } acceptEncoding)
{
return false;
}
for (int i = 0; i < acceptEncoding.Count; i++)
{
var encoding = acceptEncoding[i];
if (encoding.Quality is not 0 &&
string.Equals(encoding.Value.Value, GZipEncodingValue, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static ResourceEntry CreateResourceEntry(string resourceName)
{
using var resourceStream = s_assembly.GetManifestResourceStream(resourceName)!;
using var decompressedContent = new MemoryStream();
// Read and cache the original resource content
resourceStream.CopyTo(decompressedContent);
var decompressedArray = decompressedContent.ToArray();
// Compress the content
using var compressedContent = new MemoryStream();
using (var gzip = new GZipStream(compressedContent, CompressionMode.Compress, leaveOpen: true))
{
// This is a synchronous write to a memory stream.
// There is no benefit to asynchrony here.
gzip.Write(decompressedArray);
}
// Only use compression if it actually reduces size
byte[]? compressedArray = compressedContent.Length < decompressedArray.Length
? compressedContent.ToArray()
: null;
var hash = SHA256.HashData(compressedArray ?? decompressedArray);
var eTag = $"\"{Convert.ToBase64String(hash)}\"";
// Determine content type from resource name
var contentType = s_contentTypeProvider.TryGetContentType(resourceName, out var ct)
? ct
: "application/octet-stream";
return new ResourceEntry(resourceName, decompressedArray, compressedArray, eTag, contentType);
}
private sealed class ResourceEntry(string resourceName, byte[] decompressedContent, byte[]? compressedContent, string eTag, string contentType)
{
public byte[]? CompressedContent { get; } = compressedContent;
public string ContentType { get; } = contentType;
public byte[] DecompressedContent { get; } = decompressedContent;
public string ETag { get; } = eTag;
public string ResourceName { get; } = resourceName;
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DevUI.Entities;
/// <summary>
/// JSON serialization context for entity-related types.
/// Enables AOT-compatible JSON serialization using source generators.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EntityInfo))]
[JsonSerializable(typeof(DiscoveryResponse))]
[JsonSerializable(typeof(EnvVarRequirement))]
[JsonSerializable(typeof(List<EntityInfo>))]
[JsonSerializable(typeof(List<JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(JsonElement))]
[ExcludeFromCodeCoverage]
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DevUI.Entities;
/// <summary>
/// Information about an environment variable required by an entity.
/// </summary>
internal sealed record EnvVarRequirement(
[property: JsonPropertyName("name")]
string Name,
[property: JsonPropertyName("description")]
string? Description = null,
[property: JsonPropertyName("required")]
bool Required = true,
[property: JsonPropertyName("example")]
string? Example = null
);
/// <summary>
/// Information about an entity (agent or workflow).
/// </summary>
internal sealed record EntityInfo(
[property: JsonPropertyName("id")]
string Id,
[property: JsonPropertyName("type")]
string Type,
[property: JsonPropertyName("name")]
string Name,
[property: JsonPropertyName("description")]
string? Description = null,
[property: JsonPropertyName("framework")]
string Framework = "dotnet",
[property: JsonPropertyName("tools")]
List<string>? Tools = null,
[property: JsonPropertyName("metadata")]
Dictionary<string, JsonElement>? Metadata = null
)
{
[JsonPropertyName("source")]
public string? Source { get; init; } = "di";
[JsonPropertyName("original_url")]
public string? OriginalUrl { get; init; }
// Workflow-specific fields
[JsonPropertyName("required_env_vars")]
public List<EnvVarRequirement>? RequiredEnvVars { get; init; }
[JsonPropertyName("executors")]
public List<string>? Executors { get; init; }
[JsonPropertyName("workflow_dump")]
public JsonElement? WorkflowDump { get; init; }
[JsonPropertyName("input_schema")]
public JsonElement? InputSchema { get; init; }
[JsonPropertyName("input_type_name")]
public string? InputTypeName { get; init; }
[JsonPropertyName("start_executor_id")]
public string? StartExecutorId { get; init; }
};
/// <summary>
/// Response containing a list of discovered entities.
/// </summary>
internal sealed record DiscoveryResponse(
[property: JsonPropertyName("entities")]
List<EntityInfo> Entities
);
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.DevUI.Entities;
/// <summary>
/// Extension methods for serializing workflows to DevUI-compatible format
/// </summary>
internal static class WorkflowSerializationExtensions
{
// The frontend max iterations default value expected by the DevUI frontend
private const int MaxIterationsDefault = 100;
/// <summary>
/// Converts a workflow to a dictionary representation compatible with DevUI frontend.
/// This matches the Python workflow.to_dict() format expected by the UI.
/// </summary>
public static Dictionary<string, object> ToDevUIDict(this Workflow workflow)
{
var result = new Dictionary<string, object>
{
["id"] = workflow.Name ?? Guid.NewGuid().ToString(),
["start_executor_id"] = workflow.StartExecutorId,
["max_iterations"] = MaxIterationsDefault
};
// Add optional fields
if (!string.IsNullOrEmpty(workflow.Name))
{
result["name"] = workflow.Name;
}
if (!string.IsNullOrEmpty(workflow.Description))
{
result["description"] = workflow.Description;
}
// Convert executors to Python-compatible format
result["executors"] = ConvertExecutorsToDict(workflow);
// Convert edges to edge_groups format
result["edge_groups"] = ConvertEdgesToEdgeGroups(workflow);
return result;
}
/// <summary>
/// Converts workflow executors to a dictionary format compatible with Python
/// </summary>
private static Dictionary<string, object> ConvertExecutorsToDict(Workflow workflow)
{
var executors = new Dictionary<string, object>();
// Extract executor IDs from edges and start executor
// (Registrations is internal, so we infer executors from the graph structure)
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create executor entries (we can't access internal Registrations for type info)
foreach (var executorId in executorIds)
{
executors[executorId] = new Dictionary<string, object>
{
["id"] = executorId,
["type"] = "Executor"
};
}
return executors;
}
/// <summary>
/// Converts workflow edges to edge_groups format expected by the UI
/// </summary>
private static List<object> ConvertEdgesToEdgeGroups(Workflow workflow)
{
var edgeGroups = new List<object>();
var edgeGroupId = 0;
// Get edges using the public ReflectEdges method
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
foreach (var edgeInfo in edgeSet)
{
if (edgeInfo is DirectEdgeInfo directEdge)
{
// Single edge group for direct edges
var edges = new List<object>();
foreach (var source in directEdge.Connection.SourceIds)
{
foreach (var sink in directEdge.Connection.SinkIds)
{
var edge = new Dictionary<string, object>
{
["source_id"] = source,
["target_id"] = sink
};
// Add condition name if this is a conditional edge
if (directEdge.HasCondition)
{
edge["condition_name"] = "predicate";
}
edges.Add(edge);
}
}
edgeGroups.Add(new Dictionary<string, object>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "SingleEdgeGroup",
["edges"] = edges
});
}
else if (edgeInfo is FanOutEdgeInfo fanOutEdge)
{
// FanOut edge group
var edges = new List<object>();
foreach (var source in fanOutEdge.Connection.SourceIds)
{
foreach (var sink in fanOutEdge.Connection.SinkIds)
{
edges.Add(new Dictionary<string, object>
{
["source_id"] = source,
["target_id"] = sink
});
}
}
var fanOutGroup = new Dictionary<string, object>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "FanOutEdgeGroup",
["edges"] = edges
};
if (fanOutEdge.HasAssigner)
{
fanOutGroup["selection_func_name"] = "selector";
}
edgeGroups.Add(fanOutGroup);
}
else if (edgeInfo is FanInEdgeInfo fanInEdge)
{
// FanIn edge group
var edges = new List<object>();
foreach (var source in fanInEdge.Connection.SourceIds)
{
foreach (var sink in fanInEdge.Connection.SinkIds)
{
edges.Add(new Dictionary<string, object>
{
["source_id"] = source,
["target_id"] = sink
});
}
}
edgeGroups.Add(new Dictionary<string, object>
{
["id"] = $"edge_group_{edgeGroupId++}",
["type"] = "FanInEdgeGroup",
["edges"] = edges
});
}
}
}
return edgeGroups;
}
}
@@ -0,0 +1,244 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI.DevUI.Entities;
using Microsoft.Agents.AI.Hosting;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Provides extension methods for mapping entity discovery and management endpoints to an <see cref="IEndpointRouteBuilder"/>.
/// </summary>
internal static class EntitiesApiExtensions
{
/// <summary>
/// Maps HTTP API endpoints for entity discovery and management.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the routes to.</param>
/// <returns>The <see cref="IEndpointRouteBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoints:
/// <list type="bullet">
/// <item><description>GET /v1/entities - List all registered entities (agents and workflows)</description></item>
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
/// </list>
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
/// </remarks>
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/v1/entities")
.WithTags("Entities");
// List all entities
group.MapGet("", ListEntitiesAsync)
.WithName("ListEntities")
.WithSummary("List all registered entities (agents and workflows)")
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
// Get detailed entity information
group.MapGet("{entityId}/info", GetEntityInfoAsync)
.WithName("GetEntityInfo")
.WithSummary("Get detailed information about a specific entity")
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
.Produces(StatusCodes.Status404NotFound);
return group;
}
private static async Task<IResult> ListEntitiesAsync(
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
CancellationToken cancellationToken)
{
try
{
var entities = new List<EntityInfo>();
// Discover agents from the agent catalog
if (agentCatalog is not null)
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
entities.Add(new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
});
}
}
// Discover workflows from the workflow catalog
if (workflowCatalog is not null)
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
entities.Add(new EntityInfo(
Id: workflow.Name ?? workflow.StartExecutorId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
});
}
}
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
}
catch (Exception ex)
{
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Error listing entities");
}
}
private static async Task<IResult> GetEntityInfoAsync(
string entityId,
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
CancellationToken cancellationToken)
{
try
{
// Try to find the entity among discovered agents
if (agentCatalog is not null)
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
{
var entityInfo = new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
}
// Try to find the entity among discovered workflows
if (workflowCatalog is not null)
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
var entityInfo = new EntityInfo(
Id: workflowId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "Input",
StartExecutorId = workflow.StartExecutorId
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
}
return Results.NotFound(new { error = new { message = $"Entity '{entityId}' not found.", type = "invalid_request_error" } });
}
catch (Exception ex)
{
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Error getting entity info");
}
}
}
@@ -0,0 +1,68 @@
<Project>
<PropertyGroup>
<!-- Frontend paths - pointing to the Python package's frontend -->
<FrontendRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\frontend'))</FrontendRoot>
<FrontendBuildOutput>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..\..\python\packages\devui\agent_framework_devui\ui'))</FrontendBuildOutput>
<FrontendPackageJson>$(FrontendRoot)\package.json</FrontendPackageJson>
<FrontendNodeModules>$(FrontendRoot)\node_modules</FrontendNodeModules>
</PropertyGroup>
<!-- Ensure npm packages are installed before building -->
<Target Name="EnsureNodeModules" BeforeTargets="BeforeBuild" Condition="!Exists('$(FrontendNodeModules)')">
<Exec Command="npm install" WorkingDirectory="$(FrontendRoot)" />
</Target>
<!-- Collect frontend source files for incremental build tracking -->
<ItemGroup>
<FrontendSourceFiles Include="$(FrontendRoot)\src\**\*" />
<FrontendSourceFiles Include="$(FrontendPackageJson);$(FrontendRoot)\vite.config.ts;$(FrontendRoot)\tsconfig.json;$(FrontendRoot)\index.html" />
</ItemGroup>
<!-- Define the required frontend assets -->
<ItemGroup>
<FrontendAsset Include="$(FrontendBuildOutput)\index.html" />
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.js" />
<FrontendAsset Include="$(FrontendBuildOutput)\assets\index.css" />
<FrontendAsset Include="$(FrontendBuildOutput)\agentframework.svg" />
</ItemGroup>
<!-- Use a marker file for incremental build tracking -->
<PropertyGroup>
<FrontendBuildMarker>$(BaseIntermediateOutputPath)\frontend.build.marker</FrontendBuildMarker>
</PropertyGroup>
<!-- Build the frontend -->
<Target Name="BuildFrontend" BeforeTargets="AssignTargetPaths" DependsOnTargets="EnsureNodeModules" Inputs="@(FrontendSourceFiles)" Outputs="$(FrontendBuildMarker)">
<!-- Set VITE_API_BASE_URL to empty string for relative URLs -->
<Exec Command="npm run build" WorkingDirectory="$(FrontendRoot)" EnvironmentVariables="VITE_API_BASE_URL=" />
<!-- Create marker file to track successful build -->
<Touch Files="$(FrontendBuildMarker)" AlwaysCreate="true" />
</Target>
<!-- Statically include frontend assets as embedded resources for VS to show them -->
<ItemGroup>
<EmbeddedResource Include="$(FrontendBuildOutput)\**\*" Condition="Exists('$(FrontendBuildOutput)')">
<Link>resources\$([MSBuild]::MakeRelative('$(FrontendBuildOutput)', '%(Identity)'))</Link>
</EmbeddedResource>
</ItemGroup>
<!-- Verify required frontend assets are present -->
<Target Name="ValidateFrontendAssets" BeforeTargets="CoreCompile" DependsOnTargets="BuildFrontend">
<ItemGroup>
<MissingAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
</ItemGroup>
<Error Condition="'@(MissingAsset)' != ''" Text="Required frontend assets are missing: @(MissingAsset, ', '). Frontend build may have failed." />
</Target>
<!-- Verify assets are present before packing -->
<Target Name="ValidateFrontendAssetsBeforePack" BeforeTargets="GenerateNuspec">
<ItemGroup>
<MissingPackageAsset Include="@(FrontendAsset)" Condition="!Exists('%(Identity)')" />
</ItemGroup>
<Error Condition="'@(MissingPackageAsset)' != ''" Text="Cannot create NuGet package: Required frontend assets are missing: @(MissingPackageAsset, ', ')" />
</Target>
</Project>
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
<OutputType>Library</OutputType>
<Title>Microsoft Agent Framework Developer UI</Title>
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<!-- Suppress warnings for internal DevUI implementation -->
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
</PropertyGroup>
<!-- Import nuget packaging properties -->
<Import Project="..\..\nuget\nuget-package.props" />
<!-- Import frontend web assets build targets -->
<Import Project="Microsoft.Agents.AI.DevUI.Frontend.targets" />
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"profiles": {
"Microsoft.Agents.AI.DevUI": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:57966;http://localhost:57967"
}
}
}
@@ -0,0 +1,42 @@
# Microsoft.Agents.AI.DevUI
This package provides a web interface for testing and debugging AI agents during development.
## Installation
```bash
dotnet add package Microsoft.Agents.AI.DevUI
dotnet add package Microsoft.Agents.AI.Hosting
dotnet add package Microsoft.Agents.AI.Hosting.OpenAI
```
## Usage
Add DevUI services and map the endpoint in your ASP.NET Core application:
```csharp
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI;
var builder = WebApplication.CreateBuilder(args);
// Register your agents
builder.AddAIAgent("assistant", "You are a helpful assistant.");
if (builder.Environment.IsDevelopment())
{
// Add DevUI services
builder.AddDevUI();
}
var app = builder.Build();
if (builder.Environment.IsDevelopment())
{
// Map DevUI endpoint to /devui
app.MapDevUI();
}
app.Run();
```
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./agentframework.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Framework Dev UI</title>
<script type="module" crossorigin src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -153,13 +153,27 @@ internal sealed class WorkflowThread : AgentThread
case AgentRunUpdateEvent agentUpdate:
yield return agentUpdate.Update;
break;
case RequestInfoEvent requestInfo:
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent);
yield return update;
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
default:
// Emit all other workflow events for observability (DevUI, logging, etc.)
yield return new AgentRunResponseUpdate(ChatRole.Assistant, [])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
ResponseId = this.LastResponseId,
RawRepresentation = evt
};
break;
}
}