diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index d7fbe3eda0..58fe403c8d 100644
--- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -66,6 +66,25 @@ builder.AddAIAgent("knights-and-knaves", (sp, key) =>
#pragma warning restore VSTHRD002
});
+// Workflow consisting of multiple specialized agents
+var chemistryAgent = builder.AddAIAgent("chemist",
+ instructions: "You are a chemistry expert. Answer thinking from the chemistry perspective",
+ description: "An agent that helps with chemistry.",
+ chatClientServiceKey: "chat-model");
+
+var mathsAgent = builder.AddAIAgent("mathematician",
+ instructions: "You are a mathematics expert. Answer thinking from the maths perspective",
+ description: "An agent that helps with mathematics.",
+ chatClientServiceKey: "chat-model");
+
+var literatureAgent = builder.AddAIAgent("literator",
+ instructions: "You are a literature expert. Answer thinking from the literature perspective",
+ description: "An agent that helps with literature.",
+ chatClientServiceKey: "chat-model");
+
+builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
+builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
+
var app = builder.Build();
app.MapOpenApi();
@@ -92,6 +111,10 @@ app.MapOpenAIResponses("knights-and-knaves");
app.MapOpenAIChatCompletions("pirate");
app.MapOpenAIChatCompletions("knights-and-knaves");
+// workflow-agents
+app.MapOpenAIResponses("science-sequential-workflow");
+app.MapOpenAIResponses("science-concurrent-workflow");
+
// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs
index 6d5f5283fe..d45046a8de 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs
@@ -2,6 +2,7 @@
using System;
using System.Linq;
+using Microsoft.Agents.AI.Hosting.Local;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -22,7 +23,7 @@ public static class HostApplicationBuilderAgentExtensions
/// The instructions for the agent.
/// The configured host application builder.
/// Thrown when , , or is null.
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
+ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
@@ -38,7 +39,7 @@ public static class HostApplicationBuilderAgentExtensions
/// The chat client which the agent will use for inference.
/// The configured host application builder.
/// Thrown when , , or is null.
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
+ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
@@ -55,7 +56,7 @@ public static class HostApplicationBuilderAgentExtensions
/// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.
/// The configured host application builder.
/// Thrown when , , or is null.
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
+ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
@@ -75,7 +76,7 @@ public static class HostApplicationBuilderAgentExtensions
/// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.
/// The configured host application builder.
/// Thrown when , , or is null.
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
+ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey)
{
Throw.IfNull(builder);
Throw.IfNullOrEmpty(name);
@@ -95,7 +96,7 @@ public static class HostApplicationBuilderAgentExtensions
/// The configured host application builder.
/// Thrown when , , or is null.
/// Thrown when the agent factory delegate returns null or an invalid AI agent instance.
- public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate)
+ public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate)
{
Throw.IfNull(builder);
Throw.IfNull(name);
@@ -117,7 +118,8 @@ public static class HostApplicationBuilderAgentExtensions
// Register the agent by name for discovery.
var agentHostBuilder = GetAgentRegistry(builder);
agentHostBuilder.AgentNames.Add(name);
- return builder;
+
+ return new HostedAgentBuilder(name, builder);
}
private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder)
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs
new file mode 100644
index 0000000000..ac78877682
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Agents.AI.Hosting.Local;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides extension methods for configuring AI workflows in a host application builder.
+///
+public static class HostApplicationBuilderWorkflowExtensions
+{
+ ///
+ /// Registers a concurrent workflow that executes multiple agents in parallel.
+ ///
+ /// The to configure.
+ /// The unique name for the workflow.
+ /// A collection of instances representing agents to execute concurrently.
+ /// An that can be used to further configure the workflow.
+ /// Thrown when , , or is null.
+ /// Thrown when or is empty.
+ public static IHostedWorkflowBuilder AddConcurrentWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders)
+ {
+ Throw.IfNullOrEmpty(agentBuilders);
+
+ return builder.AddWorkflow(name, (sp, key) =>
+ {
+ var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name));
+ return AgentWorkflowBuilder.BuildConcurrent(workflowName: name, agents: agents);
+ });
+ }
+
+ ///
+ /// Registers a sequential workflow that executes agents in a specific order.
+ ///
+ /// The to configure.
+ /// The unique name for the workflow.
+ /// A collection of instances representing agents to execute in sequence.
+ /// An that can be used to further configure the workflow.
+ /// Thrown when , , or is null.
+ /// Thrown when or is empty.
+ public static IHostedWorkflowBuilder AddSequentialWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders)
+ {
+ Throw.IfNullOrEmpty(agentBuilders);
+
+ return builder.AddWorkflow(name, (sp, key) =>
+ {
+ var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name));
+ return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: agents);
+ });
+ }
+
+ ///
+ /// Registers a custom workflow using a factory delegate.
+ ///
+ /// The to configure.
+ /// The unique name for the workflow.
+ /// A factory function that creates the instance. The function receives the service provider and workflow name as parameters.
+ /// An that can be used to further configure the workflow.
+ /// Thrown when , , or is null.
+ /// Thrown when is empty.
+ ///
+ /// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
+ ///
+ public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func createWorkflowDelegate)
+ {
+ Throw.IfNull(builder);
+ Throw.IfNull(name);
+ Throw.IfNull(createWorkflowDelegate);
+
+ builder.Services.AddKeyedSingleton(name, (sp, key) =>
+ {
+ Throw.IfNull(key);
+ var keyString = key as string;
+ Throw.IfNullOrEmpty(keyString);
+ var workflow = createWorkflowDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(Workflow)} instance for key '{keyString}'.");
+ if (!string.Equals(workflow.Name, keyString, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"The workflow factory returned workflow with name '{workflow.Name}', but the expected name is '{keyString}'.");
+ }
+
+ 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/HostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs
new file mode 100644
index 0000000000..82e0997c7a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+internal sealed class HostedAgentBuilder : IHostedAgentBuilder
+{
+ public string Name { get; }
+ public IHostApplicationBuilder HostApplicationBuilder { get; }
+
+ public HostedAgentBuilder(string name, IHostApplicationBuilder hostApplicationBuilder)
+ {
+ this.Name = name;
+ this.HostApplicationBuilder = hostApplicationBuilder;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs
new file mode 100644
index 0000000000..e1d87a3836
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+internal sealed class HostedWorkflowBuilder : IHostedWorkflowBuilder
+{
+ public string Name { get; }
+ public IHostApplicationBuilder HostApplicationBuilder { get; }
+
+ public HostedWorkflowBuilder(string name, IHostApplicationBuilder hostApplicationBuilder)
+ {
+ this.Name = name;
+ this.HostApplicationBuilder = hostApplicationBuilder;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs
new file mode 100644
index 0000000000..26104c9a57
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides extension methods for to enable additional workflow configuration scenarios.
+///
+public static class HostedWorkflowBuilderExtensions
+{
+ ///
+ /// Registers the workflow as an AI agent in the dependency injection container.
+ ///
+ /// The instance to extend.
+ /// An that can be used to further configure the agent.
+ public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
+ => builder.AddAsAIAgent(name: null);
+
+ ///
+ /// Registers the workflow as an AI agent in the dependency injection container.
+ ///
+ /// The instance to extend.
+ /// The optional name for the AI agent. If not specified, the workflow name is used.
+ /// An that can be used to further configure the agent.
+ public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name)
+ {
+ var agentName = name ?? builder.Name;
+ return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) =>
+ {
+ var workflow = sp.GetRequiredKeyedService(key);
+#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
+ return workflow.AsAgentAsync(name: key).AsTask().GetAwaiter().GetResult();
+#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
+ });
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs
new file mode 100644
index 0000000000..14070bb671
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Represents a builder for configuring AI agents within a hosting environment.
+///
+public interface IHostedAgentBuilder
+{
+ ///
+ /// Gets the name of the agent being configured.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the application host builder for configuring additional services.
+ ///
+ IHostApplicationBuilder HostApplicationBuilder { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs
new file mode 100644
index 0000000000..405172ffe5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Represents a builder for configuring workflows within a hosting environment.
+///
+public interface IHostedWorkflowBuilder
+{
+ ///
+ /// Gets the name of the workflow being configured.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the application host builder for configuring additional services.
+ ///
+ IHostApplicationBuilder HostApplicationBuilder { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs
similarity index 96%
rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs
rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs
index 8d36c16e7c..0b44ad60cb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs
@@ -7,7 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
-namespace Microsoft.Agents.AI.Hosting;
+namespace Microsoft.Agents.AI.Hosting.Local;
// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
internal sealed class LocalAgentCatalog : AgentCatalog
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs
similarity index 80%
rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs
rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs
index 712634a2ba..df3db8f554 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs
@@ -2,7 +2,7 @@
using System.Collections.Generic;
-namespace Microsoft.Agents.AI.Hosting;
+namespace Microsoft.Agents.AI.Hosting.Local;
internal sealed class LocalAgentRegistry
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs
new file mode 100644
index 0000000000..572b41830e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs
@@ -0,0 +1,37 @@
+// 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
new file mode 100644
index 0000000000..803c24660f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs
@@ -0,0 +1,10 @@
+// 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/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj
index 1cec665588..86f709877d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj
@@ -16,6 +16,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs
new file mode 100644
index 0000000000..47e09afa8e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.Agents.AI.Workflows;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides a catalog of registered workflows within the hosting environment.
+///
+public abstract class WorkflowCatalog
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ protected WorkflowCatalog()
+ {
+ }
+
+ ///
+ /// Asynchronously retrieves all registered workflows from the catalog.
+ ///
+ /// The to monitor for cancellation requests. The default is .
+ public abstract IAsyncEnumerable GetWorkflowsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
index ed91c14e5f..47d0aee346 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs
@@ -26,6 +26,18 @@ public static partial class AgentWorkflowBuilder
/// The sequence of agents to compose into a sequential workflow.
/// The built workflow composed of the supplied , in the order in which they were yielded from the source.
public static Workflow BuildSequential(params IEnumerable agents)
+ => BuildSequentialCore(workflowName: null, agents);
+
+ ///
+ /// Builds a composed of a pipeline of agents where the output of one agent is the input to the next.
+ ///
+ /// The name of workflow.
+ /// The sequence of agents to compose into a sequential workflow.
+ /// The built workflow composed of the supplied , in the order in which they were yielded from the source.
+ public static Workflow BuildSequential(string workflowName, params IEnumerable agents)
+ => BuildSequentialCore(workflowName, agents);
+
+ private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable agents)
{
Throw.IfNull(agents);
@@ -60,9 +72,12 @@ public static partial class AgentWorkflowBuilder
Debug.Assert(builder is not null);
OutputMessagesExecutor end = new();
- return builder.AddEdge(previous, end)
- .WithOutputFrom(end)
- .Build();
+ builder = builder.AddEdge(previous, end).WithOutputFrom(end);
+ if (workflowName is not null)
+ {
+ builder = builder.WithName(workflowName);
+ }
+ return builder.Build();
}
///
@@ -79,6 +94,30 @@ public static partial class AgentWorkflowBuilder
public static Workflow BuildConcurrent(
IEnumerable agents,
Func>, List>? aggregator = null)
+ => BuildConcurrentCore(workflowName: null, agents, aggregator);
+
+ ///
+ /// Builds a composed of agents that operate concurrently on the same input,
+ /// aggregating their outputs into a single collection.
+ ///
+ /// The name of the workflow.
+ /// The set of agents to compose into a concurrent workflow.
+ ///
+ /// The aggregation function that accepts a list of the output messages from each and produces
+ /// a single result list. If , the default behavior is to return a list containing the last message
+ /// from each agent that produced at least one message.
+ ///
+ /// The built workflow composed of the supplied concurrent .
+ public static Workflow BuildConcurrent(
+ string workflowName,
+ IEnumerable agents,
+ Func>, List>? aggregator = null)
+ => BuildConcurrentCore(workflowName, agents, aggregator);
+
+ private static Workflow BuildConcurrentCore(
+ string? workflowName,
+ IEnumerable agents,
+ Func>, List>? aggregator = null)
{
Throw.IfNull(agents);
@@ -105,7 +144,12 @@ public static partial class AgentWorkflowBuilder
ConcurrentEndExecutor end = new(agentExecutors.Length, aggregator);
builder.AddFanInEdge(end, sources: accumulators);
- return builder.WithOutputFrom(end).Build();
+ builder = builder.WithOutputFrom(end);
+ if (workflowName is not null)
+ {
+ builder = builder.WithName(workflowName);
+ }
+ return builder.Build();
}
/// Creates a new using as the starting agent in the workflow.
@@ -536,57 +580,57 @@ public static partial class AgentWorkflowBuilder
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler(async (handoffState, context, cancellationToken) =>
+ {
+ string? requestedHandoff = null;
+ List updates = [];
+ List allMessages = handoffState.Messages;
+
+ List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages);
+
+ await foreach (var update in this._agent.RunStreamingAsync(allMessages,
+ options: this._agentOptions,
+ cancellationToken: cancellationToken)
+ .ConfigureAwait(false))
{
- string? requestedHandoff = null;
- List updates = [];
- List allMessages = handoffState.Messages;
+ await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
- List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages);
-
- await foreach (var update in this._agent.RunStreamingAsync(allMessages,
- options: this._agentOptions,
- cancellationToken: cancellationToken)
- .ConfigureAwait(false))
+ foreach (var c in update.Contents)
{
- await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
-
- foreach (var c in update.Contents)
+ if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name))
{
- if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name))
- {
- requestedHandoff = fcc.Name;
- await AddUpdateAsync(
- new AgentRunResponseUpdate
- {
- AgentId = this._agent.Id,
- AuthorName = this._agent.DisplayName,
- Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
- CreatedAt = DateTimeOffset.UtcNow,
- MessageId = Guid.NewGuid().ToString("N"),
- Role = ChatRole.Tool,
- },
- cancellationToken
- )
- .ConfigureAwait(false);
- }
+ requestedHandoff = fcc.Name;
+ await AddUpdateAsync(
+ new AgentRunResponseUpdate
+ {
+ AgentId = this._agent.Id,
+ AuthorName = this._agent.DisplayName,
+ Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
+ CreatedAt = DateTimeOffset.UtcNow,
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = ChatRole.Tool,
+ },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
}
}
+ }
- allMessages.AddRange(updates.ToAgentRunResponse().Messages);
+ allMessages.AddRange(updates.ToAgentRunResponse().Messages);
- ResetUserToAssistantForChangedRoles(roleChanges);
+ ResetUserToAssistantForChangedRoles(roleChanges);
- await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
+ await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
- async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken)
+ async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken)
+ {
+ updates.Add(update);
+ if (handoffState.TurnToken.EmitEvents is true)
{
- updates.Add(update);
- if (handoffState.TurnToken.EmitEvents is true)
- {
- await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
- }
+ await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
- });
+ }
+ });
public ValueTask ResetAsync() => default;
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs
index c9a70cc189..a29a6208f9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs
@@ -19,25 +19,6 @@ public class HostApplicationBuilderAgentExtensionsTests
Assert.Throws(
() => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions"));
- ///
- /// Verifies that AddAIAgent with valid parameters returns the same builder instance.
- ///
- /// The chat client key to use, or null to use the default service.
- [Theory]
- [InlineData(null)]
- [InlineData("customKey")]
- public void AddAIAgent_ValidParameters_ReturnsBuilder(string? chatClientKey)
- {
- // Arrange
- var builder = new HostApplicationBuilder();
-
- // Act
- var result = builder.AddAIAgent("agentName", "instructions", chatClientKey);
-
- // Assert
- Assert.Same(builder, result);
- }
-
///
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
///
@@ -59,14 +40,9 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgent_NullInstructions_AllowsNull()
{
- // Arrange
var builder = new HostApplicationBuilder();
-
- // Act
var result = builder.AddAIAgent("agentName", (string)null!);
-
- // Assert
- Assert.Same(builder, result);
+ Assert.NotNull(result);
}
///
@@ -90,14 +66,9 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgentWithKey_NullInstructions_AllowsNull()
{
- // Arrange
var builder = new HostApplicationBuilder();
-
- // Act
var result = builder.AddAIAgent("agentName", null!, "key");
-
- // Assert
- Assert.Same(builder, result);
+ Assert.NotNull(result);
}
///
@@ -148,15 +119,11 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
{
- // Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock();
-
- // Act
var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
- // Assert
- Assert.Same(builder, result);
+ Assert.NotNull(result);
}
///
@@ -192,9 +159,9 @@ public class HostApplicationBuilderAgentExtensionsTests
var builder = new HostApplicationBuilder();
// Act
- builder.AddAIAgent("agent1", "instructions1")
- .AddAIAgent("agent2", "instructions2")
- .AddAIAgent("agent3", "instructions3");
+ builder.AddAIAgent("agent1", "instructions1");
+ builder.AddAIAgent("agent2", "instructions2");
+ builder.AddAIAgent("agent3", "instructions3");
// Assert
var agentDescriptors = builder.Services
@@ -227,14 +194,9 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgent_EmptyInstructions_Succeeds()
{
- // Arrange
var builder = new HostApplicationBuilder();
-
- // Act
var result = builder.AddAIAgent("agentName", "");
-
- // Assert
- Assert.Same(builder, result);
+ Assert.NotNull(result);
}
///
/// Verifies that AddAIAgent without chat client key calls the overload with null key.
@@ -242,14 +204,9 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey()
{
- // Arrange
var builder = new HostApplicationBuilder();
-
- // Act
var result = builder.AddAIAgent("agentName", "instructions");
- // Assert
- Assert.Same(builder, result);
// The agent should be registered (proving the method chain worked)
var descriptor = builder.Services.FirstOrDefault(
d => d.ServiceKey is "agentName" &&
@@ -270,14 +227,9 @@ public class HostApplicationBuilderAgentExtensionsTests
[InlineData("my.agent_1:type-name")] // complex valid name
public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name)
{
- // Arrange
var builder = new HostApplicationBuilder();
-
- // Act
var result = builder.AddAIAgent(name, "instructions");
- // Assert
- Assert.Same(builder, result);
var descriptor = builder.Services.FirstOrDefault(
d => (d.ServiceKey as string) == name &&
d.ServiceType == typeof(AIAgent));
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs
new file mode 100644
index 0000000000..6c4250943e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs
@@ -0,0 +1,247 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Moq;
+
+namespace Microsoft.Agents.AI.Hosting.UnitTests;
+
+public class HostApplicationBuilderWorkflowExtensionsTests
+{
+ ///
+ /// Verifies that providing a null builder to AddWorkflow throws an ArgumentNullException.
+ ///
+ [Fact]
+ public void AddWorkflow_NullBuilder_ThrowsArgumentNullException() =>
+ Assert.Throws(
+ () => HostApplicationBuilderWorkflowExtensions.AddWorkflow(
+ null!,
+ "workflow",
+ (sp, key) => CreateTestWorkflow(key)));
+
+ ///
+ /// Verifies that AddWorkflow throws ArgumentNullException for null name.
+ ///
+ [Fact]
+ public void AddWorkflow_NullName_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddWorkflow(null!, (sp, key) => CreateTestWorkflow(key)));
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddWorkflow throws ArgumentNullException for null factory delegate.
+ ///
+ [Fact]
+ public void AddWorkflow_NullFactory_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddWorkflow("workflowName", null!));
+ Assert.Equal("createWorkflowDelegate", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddWorkflow returns the IHostWorkflowBuilder instance.
+ ///
+ [Fact]
+ public void AddWorkflow_ValidParameters_ReturnsBuilder()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key));
+
+ Assert.NotNull(result);
+ Assert.IsAssignableFrom(result);
+ }
+
+ ///
+ /// Verifies that AddWorkflow registers the workflow as a keyed singleton service.
+ ///
+ [Fact]
+ public void AddWorkflow_RegistersKeyedSingleton()
+ {
+ var builder = new HostApplicationBuilder();
+ const string WorkflowName = "testWorkflow";
+
+ builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key));
+
+ var descriptor = builder.Services.FirstOrDefault(
+ d => (d.ServiceKey as string) == WorkflowName &&
+ d.ServiceType == typeof(Workflow));
+
+ Assert.NotNull(descriptor);
+ Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
+ }
+
+ ///
+ /// Verifies that AddWorkflow can be called multiple times with different workflow names.
+ ///
+ [Fact]
+ public void AddWorkflow_MultipleCalls_RegistersMultipleWorkflows()
+ {
+ var builder = new HostApplicationBuilder();
+
+ builder.AddWorkflow("workflow1", (sp, key) => CreateTestWorkflow(key));
+ builder.AddWorkflow("workflow2", (sp, key) => CreateTestWorkflow(key));
+ builder.AddWorkflow("workflow3", (sp, key) => CreateTestWorkflow(key));
+
+ var workflowDescriptors = builder.Services
+ .Where(d => d.ServiceType == typeof(Workflow) && d.ServiceKey is string)
+ .ToList();
+
+ Assert.Equal(3, workflowDescriptors.Count);
+ Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow1");
+ Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow2");
+ Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3");
+ }
+
+ ///
+ /// Verifies that AddWorkflow handles empty strings for name.
+ ///
+ [Fact]
+ public void AddWorkflow_EmptyName_ThrowsArgumentException()
+ {
+ var builder = new HostApplicationBuilder();
+ var result = builder.AddWorkflow("", (sp, key) => CreateTestWorkflow(key));
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that AddWorkflow with special characters in name works correctly for valid names.
+ ///
+ [Theory]
+ [InlineData("workflow_name")] // underscore is allowed
+ [InlineData("Workflow123")] // alphanumeric is allowed
+ [InlineData("_workflow")] // can start with underscore
+ [InlineData("workflow-name")] // dash is allowed
+ [InlineData("workflow.name")] // period is allowed
+ [InlineData("workflow:type")] // colon is allowed
+ [InlineData("my.workflow_1:type-name")] // complex valid name
+ public void AddWorkflow_ValidSpecialCharactersInName_Succeeds(string name)
+ {
+ var builder = new HostApplicationBuilder();
+
+ var result = builder.AddWorkflow(name, (sp, key) => CreateTestWorkflow(key));
+
+ var descriptor = builder.Services.FirstOrDefault(
+ d => (d.ServiceKey as string) == name &&
+ d.ServiceType == typeof(Workflow));
+ Assert.NotNull(descriptor);
+ }
+
+ ///
+ /// Verifies that providing a null builder to AddConcurrentWorkflow throws an ArgumentNullException.
+ ///
+ [Fact]
+ public void AddConcurrentWorkflow_NullBuilder_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() =>
+ HostApplicationBuilderWorkflowExtensions.AddConcurrentWorkflow(null!, "workflow", [null!]));
+ }
+
+ ///
+ /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null name.
+ ///
+ [Fact]
+ public void AddConcurrentWorkflow_NullName_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddConcurrentWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null agent builders.
+ ///
+ [Fact]
+ public void AddConcurrentWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddConcurrentWorkflow("workflowName", null!));
+ Assert.Equal("agentBuilders", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddConcurrentWorkflow returns IHostWorkflowBuilder instance.
+ ///
+ [Fact]
+ public void AddConcurrentWorkflow_ValidParameters_ReturnsBuilder()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var result = builder.AddConcurrentWorkflow("concurrentWorkflow", [new HostedAgentBuilder("test", builder)]);
+
+ Assert.NotNull(result);
+ Assert.IsAssignableFrom(result);
+ }
+
+ ///
+ /// Verifies that providing a null builder to AddSequentialWorkflow throws an ArgumentNullException.
+ ///
+ [Fact]
+ public void AddSequentialWorkflow_NullBuilder_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() =>
+ HostApplicationBuilderWorkflowExtensions.AddSequentialWorkflow(null!, "workflow", [null!]));
+ }
+
+ ///
+ /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null name.
+ ///
+ [Fact]
+ public void AddSequentialWorkflow_NullName_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddSequentialWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null agent builders.
+ ///
+ [Fact]
+ public void AddSequentialWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddSequentialWorkflow("workflowName", null!));
+ Assert.Equal("agentBuilders", exception.ParamName);
+ }
+
+ [Fact]
+ public void AddSequentialWorkflow_EmptyAgentBuilders_Throws()
+ {
+ var builder = new HostApplicationBuilder();
+
+ var exception = Assert.Throws(() =>
+ builder.AddSequentialWorkflow("sequentialWorkflow", Array.Empty()));
+ Assert.Equal("agentBuilders", exception.ParamName);
+ }
+
+ ///
+ /// Helper method to create a simple test workflow with a given name.
+ ///
+ private static Workflow CreateTestWorkflow(string name)
+ {
+ // Create a simple workflow using AgentWorkflowBuilder
+ var mockAgent = new Mock();
+ mockAgent.Setup(a => a.Name).Returns("testAgent");
+
+ return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: [mockAgent.Object]);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
index e86df11d6f..efd1c96f88 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
@@ -21,7 +21,7 @@ public class AgentWorkflowBuilderTests
[Fact]
public void BuildSequential_InvalidArguments_Throws()
{
- Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(null!));
+ Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential());
}