diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index ca1a7fdc8c..0cedcfbdde 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -191,7 +191,7 @@
-
+
@@ -374,6 +374,7 @@
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs
index 5e997c4f58..09e19a82f5 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs
@@ -2,7 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
-using Microsoft.Agents.AI.Hosting;
+using Microsoft.Agents.AI;
namespace AgentWebChat.AgentHost;
@@ -10,24 +10,24 @@ internal static class ActorFrameworkWebApplicationExtensions
{
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
{
+ var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey);
+
var routeGroup = endpoints.MapGroup(path);
- routeGroup.MapGet("/", async (
- AgentCatalog agentCatalog,
- CancellationToken cancellationToken) =>
+ routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
+ {
+ var results = new List();
+ foreach (var result in registeredAIAgents)
{
- var results = new List();
- await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
+ results.Add(new AgentDiscoveryCard
{
- results.Add(new AgentDiscoveryCard
- {
- Name = result.Name!,
- Description = result.Description,
- });
- }
+ Name = result.Name!,
+ Description = result.Description,
+ });
+ }
- return Results.Ok(results);
- })
- .WithName("GetAgents");
+ return Results.Ok(results);
+ })
+ .WithName("GetAgents");
}
internal sealed class AgentDiscoveryCard
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
index 53fd4757ee..b4141ba166 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj
@@ -8,6 +8,7 @@
+
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index 46af2a5b19..7447c54aa1 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -5,6 +5,7 @@
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -21,6 +22,13 @@
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
+// Add DevUI services
+builder.AddDevUI();
+
+// Add OpenAI services
+builder.AddOpenAIChatCompletions();
+builder.AddOpenAIResponses();
+
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -95,8 +103,48 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
}).AddAsAIAgent();
-builder.AddOpenAIChatCompletions();
-builder.AddOpenAIResponses();
+builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
+{
+ List usedAgents = [pirateAgentBuilder, chemistryAgent];
+ var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name));
+ return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
+});
+
+builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
+{
+ List usedAgents = [pirateAgentBuilder, chemistryAgent];
+ var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name));
+ return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
+});
+
+builder.Services.AddSingleton(sp =>
+{
+ var chatClient = sp.GetRequiredKeyedService("chat-model");
+ return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
+});
+
+builder.Services.AddKeyedSingleton("my-di-nonmatching-agent", (sp, name) =>
+{
+ var chatClient = sp.GetRequiredKeyedService("chat-model");
+ return new ChatClientAgent(
+ chatClient,
+ name: "some-random-name", // demonstrating registration can be different for DI and actual agent
+ instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
+});
+
+builder.Services.AddKeyedSingleton("my-di-matchingname-agent", (sp, name) =>
+{
+ if (name is not string nameStr)
+ {
+ throw new NotSupportedException("Name should be passed as a key");
+ }
+
+ var chatClient = sp.GetRequiredKeyedService("chat-model");
+ return new ChatClientAgent(
+ chatClient,
+ name: nameStr, // demonstrating registration with the same name
+ instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
+});
var app = builder.Build();
@@ -118,7 +166,10 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
// Url = "http://localhost:5390/a2a/knights-and-knaves"
});
+app.MapDevUI();
+
app.MapOpenAIResponses();
+app.MapOpenAIConversations();
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
index a28b3e1902..328e3f5e83 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs
@@ -9,7 +9,9 @@
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
var agentHost = builder.AddProject("agenthost")
- .WithReference(chatModel);
+ .WithHttpEndpoint(name: "devui")
+ .WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
+ .WithReference(chatModel);
builder.AddProject("webfrontend")
.WithExternalHttpEndpoints()
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs
index 29b7dc588a..3271b40853 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
-using System.Runtime.CompilerServices;
using System.Text.Json;
-
using Microsoft.Agents.AI.DevUI.Entities;
-using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -27,21 +24,26 @@ internal static class EntitiesApiExtensions
/// - GET /v1/entities/{entityId}/info - Get detailed information about a specific entity
///
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
- /// from the registered and services.
+ /// from the registered agents and workflows in the dependency injection container.
///
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
{
+ var registeredAIAgents = GetRegisteredEntities(endpoints.ServiceProvider);
+ var registeredWorkflows = GetRegisteredEntities(endpoints.ServiceProvider);
+
var group = endpoints.MapGroup("/v1/entities")
.WithTags("Entities");
// List all entities
- group.MapGet("", ListEntitiesAsync)
+ group.MapGet("", (CancellationToken cancellationToken)
+ => ListEntitiesAsync(registeredAIAgents, registeredWorkflows, cancellationToken))
.WithName("ListEntities")
.WithSummary("List all registered entities (agents and workflows)")
.Produces(StatusCodes.Status200OK, contentType: "application/json");
// Get detailed entity information
- group.MapGet("{entityId}/info", GetEntityInfoAsync)
+ group.MapGet("{entityId}/info", (string entityId, string? type, CancellationToken cancellationToken)
+ => GetEntityInfoAsync(entityId, type, registeredAIAgents, registeredWorkflows, cancellationToken))
.WithName("GetEntityInfo")
.WithSummary("Get detailed information about a specific entity")
.Produces(StatusCodes.Status200OK, contentType: "application/json")
@@ -51,8 +53,8 @@ public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder
}
private static async Task ListEntitiesAsync(
- AgentCatalog? agentCatalog,
- WorkflowCatalog? workflowCatalog,
+ IEnumerable agents,
+ IEnumerable workflows,
CancellationToken cancellationToken)
{
try
@@ -60,13 +62,13 @@ private static async Task ListEntitiesAsync(
var entities = new Dictionary();
// Discover agents
- await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
+ foreach (var agentInfo in DiscoverAgents(agents, entityIdFilter: null))
{
entities[agentInfo.Id] = agentInfo;
}
// Discover workflows
- await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
+ foreach (var workflowInfo in DiscoverWorkflows(workflows, entityIdFilter: null))
{
entities[workflowInfo.Id] = workflowInfo;
}
@@ -85,15 +87,15 @@ private static async Task ListEntitiesAsync(
private static async Task GetEntityInfoAsync(
string entityId,
string? type,
- AgentCatalog? agentCatalog,
- WorkflowCatalog? workflowCatalog,
+ IEnumerable agents,
+ IEnumerable workflows,
CancellationToken cancellationToken)
{
try
{
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
{
- await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
+ foreach (var workflowInfo in DiscoverWorkflows(workflows, entityId))
{
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
}
@@ -101,7 +103,7 @@ private static async Task GetEntityInfoAsync(
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
- await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
+ foreach (var agentInfo in DiscoverAgents(agents, entityId))
{
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
@@ -118,17 +120,9 @@ private static async Task GetEntityInfoAsync(
}
}
- private static async IAsyncEnumerable DiscoverAgentsAsync(
- AgentCatalog? agentCatalog,
- string? entityIdFilter,
- [EnumeratorCancellation] CancellationToken cancellationToken)
+ private static IEnumerable DiscoverAgents(IEnumerable agents, string? entityIdFilter)
{
- if (agentCatalog is null)
- {
- yield break;
- }
-
- await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
+ foreach (var agent in agents)
{
// If filtering by entity ID, skip non-matching agents
if (entityIdFilter is not null &&
@@ -148,17 +142,9 @@ private static async IAsyncEnumerable DiscoverAgentsAsync(
}
}
- private static async IAsyncEnumerable DiscoverWorkflowsAsync(
- WorkflowCatalog? workflowCatalog,
- string? entityIdFilter,
- [EnumeratorCancellation] CancellationToken cancellationToken)
+ private static IEnumerable DiscoverWorkflows(IEnumerable workflows, string? entityIdFilter)
{
- if (workflowCatalog is null)
- {
- yield break;
- }
-
- await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
+ foreach (var workflow in workflows)
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
@@ -304,4 +290,14 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow)
StartExecutorId = workflow.StartExecutorId
};
}
+
+ private static IEnumerable GetRegisteredEntities(IServiceProvider serviceProvider)
+ {
+ var keyedEntities = serviceProvider.GetKeyedServices(KeyedService.AnyKey);
+ var defaultEntities = serviceProvider.GetServices() ?? [];
+
+ return keyedEntities
+ .Concat(defaultEntities)
+ .Where(entity => entity is not null);
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs
new file mode 100644
index 0000000000..30fa9ad29e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Extensions.Hosting;
+
+///
+/// Extension methods for to configure DevUI.
+///
+public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
+{
+ ///
+ /// Adds DevUI services to the host application builder.
+ ///
+ /// The to configure.
+ /// The for method chaining.
+ public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddDevUI();
+
+ return builder;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj
index 37aa6c37f8..6c9c5bd9e3 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj
@@ -1,7 +1,8 @@
- net9.0
+ $(ProjectsCoreTargetFrameworks)
+ $(ProjectsDebugCoreTargetFrameworks)
enable
enable
Microsoft.Agents.AI.DevUI
@@ -12,6 +13,10 @@
$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812
+
+ true
+
+
@@ -33,4 +38,7 @@
Provides Microsoft Agent Framework support for developer UI.
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
index b55869748d..104c43729b 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
@@ -24,9 +24,15 @@ var builder = WebApplication.CreateBuilder(args);
// Register your agents
builder.AddAIAgent("assistant", "You are a helpful assistant.");
+// Register DevUI services
+if (builder.Environment.IsDevelopment())
+{
+ builder.AddDevUI();
+}
+
// Register services for OpenAI responses and conversations (also required for DevUI)
-builder.Services.AddOpenAIResponses();
-builder.Services.AddOpenAIConversations();
+builder.AddOpenAIResponses();
+builder.AddOpenAIConversations();
var app = builder.Build();
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs
new file mode 100644
index 0000000000..6971e3d2e0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods for to configure DevUI.
+///
+public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
+{
+ ///
+ /// Adds services required for DevUI integration.
+ ///
+ /// The to configure.
+ /// The for method chaining.
+ public static IServiceCollection AddDevUI(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ // a factory that tries to construct an AIAgent from Workflow,
+ // even if workflow was not explicitly registered as an AIAgent.
+
+#pragma warning disable IDE0001 // Simplify Names
+ services.AddKeyedSingleton(KeyedService.AnyKey, (sp, key) =>
+ {
+ var keyAsStr = key as string;
+ Throw.IfNullOrEmpty(keyAsStr);
+
+ var workflow = sp.GetKeyedService(keyAsStr);
+ if (workflow is not null)
+ {
+ return workflow.AsAgent(name: workflow.Name);
+ }
+
+ // another thing we can do is resolve a non-keyed workflow.
+ // however, we can't rely on anything than key to be equal to the workflow.Name.
+ // so we try: if we fail, we return null.
+ workflow = sp.GetService();
+ if (workflow is not null && workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
+ {
+ return workflow.AsAgent(name: workflow.Name);
+ }
+
+ // and it's possible to lookup at the default-registered AIAgent
+ // with the condition of same name as the key.
+ var agent = sp.GetService();
+ if (agent is not null && agent.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
+ {
+ return agent;
+ }
+
+ return null!;
+ });
+#pragma warning restore IDE0001 // Simplify Names
+
+ return services;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
index f90e47b070..01e7c60137 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs
@@ -63,7 +63,11 @@ public HostedAgentResponseExecutor(
return ValueTask.FromResult(new ResponseError
{
Code = "agent_not_found",
- Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
+ Message = $"""
+ Agent '{agentName}' not found.
+ Ensure the agent is registered with '{agentName}' name in the dependency injection container.
+ We recommend using 'builder.AddAIAgent()' for simplicity.
+ """
});
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs
deleted file mode 100644
index 0d2ef69640..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Collections.Generic;
-using System.Threading;
-
-namespace Microsoft.Agents.AI.Hosting;
-
-///
-/// Provides a catalog of registered AI agents within the hosting environment.
-///
-///
-/// The agent catalog allows enumeration of all registered agents in the dependency injection container.
-/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically.
-///
-public abstract class AgentCatalog
-{
- ///
- /// Initializes a new instance of the class.
- ///
- protected AgentCatalog()
- {
- }
-
- ///
- /// Asynchronously retrieves all registered AI agents from the catalog.
- ///
- /// The to monitor for cancellation requests. The default is .
- ///
- /// An asynchronous enumerable of instances representing all registered agents.
- /// The enumeration will only include agents that are successfully resolved from the service provider.
- ///
- ///
- /// This method enumerates through all registered agent names and attempts to resolve each agent
- /// from the dependency injection container. Only successfully resolved agents are yielded.
- /// The enumeration is lazy and agents are resolved on-demand during iteration.
- ///
- public abstract IAsyncEnumerable GetAgentsAsync(CancellationToken cancellationToken = default);
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs
index d958fc3578..e12d017343 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using Microsoft.Agents.AI.Hosting.Local;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -126,31 +125,9 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s
return agent;
});
- // Register the agent by name for discovery.
- var agentHostBuilder = GetAgentRegistry(services);
- agentHostBuilder.AgentNames.Add(name);
-
return new HostedAgentBuilder(name, services);
}
- private static LocalAgentRegistry GetAgentRegistry(IServiceCollection services)
- {
- var descriptor = services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
- if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
- {
- instance = new LocalAgentRegistry();
- ConfigureHostBuilder(services, instance);
- }
-
- return instance;
- }
-
- private static void ConfigureHostBuilder(IServiceCollection services, LocalAgentRegistry agentHostBuilderContext)
- {
- services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
- services.AddSingleton();
- }
-
private static IList GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName)
{
var registry = serviceProvider.GetService();
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs
index 2215a52a69..8075caec59 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using System.Linq;
-using Microsoft.Agents.AI.Hosting.Local;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -47,28 +45,6 @@ public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder bu
return workflow;
});
- // Register the workflow by name for discovery.
- var workflowRegistry = GetWorkflowRegistry(builder);
- workflowRegistry.WorkflowNames.Add(name);
-
return new HostedWorkflowBuilder(name, builder);
}
-
- private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder)
- {
- var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry)));
- if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance)
- {
- instance = new LocalWorkflowRegistry();
- ConfigureHostBuilder(builder, instance);
- }
-
- return instance;
- }
-
- private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext)
- {
- builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
- builder.Services.AddSingleton();
- }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs
deleted file mode 100644
index 0b44ad60cb..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.Agents.AI.Hosting.Local;
-
-// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
-internal sealed class LocalAgentCatalog : AgentCatalog
-{
- public readonly HashSet _registeredAgents;
- private readonly IServiceProvider _serviceProvider;
-
- public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
- {
- this._registeredAgents = [.. agentHostBuilder.AgentNames];
- this._serviceProvider = serviceProvider;
- }
-
- public override async IAsyncEnumerable GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- await Task.CompletedTask.ConfigureAwait(false);
-
- foreach (var name in this._registeredAgents)
- {
- var agent = this._serviceProvider.GetKeyedService(name);
- if (agent is not null)
- {
- yield return agent;
- }
- }
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs
deleted file mode 100644
index df3db8f554..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Collections.Generic;
-
-namespace Microsoft.Agents.AI.Hosting.Local;
-
-internal sealed class LocalAgentRegistry
-{
- public HashSet AgentNames { get; } = [];
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs
deleted file mode 100644
index 572b41830e..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Agents.AI.Workflows;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.Agents.AI.Hosting.Local;
-
-internal sealed class LocalWorkflowCatalog : WorkflowCatalog
-{
- public readonly HashSet _registeredWorkflows;
- private readonly IServiceProvider _serviceProvider;
-
- public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider)
- {
- this._registeredWorkflows = [.. workflowRegistry.WorkflowNames];
- this._serviceProvider = serviceProvider;
- }
-
- public override async IAsyncEnumerable GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- await Task.CompletedTask.ConfigureAwait(false);
-
- foreach (var name in this._registeredWorkflows)
- {
- var workflow = this._serviceProvider.GetKeyedService(name);
- if (workflow is not null)
- {
- yield return workflow;
- }
- }
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs
deleted file mode 100644
index 803c24660f..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System.Collections.Generic;
-
-namespace Microsoft.Agents.AI.Hosting.Local;
-
-internal sealed class LocalWorkflowRegistry
-{
- public HashSet WorkflowNames { get; } = [];
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs
new file mode 100644
index 0000000000..d002068626
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs
@@ -0,0 +1,222 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace Microsoft.Agents.AI.DevUI.UnitTests;
+
+///
+/// Unit tests for DevUI service collection extensions.
+/// Tests verify that workflows and agents can be resolved even when registered non-conventionally.
+///
+public class DevUIExtensionsTests
+{
+ ///
+ /// Verifies that AddDevUI throws ArgumentNullException when services collection is null.
+ ///
+ [Fact]
+ public void AddDevUI_NullServices_ThrowsArgumentNullException()
+ {
+ IServiceCollection services = null!;
+ Assert.Throws(() => services.AddDevUI());
+ }
+
+ ///
+ /// Verifies that GetRequiredKeyedService throws for non-existent keys.
+ ///
+ [Fact]
+ public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddDevUI();
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Act & Assert
+ Assert.Throws(() => serviceProvider.GetRequiredKeyedService("non-existent"));
+ }
+
+ ///
+ /// Verifies that an agent with null name can be resolved by its workflow.
+ ///
+ [Fact]
+ public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var mockChatClient = new Mock();
+ var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
+ var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
+ var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
+
+ services.AddKeyedSingleton("workflow", workflow);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Act
+ var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService("workflow");
+
+ // Assert
+ Assert.NotNull(resolvedWorkflowAsAgent);
+ Assert.Null(resolvedWorkflowAsAgent.Name);
+ }
+
+ ///
+ /// Verifies that an agent with null name can be resolved by its workflow.
+ ///
+ [Fact]
+ public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent()
+ {
+ var services = new ServiceCollection();
+ var mockChatClient = new Mock();
+ var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
+ var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
+ var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
+ var workflow2 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
+
+ services.AddKeyedSingleton("workflow1", workflow1);
+ services.AddKeyedSingleton("workflow2", workflow2);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ var resolvedWorkflow1AsAgent = serviceProvider.GetKeyedService("workflow1");
+ Assert.NotNull(resolvedWorkflow1AsAgent);
+ Assert.Null(resolvedWorkflow1AsAgent.Name);
+
+ var resolvedWorkflow2AsAgent = serviceProvider.GetKeyedService("workflow2");
+ Assert.NotNull(resolvedWorkflow2AsAgent);
+ Assert.Null(resolvedWorkflow2AsAgent.Name);
+
+ Assert.False(resolvedWorkflow1AsAgent == resolvedWorkflow2AsAgent);
+ }
+
+ ///
+ /// Verifies that an agent with null name can be resolved by its workflow.
+ ///
+ [Fact]
+ public void AddDevUI_NonKeyedWorkflow_CanBeResolved_AsAIAgent()
+ {
+ var services = new ServiceCollection();
+ var mockChatClient = new Mock();
+ var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
+ var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
+ var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
+
+ services.AddKeyedSingleton("workflow", workflow);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService("workflow");
+ Assert.NotNull(resolvedWorkflowAsAgent);
+ Assert.Null(resolvedWorkflowAsAgent.Name);
+ }
+
+ ///
+ /// Verifies that an agent with null name can be resolved by its workflow.
+ ///
+ [Fact]
+ public void AddDevUI_NonKeyedWorkflow_PlusKeyedWorkflow_CanBeResolved_AsAIAgent()
+ {
+ var services = new ServiceCollection();
+ var mockChatClient = new Mock();
+ var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
+ var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
+ var workflow = AgentWorkflowBuilder.BuildSequential("standardname", agent1, agent2);
+ var keyedWorkflow = AgentWorkflowBuilder.BuildSequential("keyedname", agent1, agent2);
+
+ services.AddSingleton(workflow);
+ services.AddKeyedSingleton("keyed", keyedWorkflow);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ // resolve a workflow with the same name as workflow's name (which is registered without a key)
+ var standardAgent = serviceProvider.GetKeyedService("standardname");
+ Assert.NotNull(standardAgent);
+ Assert.Equal("standardname", standardAgent.Name);
+
+ var keyedAgent = serviceProvider.GetKeyedService("keyed");
+ Assert.NotNull(keyedAgent);
+ Assert.Equal("keyedname", keyedAgent.Name);
+
+ var nonExisting = serviceProvider.GetKeyedService("random-non-existing!!!");
+ Assert.Null(nonExisting);
+ }
+
+ ///
+ /// Verifies that an agent registered with a different key than its name can be resolved by key.
+ ///
+ [Fact]
+ public void AddDevUI_AgentRegisteredWithDifferentKey_CanBeResolvedByKey()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ const string AgentName = "actual-agent-name";
+ const string RegistrationKey = "different-key";
+ var mockChatClient = new Mock();
+ var agent = new ChatClientAgent(mockChatClient.Object, "Test", AgentName);
+
+ services.AddKeyedSingleton(RegistrationKey, agent);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ // Act
+ var resolvedAgent = serviceProvider.GetKeyedService(RegistrationKey);
+
+ // Assert
+ Assert.NotNull(resolvedAgent);
+ // The resolved agent should have the agent's name, not the registration key
+ Assert.Equal(AgentName, resolvedAgent.Name);
+ }
+
+ ///
+ /// Verifies that an agent registered with a different key than its name can be resolved by key.
+ ///
+ [Fact]
+ public void AddDevUI_Keyed_AndStandard_BothCanBeResolved()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var mockChatClient = new Mock();
+ var defaultAgent = new ChatClientAgent(mockChatClient.Object, "default", "default");
+ var keyedAgent = new ChatClientAgent(mockChatClient.Object, "keyed", "keyed");
+
+ services.AddSingleton(defaultAgent);
+ services.AddKeyedSingleton("keyed-registration", keyedAgent);
+ services.AddDevUI();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ var resolvedKeyedAgent = serviceProvider.GetKeyedService("keyed-registration");
+ Assert.NotNull(resolvedKeyedAgent);
+ Assert.Equal("keyed", resolvedKeyedAgent.Name);
+
+ // resolving default agent based on its name, not on the registration-key
+ var resolvedDefaultAgent = serviceProvider.GetKeyedService("default");
+ Assert.NotNull(resolvedDefaultAgent);
+ Assert.Equal("default", resolvedDefaultAgent.Name);
+ }
+
+ ///
+ /// Verifies that the DevUI fallback handler error message includes helpful information.
+ ///
+ [Fact]
+ public void AddDevUI_InvalidResolution_ErrorMessageIsInformative()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ services.AddDevUI();
+ var serviceProvider = services.BuildServiceProvider();
+ const string InvalidKey = "invalid-key-name";
+
+ // Act & Assert
+ var exception = Assert.Throws(() => serviceProvider.GetRequiredKeyedService(InvalidKey));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs
new file mode 100644
index 0000000000..b8512a856e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs
@@ -0,0 +1,285 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net.Http.Json;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.DevUI.Entities;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace Microsoft.Agents.AI.DevUI.UnitTests;
+
+public class DevUIIntegrationTests
+{
+ private sealed class NoOpExecutor(string id) : Executor(id)
+ {
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
+ routeBuilder.AddHandler