Skip to content

Commit 1da9107

Browse files
authored
.NET: Improve AIAgent and Workflow registrations for DevUI integration (#2227)
* wip * resolve non-agent workflows as well! * add tests for devui registrations and resolving * fixes * devui for net8 as well! * simplify TFM * update tfm... * tfm rules.... * wip * roll * verify entities are registered with a devui call * tests * add a proper support for non-keyed workflows * resolve default aiagent registration * sort usings :) * cleanup tests
1 parent 03b74bf commit 1da9107

23 files changed

+761
-236
lines changed

dotnet/agent-framework-dotnet.slnx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
192192
</Folder>
193193
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
194-
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
194+
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
195195
</Folder>
196196
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
197197
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
@@ -374,6 +374,7 @@
374374
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
375375
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
376376
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
377+
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
377378
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
378379
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
379380
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />

dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@
22

33
using System.Diagnostics.CodeAnalysis;
44
using System.Text.Json.Serialization;
5-
using Microsoft.Agents.AI.Hosting;
5+
using Microsoft.Agents.AI;
66

77
namespace AgentWebChat.AgentHost;
88

99
internal static class ActorFrameworkWebApplicationExtensions
1010
{
1111
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
1212
{
13+
var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey);
14+
1315
var routeGroup = endpoints.MapGroup(path);
14-
routeGroup.MapGet("/", async (
15-
AgentCatalog agentCatalog,
16-
CancellationToken cancellationToken) =>
16+
routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
17+
{
18+
var results = new List<AgentDiscoveryCard>();
19+
foreach (var result in registeredAIAgents)
1720
{
18-
var results = new List<AgentDiscoveryCard>();
19-
await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
21+
results.Add(new AgentDiscoveryCard
2022
{
21-
results.Add(new AgentDiscoveryCard
22-
{
23-
Name = result.Name!,
24-
Description = result.Description,
25-
});
26-
}
23+
Name = result.Name!,
24+
Description = result.Description,
25+
});
26+
}
2727

28-
return Results.Ok(results);
29-
})
30-
.WithName("GetAgents");
28+
return Results.Ok(results);
29+
})
30+
.WithName("GetAgents");
3131
}
3232

3333
internal sealed class AgentDiscoveryCard

dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
1112
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
1213
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
1314
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />

dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using AgentWebChat.AgentHost.Custom;
66
using AgentWebChat.AgentHost.Utilities;
77
using Microsoft.Agents.AI;
8+
using Microsoft.Agents.AI.DevUI;
89
using Microsoft.Agents.AI.Hosting;
910
using Microsoft.Agents.AI.Workflows;
1011
using Microsoft.Extensions.AI;
@@ -21,6 +22,13 @@
2122
// Configure the chat model and our agent.
2223
builder.AddKeyedChatClient("chat-model");
2324

25+
// Add DevUI services
26+
builder.AddDevUI();
27+
28+
// Add OpenAI services
29+
builder.AddOpenAIChatCompletions();
30+
builder.AddOpenAIResponses();
31+
2432
var pirateAgentBuilder = builder.AddAIAgent(
2533
"pirate",
2634
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
95103
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
96104
}).AddAsAIAgent();
97105

98-
builder.AddOpenAIChatCompletions();
99-
builder.AddOpenAIResponses();
106+
builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
107+
{
108+
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
109+
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
110+
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
111+
});
112+
113+
builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
114+
{
115+
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
116+
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
117+
return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
118+
});
119+
120+
builder.Services.AddSingleton<AIAgent>(sp =>
121+
{
122+
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
123+
return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
124+
});
125+
126+
builder.Services.AddKeyedSingleton<AIAgent>("my-di-nonmatching-agent", (sp, name) =>
127+
{
128+
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
129+
return new ChatClientAgent(
130+
chatClient,
131+
name: "some-random-name", // demonstrating registration can be different for DI and actual agent
132+
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
133+
});
134+
135+
builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, name) =>
136+
{
137+
if (name is not string nameStr)
138+
{
139+
throw new NotSupportedException("Name should be passed as a key");
140+
}
141+
142+
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
143+
return new ChatClientAgent(
144+
chatClient,
145+
name: nameStr, // demonstrating registration with the same name
146+
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
147+
});
100148

101149
var app = builder.Build();
102150

@@ -118,7 +166,10 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
118166
// Url = "http://localhost:5390/a2a/knights-and-knaves"
119167
});
120168

169+
app.MapDevUI();
170+
121171
app.MapOpenAIResponses();
172+
app.MapOpenAIConversations();
122173

123174
app.MapOpenAIChatCompletions(pirateAgentBuilder);
124175
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);

dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
1010

1111
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
12-
.WithReference(chatModel);
12+
.WithHttpEndpoint(name: "devui")
13+
.WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
14+
.WithReference(chatModel);
1315

1416
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
1517
.WithExternalHttpEndpoints()

dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3-
using System.Runtime.CompilerServices;
43
using System.Text.Json;
5-
64
using Microsoft.Agents.AI.DevUI.Entities;
7-
using Microsoft.Agents.AI.Hosting;
85
using Microsoft.Agents.AI.Workflows;
96
using Microsoft.Extensions.AI;
107

@@ -27,21 +24,26 @@ internal static class EntitiesApiExtensions
2724
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
2825
/// </list>
2926
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
30-
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
27+
/// from the registered <see cref="AIAgent">agents</see> and <see cref="Workflow">workflows</see> in the dependency injection container.
3128
/// </remarks>
3229
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
3330
{
31+
var registeredAIAgents = GetRegisteredEntities<AIAgent>(endpoints.ServiceProvider);
32+
var registeredWorkflows = GetRegisteredEntities<Workflow>(endpoints.ServiceProvider);
33+
3434
var group = endpoints.MapGroup("/v1/entities")
3535
.WithTags("Entities");
3636

3737
// List all entities
38-
group.MapGet("", ListEntitiesAsync)
38+
group.MapGet("", (CancellationToken cancellationToken)
39+
=> ListEntitiesAsync(registeredAIAgents, registeredWorkflows, cancellationToken))
3940
.WithName("ListEntities")
4041
.WithSummary("List all registered entities (agents and workflows)")
4142
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
4243

4344
// Get detailed entity information
44-
group.MapGet("{entityId}/info", GetEntityInfoAsync)
45+
group.MapGet("{entityId}/info", (string entityId, string? type, CancellationToken cancellationToken)
46+
=> GetEntityInfoAsync(entityId, type, registeredAIAgents, registeredWorkflows, cancellationToken))
4547
.WithName("GetEntityInfo")
4648
.WithSummary("Get detailed information about a specific entity")
4749
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
@@ -51,22 +53,22 @@ public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder
5153
}
5254

5355
private static async Task<IResult> ListEntitiesAsync(
54-
AgentCatalog? agentCatalog,
55-
WorkflowCatalog? workflowCatalog,
56+
IEnumerable<AIAgent> agents,
57+
IEnumerable<Workflow> workflows,
5658
CancellationToken cancellationToken)
5759
{
5860
try
5961
{
6062
var entities = new Dictionary<string, EntityInfo>();
6163

6264
// Discover agents
63-
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
65+
foreach (var agentInfo in DiscoverAgents(agents, entityIdFilter: null))
6466
{
6567
entities[agentInfo.Id] = agentInfo;
6668
}
6769

6870
// Discover workflows
69-
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
71+
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityIdFilter: null))
7072
{
7173
entities[workflowInfo.Id] = workflowInfo;
7274
}
@@ -85,23 +87,23 @@ private static async Task<IResult> ListEntitiesAsync(
8587
private static async Task<IResult> GetEntityInfoAsync(
8688
string entityId,
8789
string? type,
88-
AgentCatalog? agentCatalog,
89-
WorkflowCatalog? workflowCatalog,
90+
IEnumerable<AIAgent> agents,
91+
IEnumerable<Workflow> workflows,
9092
CancellationToken cancellationToken)
9193
{
9294
try
9395
{
9496
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
9597
{
96-
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
98+
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityId))
9799
{
98100
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
99101
}
100102
}
101103

102104
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
103105
{
104-
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
106+
foreach (var agentInfo in DiscoverAgents(agents, entityId))
105107
{
106108
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
107109
}
@@ -118,17 +120,9 @@ private static async Task<IResult> GetEntityInfoAsync(
118120
}
119121
}
120122

121-
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
122-
AgentCatalog? agentCatalog,
123-
string? entityIdFilter,
124-
[EnumeratorCancellation] CancellationToken cancellationToken)
123+
private static IEnumerable<EntityInfo> DiscoverAgents(IEnumerable<AIAgent> agents, string? entityIdFilter)
125124
{
126-
if (agentCatalog is null)
127-
{
128-
yield break;
129-
}
130-
131-
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
125+
foreach (var agent in agents)
132126
{
133127
// If filtering by entity ID, skip non-matching agents
134128
if (entityIdFilter is not null &&
@@ -148,17 +142,9 @@ private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
148142
}
149143
}
150144

151-
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
152-
WorkflowCatalog? workflowCatalog,
153-
string? entityIdFilter,
154-
[EnumeratorCancellation] CancellationToken cancellationToken)
145+
private static IEnumerable<EntityInfo> DiscoverWorkflows(IEnumerable<Workflow> workflows, string? entityIdFilter)
155146
{
156-
if (workflowCatalog is null)
157-
{
158-
yield break;
159-
}
160-
161-
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
147+
foreach (var workflow in workflows)
162148
{
163149
var workflowId = workflow.Name ?? workflow.StartExecutorId;
164150

@@ -304,4 +290,14 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow)
304290
StartExecutorId = workflow.StartExecutorId
305291
};
306292
}
293+
294+
private static IEnumerable<T> GetRegisteredEntities<T>(IServiceProvider serviceProvider)
295+
{
296+
var keyedEntities = serviceProvider.GetKeyedServices<T>(KeyedService.AnyKey);
297+
var defaultEntities = serviceProvider.GetServices<T>() ?? [];
298+
299+
return keyedEntities
300+
.Concat(defaultEntities)
301+
.Where(entity => entity is not null);
302+
}
307303
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
namespace Microsoft.Extensions.Hosting;
4+
5+
/// <summary>
6+
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure DevUI.
7+
/// </summary>
8+
public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
9+
{
10+
/// <summary>
11+
/// Adds DevUI services to the host application builder.
12+
/// </summary>
13+
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
14+
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
15+
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
16+
{
17+
ArgumentNullException.ThrowIfNull(builder);
18+
19+
builder.Services.AddDevUI();
20+
21+
return builder;
22+
}
23+
}

dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
4-
<TargetFrameworks>net9.0</TargetFrameworks>
4+
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
5+
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
56
<ImplicitUsings>enable</ImplicitUsings>
67
<Nullable>enable</Nullable>
78
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
@@ -12,6 +13,10 @@
1213
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
1314
</PropertyGroup>
1415

16+
<PropertyGroup>
17+
<InjectSharedThrow>true</InjectSharedThrow>
18+
</PropertyGroup>
19+
1520
<!-- Import nuget packaging properties -->
1621
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
1722

@@ -33,4 +38,7 @@
3338
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
3439
</PropertyGroup>
3540

41+
<ItemGroup>
42+
<InternalsVisibleTo Include="Microsoft.Agents.AI.DevUI.UnitTests"/>
43+
</ItemGroup>
3644
</Project>

dotnet/src/Microsoft.Agents.AI.DevUI/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ var builder = WebApplication.CreateBuilder(args);
2424
// Register your agents
2525
builder.AddAIAgent("assistant", "You are a helpful assistant.");
2626

27+
// Register DevUI services
28+
if (builder.Environment.IsDevelopment())
29+
{
30+
builder.AddDevUI();
31+
}
32+
2733
// Register services for OpenAI responses and conversations (also required for DevUI)
28-
builder.Services.AddOpenAIResponses();
29-
builder.Services.AddOpenAIConversations();
34+
builder.AddOpenAIResponses();
35+
builder.AddOpenAIConversations();
3036

3137
var app = builder.Build();
3238

0 commit comments

Comments
 (0)