.NET [WIP]: introduce Hosting extensions for Workflows (#1359)

* skeleton

* wip

* rename + fix tests

* implement workflow tests

* fix comments

* Update dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixes

* fix worfklow build logic

* rollback + new overload on workflow builder

* address PR comments

* :)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Korolev Dmitry
2025-10-14 21:44:58 +02:00
committed by GitHub
Unverified
parent f42a3ee6b9
commit 0331331dbc
18 changed files with 678 additions and 107 deletions
@@ -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");
@@ -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
/// <param name="instructions">The instructions for the agent.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
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
/// <param name="chatClient">The chat client which the agent will use for inference.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
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
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
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
/// <param name="chatClientServiceKey">The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved.</param>
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="instructions"/> is null.</exception>
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
/// <returns>The configured host application builder.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createAgentDelegate"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the agent factory delegate returns null or an invalid AI agent instance.</exception>
public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> createAgentDelegate)
public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, AIAgent> 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)
@@ -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;
/// <summary>
/// Provides extension methods for configuring AI workflows in a host application builder.
/// </summary>
public static class HostApplicationBuilderWorkflowExtensions
{
/// <summary>
/// Registers a concurrent workflow that executes multiple agents in parallel.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="agentBuilders">A collection of <see cref="IHostedAgentBuilder"/> instances representing agents to execute concurrently.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="agentBuilders"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> or <paramref name="agentBuilders"/> is empty.</exception>
public static IHostedWorkflowBuilder AddConcurrentWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable<IHostedAgentBuilder> agentBuilders)
{
Throw.IfNullOrEmpty(agentBuilders);
return builder.AddWorkflow(name, (sp, key) =>
{
var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildConcurrent(workflowName: name, agents: agents);
});
}
/// <summary>
/// Registers a sequential workflow that executes agents in a specific order.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="agentBuilders">A collection of <see cref="IHostedAgentBuilder"/> instances representing agents to execute in sequence.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="agentBuilders"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> or <paramref name="agentBuilders"/> is empty.</exception>
public static IHostedWorkflowBuilder AddSequentialWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable<IHostedAgentBuilder> agentBuilders)
{
Throw.IfNullOrEmpty(agentBuilders);
return builder.AddWorkflow(name, (sp, key) =>
{
var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: agents);
});
}
/// <summary>
/// Registers a custom workflow using a factory delegate.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="name">The unique name for the workflow.</param>
/// <param name="createWorkflowDelegate">A factory function that creates the <see cref="Workflow"/> instance. The function receives the service provider and workflow name as parameters.</param>
/// <returns>An <see cref="IHostedWorkflowBuilder"/> that can be used to further configure the workflow.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="name"/>, or <paramref name="createWorkflowDelegate"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name.
/// </exception>
public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func<IServiceProvider, string, Workflow> 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<WorkflowCatalog, LocalWorkflowCatalog>();
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides extension methods for <see cref="IHostedWorkflowBuilder"/> to enable additional workflow configuration scenarios.
/// </summary>
public static class HostedWorkflowBuilderExtensions
{
/// <summary>
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder)
=> builder.AddAsAIAgent(name: null);
/// <summary>
/// Registers the workflow as an AI agent in the dependency injection container.
/// </summary>
/// <param name="builder">The <see cref="IHostedWorkflowBuilder"/> instance to extend.</param>
/// <param name="name">The optional name for the AI agent. If not specified, the workflow name is used.</param>
/// <returns>An <see cref="IHostedAgentBuilder"/> that can be used to further configure the agent.</returns>
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<Workflow>(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
});
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Hosting;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Represents a builder for configuring AI agents within a hosting environment.
/// </summary>
public interface IHostedAgentBuilder
{
/// <summary>
/// Gets the name of the agent being configured.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the application host builder for configuring additional services.
/// </summary>
IHostApplicationBuilder HostApplicationBuilder { get; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Hosting;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Represents a builder for configuring workflows within a hosting environment.
/// </summary>
public interface IHostedWorkflowBuilder
{
/// <summary>
/// Gets the name of the workflow being configured.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the application host builder for configuring additional services.
/// </summary>
IHostApplicationBuilder HostApplicationBuilder { get; }
}
@@ -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
@@ -2,7 +2,7 @@
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Hosting;
namespace Microsoft.Agents.AI.Hosting.Local;
internal sealed class LocalAgentRegistry
{
@@ -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<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;
}
}
}
}
@@ -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<string> WorkflowNames { get; } = [];
}
@@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -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;
/// <summary>
/// Provides a catalog of registered workflows within the hosting environment.
/// </summary>
public abstract class WorkflowCatalog
{
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowCatalog"/> class.
/// </summary>
protected WorkflowCatalog()
{
}
/// <summary>
/// Asynchronously retrieves all registered workflows from the catalog.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
public abstract IAsyncEnumerable<Workflow> GetWorkflowsAsync(CancellationToken cancellationToken = default);
}
@@ -26,6 +26,18 @@ public static partial class AgentWorkflowBuilder
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
/// <returns>The built workflow composed of the supplied <paramref name="agents"/>, in the order in which they were yielded from the source.</returns>
public static Workflow BuildSequential(params IEnumerable<AIAgent> agents)
=> BuildSequentialCore(workflowName: null, agents);
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of a pipeline of agents where the output of one agent is the input to the next.
/// </summary>
/// <param name="workflowName">The name of workflow.</param>
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
/// <returns>The built workflow composed of the supplied <paramref name="agents"/>, in the order in which they were yielded from the source.</returns>
public static Workflow BuildSequential(string workflowName, params IEnumerable<AIAgent> agents)
=> BuildSequentialCore(workflowName, agents);
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> 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();
}
/// <summary>
@@ -79,6 +94,30 @@ public static partial class AgentWorkflowBuilder
public static Workflow BuildConcurrent(
IEnumerable<AIAgent> agents,
Func<IList<List<ChatMessage>>, List<ChatMessage>>? aggregator = null)
=> BuildConcurrentCore(workflowName: null, agents, aggregator);
/// <summary>
/// Builds a <see cref="Workflow{T}"/> composed of agents that operate concurrently on the same input,
/// aggregating their outputs into a single collection.
/// </summary>
/// <param name="workflowName">The name of the workflow.</param>
/// <param name="agents">The set of agents to compose into a concurrent workflow.</param>
/// <param name="aggregator">
/// The aggregation function that accepts a list of the output messages from each <paramref name="agents"/> and produces
/// a single result list. If <see langword="null"/>, the default behavior is to return a list containing the last message
/// from each agent that produced at least one message.
/// </param>
/// <returns>The built workflow composed of the supplied concurrent <paramref name="agents"/>.</returns>
public static Workflow BuildConcurrent(
string workflowName,
IEnumerable<AIAgent> agents,
Func<IList<List<ChatMessage>>, List<ChatMessage>>? aggregator = null)
=> BuildConcurrentCore(workflowName, agents, aggregator);
private static Workflow BuildConcurrentCore(
string? workflowName,
IEnumerable<AIAgent> agents,
Func<IList<List<ChatMessage>>, List<ChatMessage>>? 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();
}
/// <summary>Creates a new <see cref="HandoffsWorkflowBuilder"/> using <paramref name="initialAgent"/> as the starting agent in the workflow.</summary>
@@ -536,57 +580,57 @@ public static partial class AgentWorkflowBuilder
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>(async (handoffState, context, cancellationToken) =>
{
string? requestedHandoff = null;
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
List<ChatMessage>? 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<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
List<ChatMessage>? 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;
}
@@ -19,25 +19,6 @@ public class HostApplicationBuilderAgentExtensionsTests
Assert.Throws<ArgumentNullException>(
() => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions"));
/// <summary>
/// Verifies that AddAIAgent with valid parameters returns the same builder instance.
/// </summary>
/// <param name="chatClientKey">The chat client key to use, or null to use the default service.</param>
[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);
}
/// <summary>
/// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name.
/// </summary>
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -148,15 +119,11 @@ public class HostApplicationBuilderAgentExtensionsTests
[Fact]
public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder()
{
// Arrange
var builder = new HostApplicationBuilder();
var mockAgent = new Mock<AIAgent>();
// Act
var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object);
// Assert
Assert.Same(builder, result);
Assert.NotNull(result);
}
/// <summary>
@@ -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);
}
/// <summary>
/// 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));
@@ -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
{
/// <summary>
/// Verifies that providing a null builder to AddWorkflow throws an ArgumentNullException.
/// </summary>
[Fact]
public void AddWorkflow_NullBuilder_ThrowsArgumentNullException() =>
Assert.Throws<ArgumentNullException>(
() => HostApplicationBuilderWorkflowExtensions.AddWorkflow(
null!,
"workflow",
(sp, key) => CreateTestWorkflow(key)));
/// <summary>
/// Verifies that AddWorkflow throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddWorkflow_NullName_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddWorkflow(null!, (sp, key) => CreateTestWorkflow(key)));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddWorkflow throws ArgumentNullException for null factory delegate.
/// </summary>
[Fact]
public void AddWorkflow_NullFactory_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddWorkflow("workflowName", null!));
Assert.Equal("createWorkflowDelegate", exception.ParamName);
}
/// <summary>
/// Verifies that AddWorkflow returns the IHostWorkflowBuilder instance.
/// </summary>
[Fact]
public void AddWorkflow_ValidParameters_ReturnsBuilder()
{
var builder = new HostApplicationBuilder();
var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key));
Assert.NotNull(result);
Assert.IsAssignableFrom<IHostedWorkflowBuilder>(result);
}
/// <summary>
/// Verifies that AddWorkflow registers the workflow as a keyed singleton service.
/// </summary>
[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);
}
/// <summary>
/// Verifies that AddWorkflow can be called multiple times with different workflow names.
/// </summary>
[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");
}
/// <summary>
/// Verifies that AddWorkflow handles empty strings for name.
/// </summary>
[Fact]
public void AddWorkflow_EmptyName_ThrowsArgumentException()
{
var builder = new HostApplicationBuilder();
var result = builder.AddWorkflow("", (sp, key) => CreateTestWorkflow(key));
Assert.NotNull(result);
}
/// <summary>
/// Verifies that AddWorkflow with special characters in name works correctly for valid names.
/// </summary>
[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);
}
/// <summary>
/// Verifies that providing a null builder to AddConcurrentWorkflow throws an ArgumentNullException.
/// </summary>
[Fact]
public void AddConcurrentWorkflow_NullBuilder_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
HostApplicationBuilderWorkflowExtensions.AddConcurrentWorkflow(null!, "workflow", [null!]));
}
/// <summary>
/// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddConcurrentWorkflow_NullName_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddConcurrentWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null agent builders.
/// </summary>
[Fact]
public void AddConcurrentWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddConcurrentWorkflow("workflowName", null!));
Assert.Equal("agentBuilders", exception.ParamName);
}
/// <summary>
/// Verifies that AddConcurrentWorkflow returns IHostWorkflowBuilder instance.
/// </summary>
[Fact]
public void AddConcurrentWorkflow_ValidParameters_ReturnsBuilder()
{
var builder = new HostApplicationBuilder();
var result = builder.AddConcurrentWorkflow("concurrentWorkflow", [new HostedAgentBuilder("test", builder)]);
Assert.NotNull(result);
Assert.IsAssignableFrom<IHostedWorkflowBuilder>(result);
}
/// <summary>
/// Verifies that providing a null builder to AddSequentialWorkflow throws an ArgumentNullException.
/// </summary>
[Fact]
public void AddSequentialWorkflow_NullBuilder_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
HostApplicationBuilderWorkflowExtensions.AddSequentialWorkflow(null!, "workflow", [null!]));
}
/// <summary>
/// Verifies that AddSequentialWorkflow throws ArgumentNullException for null name.
/// </summary>
[Fact]
public void AddSequentialWorkflow_NullName_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddSequentialWorkflow(null!, [new HostedAgentBuilder("test", builder)]));
Assert.Equal("name", exception.ParamName);
}
/// <summary>
/// Verifies that AddSequentialWorkflow throws ArgumentNullException for null agent builders.
/// </summary>
[Fact]
public void AddSequentialWorkflow_NullAgentBuilders_ThrowsArgumentNullException()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.AddSequentialWorkflow("workflowName", null!));
Assert.Equal("agentBuilders", exception.ParamName);
}
[Fact]
public void AddSequentialWorkflow_EmptyAgentBuilders_Throws()
{
var builder = new HostApplicationBuilder();
var exception = Assert.Throws<ArgumentException>(() =>
builder.AddSequentialWorkflow("sequentialWorkflow", Array.Empty<IHostedAgentBuilder>()));
Assert.Equal("agentBuilders", exception.ParamName);
}
/// <summary>
/// Helper method to create a simple test workflow with a given name.
/// </summary>
private static Workflow CreateTestWorkflow(string name)
{
// Create a simple workflow using AgentWorkflowBuilder
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(a => a.Name).Returns("testAgent");
return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: [mockAgent.Object]);
}
}
@@ -21,7 +21,7 @@ public class AgentWorkflowBuilderTests
[Fact]
public void BuildSequential_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(null!));
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}