.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
This commit is contained in:
Korolev Dmitry
2025-11-18 16:38:00 +01:00
committed by GitHub
Unverified
parent 03b74bfad4
commit 1da9107f4a
23 changed files with 764 additions and 239 deletions
+2 -1
View File
@@ -191,7 +191,7 @@
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
@@ -374,6 +374,7 @@
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
@@ -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 routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet("/", async (
AgentCatalog agentCatalog,
CancellationToken cancellationToken) =>
{
var results = new List<AgentDiscoveryCard>();
await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(new AgentDiscoveryCard
{
Name = result.Name!,
Description = result.Description,
});
}
var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey);
return Results.Ok(results);
})
.WithName("GetAgents");
var routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
{
var results = new List<AgentDiscoveryCard>();
foreach (var result in registeredAIAgents)
{
results.Add(new AgentDiscoveryCard
{
Name = result.Name!,
Description = result.Description,
});
}
return Results.Ok(results);
})
.WithName("GetAgents");
}
internal sealed class AgentDiscoveryCard
@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
@@ -5,6 +5,7 @@ using AgentWebChat.AgentHost;
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 @@ builder.Services.AddProblemDetails();
// 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 @@ var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
}).AddAsAIAgent();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
{
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
});
builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
{
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
});
builder.Services.AddSingleton<AIAgent>(sp =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
});
builder.Services.AddKeyedSingleton<AIAgent>("my-di-nonmatching-agent", (sp, name) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("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<AIAgent>("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<IChatClient>("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 @@ app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard
// Url = "http://localhost:5390/a2a/knights-and-knaves"
});
app.MapDevUI();
app.MapOpenAIResponses();
app.MapOpenAIConversations();
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
@@ -9,7 +9,9 @@ var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIRe
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
.WithReference(chatModel);
.WithHttpEndpoint(name: "devui")
.WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
.WithReference(chatModel);
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
.WithExternalHttpEndpoints()
@@ -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
/// <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.
/// from the registered <see cref="AIAgent">agents</see> and <see cref="Workflow">workflows</see> in the dependency injection container.
/// </remarks>
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
{
var registeredAIAgents = GetRegisteredEntities<AIAgent>(endpoints.ServiceProvider);
var registeredWorkflows = GetRegisteredEntities<Workflow>(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<DiscoveryResponse>(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<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
@@ -51,8 +53,8 @@ internal static class EntitiesApiExtensions
}
private static async Task<IResult> ListEntitiesAsync(
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
IEnumerable<AIAgent> agents,
IEnumerable<Workflow> workflows,
CancellationToken cancellationToken)
{
try
@@ -60,13 +62,13 @@ internal static class EntitiesApiExtensions
var entities = new Dictionary<string, EntityInfo>();
// 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 @@ internal static class EntitiesApiExtensions
private static async Task<IResult> GetEntityInfoAsync(
string entityId,
string? type,
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
IEnumerable<AIAgent> agents,
IEnumerable<Workflow> 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 @@ internal static class EntitiesApiExtensions
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 @@ internal static class EntitiesApiExtensions
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
AgentCatalog? agentCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
private static IEnumerable<EntityInfo> DiscoverAgents(IEnumerable<AIAgent> 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 @@ internal static class EntitiesApiExtensions
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
WorkflowCatalog? workflowCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
private static IEnumerable<EntityInfo> DiscoverWorkflows(IEnumerable<Workflow> 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 @@ internal static class EntitiesApiExtensions
StartExecutorId = workflow.StartExecutorId
};
}
private static IEnumerable<T> GetRegisteredEntities<T>(IServiceProvider serviceProvider)
{
var keyedEntities = serviceProvider.GetKeyedServices<T>(KeyedService.AnyKey);
var defaultEntities = serviceProvider.GetServices<T>() ?? [];
return keyedEntities
.Concat(defaultEntities)
.Where(entity => entity is not null);
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure DevUI.
/// </summary>
public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
{
/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddDevUI();
return builder;
}
}
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
@@ -12,6 +13,10 @@
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<!-- Import nuget packaging properties -->
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -33,4 +38,7 @@
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.DevUI.UnitTests"/>
</ItemGroup>
</Project>
@@ -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();
@@ -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;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure DevUI.
/// </summary>
public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
{
/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
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<AIAgent>(KeyedService.AnyKey, (sp, key) =>
{
var keyAsStr = key as string;
Throw.IfNullOrEmpty(keyAsStr);
var workflow = sp.GetKeyedService<Workflow>(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<Workflow>();
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<AIAgent>();
if (agent is not null && agent.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
{
return agent;
}
return null!;
});
#pragma warning restore IDE0001 // Simplify Names
return services;
}
}
@@ -63,7 +63,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
return ValueTask.FromResult<ResponseError?>(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.
"""
});
}
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides a catalog of registered AI agents within the hosting environment.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class AgentCatalog
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentCatalog"/> class.
/// </summary>
protected AgentCatalog()
{
}
/// <summary>
/// Asynchronously retrieves all registered AI agents from the catalog.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// An asynchronous enumerable of <see cref="AIAgent"/> instances representing all registered agents.
/// The enumeration will only include agents that are successfully resolved from the service provider.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
public abstract IAsyncEnumerable<AIAgent> GetAgentsAsync(CancellationToken cancellationToken = default);
}
@@ -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 class AgentHostingServiceCollectionExtensions
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<AgentCatalog, LocalAgentCatalog>();
}
private static IList<AITool> GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName)
{
var registry = serviceProvider.GetService<LocalAgentToolRegistry>();
@@ -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 class HostApplicationBuilderWorkflowExtensions
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<WorkflowCatalog, LocalWorkflowCatalog>();
}
}
@@ -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<string> _registeredAgents;
private readonly IServiceProvider _serviceProvider;
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
{
this._registeredAgents = [.. agentHostBuilder.AgentNames];
this._serviceProvider = serviceProvider;
}
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
foreach (var name in this._registeredAgents)
{
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
if (agent is not null)
{
yield return agent;
}
}
}
}
@@ -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<string> AgentNames { get; } = [];
}
@@ -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<string> _registeredWorkflows;
private readonly IServiceProvider _serviceProvider;
public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider)
{
this._registeredWorkflows = [.. workflowRegistry.WorkflowNames];
this._serviceProvider = serviceProvider;
}
public override async IAsyncEnumerable<Workflow> GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
foreach (var name in this._registeredWorkflows)
{
var workflow = this._serviceProvider.GetKeyedService<Workflow>(name);
if (workflow is not null)
{
yield return workflow;
}
}
}
}
@@ -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<string> WorkflowNames { get; } = [];
}
@@ -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;
/// <summary>
/// Unit tests for DevUI service collection extensions.
/// Tests verify that workflows and agents can be resolved even when registered non-conventionally.
/// </summary>
public class DevUIExtensionsTests
{
/// <summary>
/// Verifies that AddDevUI throws ArgumentNullException when services collection is null.
/// </summary>
[Fact]
public void AddDevUI_NullServices_ThrowsArgumentNullException()
{
IServiceCollection services = null!;
Assert.Throws<ArgumentNullException>(() => services.AddDevUI());
}
/// <summary>
/// Verifies that GetRequiredKeyedService throws for non-existent keys.
/// </summary>
[Fact]
public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
Assert.Throws<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>("non-existent"));
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent()
{
// Arrange
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
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<AIAgent>("workflow");
// Assert
Assert.NotNull(resolvedWorkflowAsAgent);
Assert.Null(resolvedWorkflowAsAgent.Name);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
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<AIAgent>("workflow1");
Assert.NotNull(resolvedWorkflow1AsAgent);
Assert.Null(resolvedWorkflow1AsAgent.Name);
var resolvedWorkflow2AsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow2");
Assert.NotNull(resolvedWorkflow2AsAgent);
Assert.Null(resolvedWorkflow2AsAgent.Name);
Assert.False(resolvedWorkflow1AsAgent == resolvedWorkflow2AsAgent);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_NonKeyedWorkflow_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
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<AIAgent>("workflow");
Assert.NotNull(resolvedWorkflowAsAgent);
Assert.Null(resolvedWorkflowAsAgent.Name);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_NonKeyedWorkflow_PlusKeyedWorkflow_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
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<AIAgent>("standardname");
Assert.NotNull(standardAgent);
Assert.Equal("standardname", standardAgent.Name);
var keyedAgent = serviceProvider.GetKeyedService<AIAgent>("keyed");
Assert.NotNull(keyedAgent);
Assert.Equal("keyedname", keyedAgent.Name);
var nonExisting = serviceProvider.GetKeyedService<AIAgent>("random-non-existing!!!");
Assert.Null(nonExisting);
}
/// <summary>
/// Verifies that an agent registered with a different key than its name can be resolved by key.
/// </summary>
[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<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", AgentName);
services.AddKeyedSingleton<AIAgent>(RegistrationKey, agent);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// Act
var resolvedAgent = serviceProvider.GetKeyedService<AIAgent>(RegistrationKey);
// Assert
Assert.NotNull(resolvedAgent);
// The resolved agent should have the agent's name, not the registration key
Assert.Equal(AgentName, resolvedAgent.Name);
}
/// <summary>
/// Verifies that an agent registered with a different key than its name can be resolved by key.
/// </summary>
[Fact]
public void AddDevUI_Keyed_AndStandard_BothCanBeResolved()
{
// Arrange
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var defaultAgent = new ChatClientAgent(mockChatClient.Object, "default", "default");
var keyedAgent = new ChatClientAgent(mockChatClient.Object, "keyed", "keyed");
services.AddSingleton<AIAgent>(defaultAgent);
services.AddKeyedSingleton<AIAgent>("keyed-registration", keyedAgent);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
var resolvedKeyedAgent = serviceProvider.GetKeyedService<AIAgent>("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<AIAgent>("default");
Assert.NotNull(resolvedDefaultAgent);
Assert.Equal("default", resolvedDefaultAgent.Name);
}
/// <summary>
/// Verifies that the DevUI fallback handler error message includes helpful information.
/// </summary>
[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<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>(InvalidKey));
}
}
@@ -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<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
}
[Fact]
public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var resolvedAgent = app.Services.GetKeyedService<AIAgent>("registration-key");
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
Assert.NotNull(discoveryResponse);
Assert.Single(discoveryResponse.Entities);
Assert.Equal("agent-name", discoveryResponse.Entities[0].Name);
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMultipleAIAgents_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-one");
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-two");
var agent3 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-three");
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-three" && e.Type == "agent");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesAIAgents_WithKeyedAndDefaultRegistrationAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agentKeyed1 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-one");
var agentKeyed2 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-two");
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-agent");
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-agent" && e.Type == "agent");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMultipleWorkflows_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var workflow1 = new WorkflowBuilder("executor-1")
.WithName("workflow-one")
.WithDescription("First workflow")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflow2 = new WorkflowBuilder("executor-2")
.WithName("workflow-two")
.WithDescription("Second workflow")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflow3 = new WorkflowBuilder("executor-3")
.WithName("workflow-three")
.WithDescription("Third workflow")
.BindExecutor(new NoOpExecutor("executor-3"))
.Build();
builder.Services.AddKeyedSingleton("key-1", workflow1);
builder.Services.AddKeyedSingleton("key-2", workflow2);
builder.Services.AddKeyedSingleton("key-3", workflow3);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var workflowKeyed1 = new WorkflowBuilder("executor-1")
.WithName("keyed-workflow-one")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflowKeyed2 = new WorkflowBuilder("executor-2")
.WithName("keyed-workflow-two")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflowDefault = new WorkflowBuilder("executor-default")
.WithName("default-workflow")
.BindExecutor(new NoOpExecutor("executor-default"))
.Build();
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
// Create AIAgents
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-one");
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-two");
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-mixed-agent");
// Create Workflows
var workflow1 = new WorkflowBuilder("executor-1")
.WithName("mixed-workflow-one")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflow2 = new WorkflowBuilder("executor-2")
.WithName("mixed-workflow-two")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflowDefault = new WorkflowBuilder("executor-default")
.WithName("default-mixed-workflow")
.BindExecutor(new NoOpExecutor("executor-default"))
.Build();
// Register all
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-2", agent2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(6, discoveryResponse.Entities.Count);
// Verify agents
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-agent" && e.Type == "agent");
// Verify workflows
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-workflow" && e.Type == "workflow");
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"profiles": {
"Microsoft.Agents.AI.DevUI.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:63009;http://localhost:63010"
}
}
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Microsoft.Agents.AI.Hosting.A2A.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:52186;http://localhost:52187"
}
}
}