feat: SequentialWorkflowBuilder and ConcurrentWorkflowBuilder, OrchestrationBuilderBase

Promotes the Sequential and Concurrent orchestration shapes to first-class fluent
builder classes, matching Handoff / GroupChat / Magentic. Users can call
`WithOutputFrom(agents)` / `WithIntermediateOutputFrom(agents)` to control which
agents are designated output / intermediate sources; when no designation call is
made, the Python-aligned defaults apply (terminal aggregator output + every agent
intermediate; Concurrent also tags per-agent accumulators).

`AgentWorkflowBuilder.BuildSequential(...)` and `BuildConcurrent(...)` are kept
and now delegate to the new builders; observable behavior unchanged. Five static
factories now mirror each other:

- `AgentWorkflowBuilder.CreateSequentialBuilderWith(params IEnumerable<AIAgent>)`
- `AgentWorkflowBuilder.CreateConcurrentBuilderWith(params IEnumerable<AIAgent>)`
- `AgentWorkflowBuilder.CreateHandoffBuilderWith(AIAgent)`        (already existed)
- `AgentWorkflowBuilder.CreateGroupChatBuilderWith(Func<...>)`    (already existed)
- `AgentWorkflowBuilder.CreateMagenticBuilderWith(AIAgent)`       (new)

OrchestrationBuilderBase
------------------------
New abstract `OrchestrationBuilderBase<TBuilder>` unifies the shared fluent
surface across all five orchestration builders: `WithName`, `WithDescription`,
`WithOutputFrom`, `WithIntermediateOutputFrom`, and the
`ApplyOutputDesignations(builder, agentMap, kind, applyDefaults)` helper that
either replays the user's designations or invokes the orchestration-specific
defaults.

Removes ~150 LOC of duplicated designation-management code from the four
non-Handoff builders, plus the equivalent from `HandoffWorkflowBuilderCore`.

Tests
-----
- New `SequentialWorkflowBuilderTests.cs` / `ConcurrentWorkflowBuilderTests.cs`
  (replace the old `AgentWorkflowBuilder.{Sequential,Concurrent}Tests.cs`
  nested-class files). Method names normalized to
  `Test_<BuilderType>_<Scenario>[Async]`.
- Shared helpers (`DoubleEchoAgent`, `DoubleEchoAgentWithBarrier`,
  `WorkflowRunResult`, `RunWorkflow*`) moved from the old
  `AgentWorkflowBuilderTests` partial class into a new
  `OrchestrationTestHelpers` static class in `OrchestrationTestHelpers.cs`.
  Downstream test files (Group Chat, Handoff, Sequential, Concurrent) updated
  to qualify with `OrchestrationTestHelpers.*`.
- A new `AgentWorkflowBuilderTests.cs` covers the static surface directly:
  `BuildSequential` / `BuildConcurrent` invariants and aggregator wiring, plus
  null-rejection + round-trip checks for every `Create*BuilderWith` factory.
- New AsAgent intermediate-suppression tests on a nested `AsAgentForwarding`
  class for each of Sequential and Concurrent: build with only the terminal
  agent designated via `WithOutputFrom`, run via `AsAIAgent(...)`, assert via
  `AgentResponseUpdate.AuthorName` that intermediate agents do not surface.
  Both join the `FuturesSerial` collection.
- New `Test_<Builder>_WithDescriptionPropagatesToWorkflow` smoke tests on
  Sequential and Concurrent (newly available via the base class).

625/625 unit tests pass on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jacob Alber
2026-05-22 14:49:42 -04:00
committed by Jacob Alber
Unverified
parent 508aeefb6b
commit 3baf527909
15 changed files with 1057 additions and 696 deletions
@@ -3,9 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -37,33 +35,10 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNullOrEmpty(agents);
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
builder.AddEdge(previous, next);
previous = next;
}
OutputMessagesExecutor end = new();
builder = builder.AddEdge(previous, end)
.WithOutputFrom(end)
.WithIntermediateOutputFrom(agentExecutors);
SequentialWorkflowBuilder builder = new(agents);
if (workflowName is not null)
{
builder = builder.WithName(workflowName);
builder.WithName(workflowName);
}
return builder.Build();
}
@@ -109,42 +84,14 @@ public static partial class AgentWorkflowBuilder
{
Throw.IfNull(agents);
// A workflow needs a starting executor, so we create one that forwards everything to each agent.
ChatForwardingExecutor start = new("Start");
WorkflowBuilder builder = new(start);
// For each agent, we create an executor to host it and an accumulator to batch up its output messages,
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorBinding[] agentExecutors = (from agent in agents
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
}
// Create the accumulating executor that will gather the results from each agent, and connect
// each agent's accumulator to it. If no aggregation function was provided, we default to returning
// the last message from each agent
aggregator ??= static lists => (from list in lists where list.Count > 0 select list.Last()).ToList();
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInBarrierEdge(accumulators, end);
builder = builder.WithOutputFrom(end)
.WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]);
ConcurrentWorkflowBuilder builder = new(agents);
if (workflowName is not null)
{
builder = builder.WithName(workflowName);
builder.WithName(workflowName);
}
if (aggregator is not null)
{
builder.WithAggregator(aggregator);
}
return builder.Build();
}
@@ -182,4 +129,32 @@ public static partial class AgentWorkflowBuilder
Throw.IfNull(managerFactory);
return new GroupChatWorkflowBuilder(managerFactory);
}
/// <summary>Creates a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline of <paramref name="agents"/>.</summary>
/// <param name="agents">The sequence of agents to compose into a sequential workflow.</param>
/// <returns>The builder for creating a sequential workflow.</returns>
public static SequentialWorkflowBuilder CreateSequentialBuilderWith(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
return new SequentialWorkflowBuilder(agents);
}
/// <summary>Creates a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating <paramref name="agents"/>.</summary>
/// <param name="agents">The set of agents to compose into a concurrent workflow.</param>
/// <returns>The builder for creating a concurrent workflow.</returns>
public static ConcurrentWorkflowBuilder CreateConcurrentBuilderWith(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
return new ConcurrentWorkflowBuilder(agents);
}
/// <summary>Creates a new <see cref="MagenticWorkflowBuilder"/> with the given <paramref name="managerAgent"/>.</summary>
/// <param name="managerAgent">The LLM-powered manager agent that coordinates the team.</param>
/// <returns>The builder for creating a Magentic workflow.</returns>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public static MagenticWorkflowBuilder CreateMagenticBuilderWith(AIAgent managerAgent)
{
Throw.IfNull(managerAgent);
return new MagenticWorkflowBuilder(managerAgent);
}
}
@@ -0,0 +1,104 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for concurrent agent workflows: a fan-out start that broadcasts the
/// incoming messages to every participating agent, a per-agent accumulator that batches
/// each agent's outgoing messages, and a fan-in aggregator that reduces them into a
/// single output list.
/// </summary>
/// <remarks>
/// When no explicit output designations are made, the default is the Python-aligned
/// shape: the terminal aggregator is the workflow output, and every participating agent
/// (plus its per-agent accumulator) is designated as an intermediate output source.
/// Calling <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(System.Collections.Generic.IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(System.Collections.Generic.IEnumerable{AIAgent})"/>
/// at all suppresses these defaults.
/// </remarks>
public sealed class ConcurrentWorkflowBuilder : OrchestrationBuilderBase<ConcurrentWorkflowBuilder>
{
private readonly List<AIAgent> _agents = [];
private Func<IList<List<ChatMessage>>, List<ChatMessage>>? _aggregator;
/// <summary>
/// Initializes a new <see cref="ConcurrentWorkflowBuilder"/> with the given participating
/// <paramref name="agents"/>.
/// </summary>
public ConcurrentWorkflowBuilder(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
this._agents.Add(agent);
}
}
/// <summary>
/// Sets the aggregator function. If not called, defaults to returning the last message
/// from each agent that produced at least one message.
/// </summary>
public ConcurrentWorkflowBuilder WithAggregator(Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
{
this._aggregator = Throw.IfNull(aggregator);
return this;
}
/// <summary>Builds the configured concurrent workflow.</summary>
public Workflow Build()
{
if (this._agents.Count == 0)
{
throw new ArgumentException("At least one agent must be provided to the ConcurrentWorkflowBuilder.", "agents");
}
ChatForwardingExecutor start = new("Start");
WorkflowBuilder builder = new(start);
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
ExecutorBinding[] agentExecutors = new ExecutorBinding[this._agents.Count];
ExecutorBinding[] accumulators = new ExecutorBinding[this._agents.Count];
AIAgentHostOptions options = new() { ReassignOtherAgentsAsUsers = true };
for (int i = 0; i < this._agents.Count; i++)
{
AIAgent agent = this._agents[i];
ExecutorBinding binding = agent.BindAsExecutor(options);
agentExecutors[i] = binding;
agentMap[agent] = binding;
accumulators[i] = new AggregateTurnMessagesExecutor($"Batcher/{binding.Id}");
}
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
}
Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator =
this._aggregator ?? (static lists => (from list in lists where list.Count > 0 select list.Last()).ToList());
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(_, __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInBarrierEdge(accumulators, end);
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "concurrent", () =>
{
builder.WithOutputFrom(end);
builder.WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]);
});
return builder.Build();
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
@@ -12,14 +12,10 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow.
/// </summary>
public sealed class GroupChatWorkflowBuilder
public sealed class GroupChatWorkflowBuilder : OrchestrationBuilderBase<GroupChatWorkflowBuilder>
{
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory;
private readonly HashSet<AIAgent> _participants = new(AIAgentIDEqualityComparer.Instance);
private string _name = string.Empty;
private string _description = string.Empty;
private Dictionary<AIAgent, HashSet<OutputTag>>? _outputDesignations;
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
@@ -46,70 +42,6 @@ public sealed class GroupChatWorkflowBuilder
return this;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
/// <param name="name">The name of the workflow.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <summary>
/// Sets the description for the workflow.
/// </summary>
/// <param name="description">The description of what the workflow does.</param>
/// <returns>This instance of the <see cref="GroupChatWorkflowBuilder"/>.</returns>
public GroupChatWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
/// suppresses the orchestration-specific defaults: only the user-specified designations
/// reach the inner <see cref="WorkflowBuilder"/>.
/// </summary>
public GroupChatWorkflowBuilder WithOutputFrom(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.ContainsKey(agent))
{
this._outputDesignations[agent] = [];
}
}
return this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow output.
/// See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
/// </summary>
public GroupChatWorkflowBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.TryGetValue(agent, out HashSet<OutputTag>? tags))
{
tags = [];
this._outputDesignations[agent] = tags;
}
tags.Add(OutputTag.Intermediate);
}
return this;
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via group chat, with the next
/// agent to process messages selected by the group chat manager.
@@ -137,15 +69,7 @@ public sealed class GroupChatWorkflowBuilder
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
if (!string.IsNullOrEmpty(this._name))
{
builder = builder.WithName(this._name);
}
if (!string.IsNullOrEmpty(this._description))
{
builder = builder.WithDescription(this._description);
}
this.ApplyMetadata(builder);
foreach (var participant in agentMap.Values)
{
@@ -154,48 +78,15 @@ public sealed class GroupChatWorkflowBuilder
.AddEdge(participant, host);
}
this.ApplyOutputDesignations(builder, host, agentMap);
return builder.Build();
}
private void ApplyOutputDesignations(
WorkflowBuilder builder,
ExecutorBinding host,
Dictionary<AIAgent, ExecutorBinding> agentMap)
{
if (this._outputDesignations is null)
this.ApplyOutputDesignations(builder, agentMap, "group chat", () =>
{
// Defaults (matches Python group-chat orchestration):
// host -> terminal output
// participants-> intermediate output
builder.WithOutputFrom(host);
if (agentMap.Count > 0)
{
builder.WithIntermediateOutputFrom([.. agentMap.Values]);
}
return;
}
});
foreach (AIAgent agent in this._outputDesignations.Keys)
{
if (!agentMap.TryGetValue(agent, out ExecutorBinding? binding))
{
throw new InvalidOperationException(
$"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this group chat workflow.");
}
HashSet<OutputTag> tags = this._outputDesignations[agent];
if (tags.Count == 0)
{
builder.WithOutputFrom(binding);
}
else
{
foreach (OutputTag tag in tags)
{
builder.WithOutputFrom(binding, tag);
}
}
}
return builder.Build();
}
}
@@ -39,7 +39,8 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBuilder>
where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
@@ -55,8 +56,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private string? _name;
private string? _description;
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
// handoff triggers another invocation of that same agent with the continuation prompt, up to
@@ -74,16 +73,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
// if true, the workflow ends (and the autonomous loop, if any, terminates).
private Func<IReadOnlyList<ChatMessage>, ValueTask<bool>>? _terminationCondition;
/// <summary>
/// Memoized output designations. <see langword="null"/> means the user has not made any
/// explicit designation, and the orchestration-specific defaults will be applied at
/// <see cref="Build"/> time. A non-null (possibly empty) dictionary means the user took
/// control and only these designations will be replayed onto the inner
/// <see cref="WorkflowBuilder"/>. An entry's value is the set of tags requested for the
/// agent — an empty set encodes a terminal-only designation.
/// </summary>
private Dictionary<AIAgent, HashSet<OutputTag>>? _outputDesignations;
/// <summary>
/// Initializes a new instance of the <see cref="HandoffsWorkflowBuilder"/> class with no handoff relationships.
/// </summary>
@@ -126,20 +115,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public TBuilder WithName(string name)
{
this._name = name;
return (TBuilder)this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public TBuilder WithDescription(string description)
{
this._description = description;
return (TBuilder)this;
}
/// <summary>
/// Sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
@@ -185,48 +160,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
/// suppresses the orchestration-specific defaults: only the user-specified designations
/// reach the inner <see cref="WorkflowBuilder"/>. To restore defaults, build a fresh builder.
/// </summary>
public TBuilder WithOutputFrom(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.ContainsKey(agent))
{
this._outputDesignations[agent] = [];
}
}
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow
/// output. See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
/// </summary>
public TBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.TryGetValue(agent, out HashSet<OutputTag>? tags))
{
tags = [];
this._outputDesignations[agent] = tags;
}
tags.Add(OutputTag.Intermediate);
}
return (TBuilder)this;
}
/// <summary>
/// Adds handoff relationships from a source agent to one or more target agents.
/// </summary>
@@ -683,64 +616,30 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
});
}
if (!string.IsNullOrWhiteSpace(this._name))
{
builder.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
builder.WithDescription(this._description);
}
// Ensure the end executor is bound regardless of whether it ends up as an output
// designation source — the user may take full control of output designations.
builder.BindExecutor(end);
this.ApplyOutputDesignations(builder, end, executors);
return builder.Build();
}
// Build the AIAgent -> ExecutorBinding map the base helper expects.
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in this._allAgents)
{
agentMap[agent] = executors[agent.Id];
}
private void ApplyOutputDesignations(
WorkflowBuilder builder,
HandoffEndExecutor end,
Dictionary<string, ExecutorBinding> executors)
{
if (this._outputDesignations is null)
this.ApplyOutputDesignations(builder, agentMap, "handoff", () =>
{
// Defaults (matches Python's Handoff orchestration):
// end -> terminal output (Output)
// every handoff agent -> intermediate output (Intermediate)
// end -> terminal output
// every handoff agent -> intermediate output
builder.WithOutputFrom(end);
List<ExecutorBinding> agentBindings = [.. executors.Values];
if (agentBindings.Count > 0)
{
builder.WithIntermediateOutputFrom(agentBindings);
}
return;
}
});
// User took control — replay only their designations.
foreach (AIAgent agent in this._outputDesignations.Keys)
{
if (!executors.TryGetValue(agent.Id, out ExecutorBinding? binding))
{
throw new InvalidOperationException(
$"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this handoff workflow.");
}
HashSet<OutputTag> tags = this._outputDesignations[agent];
if (tags.Count == 0)
{
builder.WithOutputFrom(binding);
}
else
{
foreach (OutputTag tag in tags)
{
builder.WithOutputFrom(binding, tag);
}
}
}
return builder.Build();
}
}
@@ -29,18 +29,14 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
/// <param name="managerAgent"></param>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class MagenticWorkflowBuilder(AIAgent managerAgent)
public class MagenticWorkflowBuilder(AIAgent managerAgent) : OrchestrationBuilderBase<MagenticWorkflowBuilder>
{
private readonly List<AIAgent> _team = new();
private string? _name;
private string? _description;
private int _maxStalls = TaskLimits.DefaultMaxStallCount;
private int? _maxRounds;
private int? _maxResets;
private bool _requirePlanSignoff = true;
private Dictionary<AIAgent, HashSet<OutputTag>>? _outputDesignations;
/// <inheritdoc cref="GroupChatWorkflowBuilder.AddParticipants(IEnumerable{AIAgent})"/>
public MagenticWorkflowBuilder AddParticipants(params IEnumerable<AIAgent> agents)
{
@@ -48,20 +44,6 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithName(string)"/>
public MagenticWorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <inheritdoc cref="WorkflowBuilder.WithDescription(string)"/>
public MagenticWorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Set the maximum number of coordination rounds. <see langword="null"/> means unlimited.
/// </summary>
@@ -103,48 +85,6 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
return this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
/// suppresses the orchestration-specific defaults: only the user-specified designations
/// reach the inner <see cref="WorkflowBuilder"/>.
/// </summary>
public MagenticWorkflowBuilder WithOutputFrom(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.ContainsKey(agent))
{
this._outputDesignations[agent] = [];
}
}
return this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow output.
/// See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
/// </summary>
public MagenticWorkflowBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this._outputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this._outputDesignations.TryGetValue(agent, out HashSet<OutputTag>? tags))
{
tags = [];
this._outputDesignations[agent] = tags;
}
tags.Add(OutputTag.Intermediate);
}
return this;
}
private WorkflowBuilder ReduceToWorkflowBuilder()
{
// Create a copy of the team so that improper modifications by using the builder after .Build() do not affect the
@@ -172,60 +112,18 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
}
result.AddFanOutEdge(orchestrator, teamBindings);
this.ApplyOutputDesignations(result, orchestrator, teamMap);
if (!string.IsNullOrWhiteSpace(this._name))
this.ApplyOutputDesignations(result, teamMap, "Magentic", () =>
{
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
return result;
}
private void ApplyOutputDesignations(
WorkflowBuilder builder,
ExecutorBinding orchestrator,
Dictionary<AIAgent, ExecutorBinding> teamMap)
{
if (this._outputDesignations is null)
{
// Defaults (matches Python Magentic orchestration):
// orchestrator -> terminal output
// team members -> intermediate output
builder.WithOutputFrom(orchestrator);
result.WithOutputFrom(orchestrator);
if (teamMap.Count > 0)
{
builder.WithIntermediateOutputFrom([.. teamMap.Values]);
result.WithIntermediateOutputFrom([.. teamMap.Values]);
}
return;
}
});
foreach (AIAgent agent in this._outputDesignations.Keys)
{
if (!teamMap.TryGetValue(agent, out ExecutorBinding? binding))
{
throw new InvalidOperationException(
$"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this Magentic workflow.");
}
HashSet<OutputTag> tags = this._outputDesignations[agent];
if (tags.Count == 0)
{
builder.WithOutputFrom(binding);
}
else
{
foreach (OutputTag tag in tags)
{
builder.WithOutputFrom(binding, tag);
}
}
}
this.ApplyMetadata(result);
return result;
}
/// <inheritdoc cref="WorkflowBuilder.Build"/>
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Common fluent surface shared by every orchestration-style workflow builder:
/// human-readable name + description, and the
/// <see cref="WithOutputFrom"/> / <see cref="WithIntermediateOutputFrom"/> output-designation
/// pair with memoized defaults-suppression semantics.
/// </summary>
/// <typeparam name="TBuilder">The concrete builder type, for fluent self-return.</typeparam>
public abstract class OrchestrationBuilderBase<TBuilder>
where TBuilder : OrchestrationBuilderBase<TBuilder>
{
/// <summary>Optional workflow name; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
protected string? Name { get; private set; }
/// <summary>Optional workflow description; applied to the inner <see cref="WorkflowBuilder"/> at <c>Build()</c>.</summary>
protected string? Description { get; private set; }
/// <summary>
/// Memoized output designations. <see langword="null"/> means the user has not made any
/// explicit designation, and the orchestration-specific defaults will be applied at
/// <c>Build()</c> time. A non-<see langword="null"/> (possibly empty) map means the user took
/// control and only these designations will be replayed onto the inner
/// <see cref="WorkflowBuilder"/>. An entry's value is the set of tags requested for the
/// agent — an empty set encodes a terminal-only designation.
/// </summary>
protected Dictionary<AIAgent, HashSet<OutputTag>>? OutputDesignations { get; private set; }
/// <summary>Sets the human-readable name for the workflow.</summary>
public TBuilder WithName(string name)
{
this.Name = name;
return (TBuilder)this;
}
/// <summary>Sets the description for the workflow.</summary>
public TBuilder WithDescription(string description)
{
this.Description = description;
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of terminal workflow output.
/// Calling any output-designation method (this or <see cref="WithIntermediateOutputFrom"/>)
/// suppresses the orchestration-specific defaults: only the user-specified designations
/// reach the inner <see cref="WorkflowBuilder"/>.
/// </summary>
public TBuilder WithOutputFrom(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this.OutputDesignations.ContainsKey(agent))
{
this.OutputDesignations[agent] = [];
}
}
return (TBuilder)this;
}
/// <summary>
/// Designates the given <paramref name="agents"/> as sources of <b>intermediate</b> workflow
/// output. See <see cref="WithOutputFrom"/> for the defaults-suppression semantics.
/// </summary>
public TBuilder WithIntermediateOutputFrom(IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
this.OutputDesignations ??= new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
if (!this.OutputDesignations.TryGetValue(agent, out HashSet<OutputTag>? tags))
{
tags = [];
this.OutputDesignations[agent] = tags;
}
tags.Add(OutputTag.Intermediate);
}
return (TBuilder)this;
}
/// <summary>
/// Applies the optional <see cref="Name"/> and <see cref="Description"/> to <paramref name="builder"/>.
/// Subclasses should call this from their <c>Build()</c> implementation.
/// </summary>
protected void ApplyMetadata(WorkflowBuilder builder)
{
Throw.IfNull(builder);
if (!string.IsNullOrWhiteSpace(this.Name))
{
builder.WithName(this.Name!);
}
if (!string.IsNullOrWhiteSpace(this.Description))
{
builder.WithDescription(this.Description!);
}
}
/// <summary>
/// Applies the user's memoized output designations to <paramref name="builder"/>, or invokes
/// <paramref name="applyDefaults"/> if the user made no explicit designation.
/// </summary>
/// <param name="builder">The inner <see cref="WorkflowBuilder"/>.</param>
/// <param name="agentMap">Map from participating <see cref="AIAgent"/> to its bound executor.</param>
/// <param name="orchestrationKind">Used in the not-a-participant error message (e.g. "sequential", "group chat").</param>
/// <param name="applyDefaults">Action invoked when no explicit designation was made.</param>
protected void ApplyOutputDesignations(
WorkflowBuilder builder,
IReadOnlyDictionary<AIAgent, ExecutorBinding> agentMap,
string orchestrationKind,
Action applyDefaults)
{
Throw.IfNull(builder);
Throw.IfNull(agentMap);
Throw.IfNull(applyDefaults);
if (this.OutputDesignations is null)
{
applyDefaults();
return;
}
foreach (AIAgent agent in this.OutputDesignations.Keys)
{
if (!agentMap.TryGetValue(agent, out ExecutorBinding? binding))
{
throw new InvalidOperationException(
$"Output designation references agent '{agent.Name ?? agent.Id}', which is not a participant in this {orchestrationKind} workflow.");
}
HashSet<OutputTag> tags = this.OutputDesignations[agent];
if (tags.Count == 0)
{
builder.WithOutputFrom(binding);
}
else
{
foreach (OutputTag tag in tags)
{
builder.WithOutputFrom(binding, tag);
}
}
}
}
}
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Fluent builder for sequential agent workflows: a pipeline where the output of one
/// agent is the input to the next, terminating in an aggregator that yields the
/// accumulated <see cref="Microsoft.Extensions.AI.ChatMessage"/>s as the workflow output.
/// </summary>
/// <remarks>
/// When no explicit output designations are made, the default is the Python-aligned
/// shape: the terminal aggregator is the workflow output, and every participating agent
/// is designated as an intermediate output source. Calling
/// <see cref="OrchestrationBuilderBase{TBuilder}.WithOutputFrom(System.Collections.Generic.IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(System.Collections.Generic.IEnumerable{AIAgent})"/>
/// at all suppresses these defaults.
/// </remarks>
public sealed class SequentialWorkflowBuilder : OrchestrationBuilderBase<SequentialWorkflowBuilder>
{
private readonly List<AIAgent> _agents = [];
/// <summary>
/// Initializes a new <see cref="SequentialWorkflowBuilder"/> with the given pipeline
/// of <paramref name="agents"/>.
/// </summary>
public SequentialWorkflowBuilder(params IEnumerable<AIAgent> agents)
{
Throw.IfNull(agents);
foreach (AIAgent agent in agents)
{
Throw.IfNull(agent, nameof(agents));
this._agents.Add(agent);
}
}
/// <summary>Builds the configured sequential workflow.</summary>
public Workflow Build()
{
if (this._agents.Count == 0)
{
throw new ArgumentException("At least one agent must be provided to the SequentialWorkflowBuilder.", "agents");
}
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
List<ExecutorBinding> agentExecutors = new(this._agents.Count);
foreach (AIAgent agent in this._agents)
{
ExecutorBinding binding = agent.BindAsExecutor(options);
agentExecutors.Add(binding);
agentMap[agent] = binding;
}
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
builder.AddEdge(previous, next);
previous = next;
}
OutputMessagesExecutor end = new();
builder.AddEdge(previous, end).BindExecutor(end);
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "sequential", () =>
{
builder.WithOutputFrom(end);
builder.WithIntermediateOutputFrom(agentExecutors);
});
return builder.Build();
}
}
@@ -1,77 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public static partial class AgentWorkflowBuilderTests
{
public class ConcurrentTests
{
[Fact]
public void BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
[Fact]
public async Task BuildConcurrent_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
var workflow = AgentWorkflowBuilder.BuildConcurrent(
[
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
]);
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
// TODO: https://github.com/microsoft/agent-framework/issues/784
// These asserts are flaky until we guarantee message delivery order.
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
Assert.Equal(2, result.Count);
}
}
[Fact]
public void Test_BuildConcurrent_DefaultDesignationsMatchSpec()
{
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"), new DoubleEchoAgent("agent3")]);
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
List<KeyValuePair<string, HashSet<OutputTag>>> terminals = designations
.Where(kvp => kvp.Value.Count == 0)
.ToList();
terminals.Should().ContainSingle("Concurrent has exactly one terminal output executor (ConcurrentEndExecutor)");
List<KeyValuePair<string, HashSet<OutputTag>>> intermediates = designations
.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
.ToList();
intermediates.Should().HaveCount(6,
"every concurrent agent (3) and its per-agent accumulator (3) are designated intermediate");
}
}
}
@@ -1,90 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public static partial class AgentWorkflowBuilderTests
{
public class SequentialTests
{
[Fact]
public void BuildSequential_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents)
{
var workflow = AgentWorkflowBuilder.BuildSequential(
from i in Enumerable.Range(1, numAgents)
select new DoubleEchoAgent($"agent{i}"));
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[numAgents + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < numAgents + 1; i++)
{
string id = $"agent{((i - 1) % numAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
[Fact]
public void Test_BuildSequential_DefaultDesignationsMatchSpec()
{
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
new DoubleEchoAgent("agent1"),
new DoubleEchoAgent("agent2"),
new DoubleEchoAgent("agent3"));
// Defaults: every agent executor is intermediate; exactly one terminal entry (the OutputMessagesExecutor).
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Should().NotBeEmpty();
List<KeyValuePair<string, HashSet<OutputTag>>> terminals = designations
.Where(kvp => kvp.Value.Count == 0)
.ToList();
terminals.Should().ContainSingle("Sequential has exactly one terminal output executor (OutputMessagesExecutor)");
List<KeyValuePair<string, HashSet<OutputTag>>> intermediates = designations
.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
.ToList();
intermediates.Should().HaveCount(3, "every agent in the pipeline is designated intermediate");
}
}
}
@@ -4,132 +4,174 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using FluentAssertions;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Container for tests covering <see cref="AgentWorkflowBuilder"/> entry points that
/// do not produce a dedicated builder type (currently <c>BuildSequential</c> and
/// <c>BuildConcurrent</c>). The actual test methods live in nested classes
/// (<see cref="SequentialTests"/> and <see cref="ConcurrentTests"/>) split across
/// partial files. Shared test helpers — the <c>DoubleEchoAgent</c> family and the
/// <c>RunWorkflow*</c> methods — are declared on this outer partial as
/// <c>internal</c> so the nested test classes and the standalone
/// <see cref="GroupChatWorkflowBuilderTests"/> can all reuse them.
/// Tests targeting the static <see cref="AgentWorkflowBuilder"/> helper surface —
/// <see cref="AgentWorkflowBuilder.BuildSequential(System.Collections.Generic.IEnumerable{AIAgent})"/>,
/// <see cref="AgentWorkflowBuilder.BuildConcurrent(System.Collections.Generic.IEnumerable{AIAgent}, System.Func{System.Collections.Generic.IList{System.Collections.Generic.List{ChatMessage}}, System.Collections.Generic.List{ChatMessage}})"/>,
/// and the various <c>Create*BuilderWith</c> factories. Per-builder unit tests live in their own
/// files (<see cref="SequentialWorkflowBuilderTests"/>, <see cref="ConcurrentWorkflowBuilderTests"/>, etc.).
/// </summary>
public static partial class AgentWorkflowBuilderTests
public class AgentWorkflowBuilderTests
{
internal class DoubleEchoAgent(string name) : AIAgent
[Fact]
public void Test_AgentWorkflowBuilder_BuildSequential_InvalidArguments_Throws()
{
public override string Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var contents = messages.SelectMany(m => m.Contents).ToList();
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
}
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}
internal sealed class DoubleEchoAgentSession() : AgentSession();
internal sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public async Task Test_AgentWorkflowBuilder_BuildSequential_DelegatesToBuilderAsync(int numAgents)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
{
barrier.Value!.SetResult(true);
}
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
from i in Enumerable.Range(1, numAgents)
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"));
await barrier.Value!.Task.ConfigureAwait(false);
// Smoke: end-to-end run produces a non-empty result. Detailed pipeline-ordering
// assertions live in SequentialWorkflowBuilderTests.
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
{
await Task.Yield();
yield return update;
}
}
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.NotEmpty(updateText);
}
internal sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
internal static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
[Fact]
public void Test_AgentWorkflowBuilder_BuildSequential_WithWorkflowNameSetsNameOnWorkflow()
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
"static-sequential",
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"));
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return await ProcessWorkflowRunAsync(run);
workflow.Name.Should().Be("static-sequential");
}
internal static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
[Fact]
public void Test_AgentWorkflowBuilder_BuildConcurrent_InvalidArguments_Throws()
{
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
output = e;
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
break;
case SuperStepCompletedEvent stepCompleted:
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
break;
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
internal static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
}
[Fact]
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_DelegatesToBuilderAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
]);
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
}
[Fact]
public void Test_AgentWorkflowBuilder_BuildConcurrent_WithWorkflowNameSetsNameOnWorkflow()
{
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
"static-concurrent",
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")]);
workflow.Name.Should().Be("static-concurrent");
}
[Fact]
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_AggregatorIsHonoredAsync()
{
// Replace the default ("last message from each agent") with a custom aggregator,
// and confirm the workflow yields its result.
List<ChatMessage> sentinel = [new(ChatRole.Assistant, "custom-aggregator-result")];
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")],
aggregator: _ => sentinel);
(_, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
result.Should().NotBeNull().And.ContainSingle();
result![0].Text.Should().Be("custom-aggregator-result");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_ReturnsConfigurableBuilder()
{
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
SequentialWorkflowBuilder builder = AgentWorkflowBuilder.CreateSequentialBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_ReturnsConfigurableBuilder()
{
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
ConcurrentWorkflowBuilder builder = AgentWorkflowBuilder.CreateConcurrentBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateHandoffBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
#pragma warning restore MAAIW001
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateGroupChatBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateMagenticBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("managerAgent", () => AgentWorkflowBuilder.CreateMagenticBuilderWith(null!));
#pragma warning restore MAAIW001
}
}
@@ -0,0 +1,165 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.UnitTests.Futures;
using Microsoft.Extensions.AI;
using Xunit;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class ConcurrentWorkflowBuilderTests
{
[Fact]
public void Test_ConcurrentWorkflowBuilder_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => new ConcurrentWorkflowBuilder(null!));
Assert.Throws<ArgumentException>("agents", () => new ConcurrentWorkflowBuilder().Build());
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
}
[Fact]
public async Task Test_ConcurrentWorkflowBuilder_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
var workflow = new ConcurrentWorkflowBuilder(
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining))
.Build();
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
// TODO: https://github.com/microsoft/agent-framework/issues/784
// These asserts are flaky until we guarantee message delivery order.
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
Assert.Equal(2, result.Count);
}
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_DefaultDesignationsMatchSpec()
{
Workflow workflow = new ConcurrentWorkflowBuilder(
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent2"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Where(kvp => kvp.Value.Count == 0)
.Should().ContainSingle("ConcurrentEndExecutor is the sole terminal output by default");
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
.Should().HaveCount(6, "every agent (3) and per-agent accumulator (3) is designated intermediate by default");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
{
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
Workflow workflow = new ConcurrentWorkflowBuilder(a1, a2, a3)
.WithOutputFrom(a1)
.WithIntermediateOutputFrom([a2])
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Should().HaveCount(2,
"only the two explicitly-designated agents land on the inner builder; the end + accumulator defaults are suppressed");
designations.Values.Where(tags => tags.Count == 0)
.Should().ContainSingle("agent1 is the only terminal designation");
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
.Should().ContainSingle("agent2 is the only intermediate designation");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_DesignationForNonParticipantThrows()
{
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
ConcurrentWorkflowBuilder builder = new ConcurrentWorkflowBuilder(participant)
.WithIntermediateOutputFrom([stranger]);
Action build = () => builder.Build();
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_WithNamePropagatesToWorkflow()
{
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithName("named-concurrent")
.Build();
workflow.Name.Should().Be("named-concurrent");
}
[Fact]
public void Test_ConcurrentWorkflowBuilder_WithDescriptionPropagatesToWorkflow()
{
Workflow workflow = new ConcurrentWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithDescription("describes the concurrent fan-out/fan-in")
.Build();
workflow.Description.Should().Be("describes the concurrent fan-out/fan-in");
}
[Collection(FuturesSerialCollection.Name)]
public class AsAgentForwarding
{
[Fact]
public async Task Test_ConcurrentWorkflowBuilder_AsAgent_OnlyTerminalDesignationSurfacesAsync()
{
using FuturesScope _ = new(enabled: true);
OrchestrationTestHelpers.DoubleEchoAgent agent1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent agent2 = new("agent2");
// Designate only agent1 as a terminal output source — agent2 and the fan-in
// aggregator default-intermediate designations are suppressed.
Workflow workflow = new ConcurrentWorkflowBuilder(agent1, agent2)
.WithOutputFrom(agent1)
.Build();
List<AgentResponseUpdate> updates = await workflow
.AsAIAgent("WorkflowAgent")
.RunStreamingAsync(new ChatMessage(ChatRole.User, "abc"))
.ToListAsync();
HashSet<string> authoredBy = updates
.Select(u => u.AuthorName)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToHashSet();
authoredBy.Should().Contain("agent1", "the designated agent must surface");
authoredBy.Should().NotContain("agent2",
"the undesignated agent must not surface when only one is designated under Futures-on");
}
}
}
@@ -20,11 +20,11 @@ public class GroupChatWorkflowBuilderTests
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new AgentWorkflowBuilderTests.DoubleEchoAgent("a1")]));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new OrchestrationTestHelpers.DoubleEchoAgent("a1")]));
Assert.NotNull(groupChat);
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
@@ -32,7 +32,7 @@ public class GroupChatWorkflowBuilderTests
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new RoundRobinGroupChatManager([new AgentWorkflowBuilderTests.DoubleEchoAgent("a1")]);
var manager = new RoundRobinGroupChatManager([new OrchestrationTestHelpers.DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
@@ -58,7 +58,7 @@ public class GroupChatWorkflowBuilderTests
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"), new AgentWorkflowBuilderTests.DoubleEchoAgent("agent2"))
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"), new OrchestrationTestHelpers.DoubleEchoAgent("agent2"))
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
@@ -74,7 +74,7 @@ public class GroupChatWorkflowBuilderTests
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"))
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithName(WorkflowName)
.Build();
@@ -87,7 +87,7 @@ public class GroupChatWorkflowBuilderTests
{
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"))
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.Build();
Assert.Null(workflow.Name);
@@ -104,14 +104,14 @@ public class GroupChatWorkflowBuilderTests
{
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"), new AgentWorkflowBuilderTests.DoubleEchoAgent("agent2"))
.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent3"))
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"), new OrchestrationTestHelpers.DoubleEchoAgent("agent2"))
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
.Build();
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _, _) = await AgentWorkflowBuilderTests.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) = await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
@@ -166,9 +166,9 @@ public class GroupChatWorkflowBuilderTests
[Fact]
public void Test_GroupChatWorkflowBuilder_DefaultDesignationsMatchSpec()
{
AgentWorkflowBuilderTests.DoubleEchoAgent a1 = new("agent1");
AgentWorkflowBuilderTests.DoubleEchoAgent a2 = new("agent2");
AgentWorkflowBuilderTests.DoubleEchoAgent a3 = new("agent3");
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
@@ -186,9 +186,9 @@ public class GroupChatWorkflowBuilderTests
[Fact]
public void Test_GroupChatWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
{
AgentWorkflowBuilderTests.DoubleEchoAgent a1 = new("agent1");
AgentWorkflowBuilderTests.DoubleEchoAgent a2 = new("agent2");
AgentWorkflowBuilderTests.DoubleEchoAgent a3 = new("agent3");
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
@@ -210,8 +210,8 @@ public class GroupChatWorkflowBuilderTests
[Fact]
public void Test_GroupChatWorkflowBuilder_DesignationForNonParticipantThrows()
{
AgentWorkflowBuilderTests.DoubleEchoAgent participant = new("p1");
AgentWorkflowBuilderTests.DoubleEchoAgent stranger = new("stranger");
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 })
@@ -19,8 +19,8 @@ public class HandoffWorkflowBuilderTests
[Fact]
public void Test_HandoffWorkflowBuilder_DefaultDesignationsMatchSpec()
{
AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator");
AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist");
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
Workflow workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
@@ -38,8 +38,8 @@ public class HandoffWorkflowBuilderTests
[Fact]
public void Test_HandoffWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
{
AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator");
AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist");
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
Workflow workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
@@ -61,9 +61,9 @@ public class HandoffWorkflowBuilderTests
[Fact]
public void Test_HandoffWorkflowBuilder_DesignationForNonParticipantThrows()
{
AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator");
AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist");
AgentWorkflowBuilderTests.DoubleEchoAgent stranger = new("stranger");
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
HandoffWorkflowBuilder builder = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Container for shared test helpers used by every orchestration-builder test class —
/// the <c>DoubleEchoAgent</c> family and the <c>RunWorkflow*</c> methods. The actual
/// test methods live in per-builder files (<c>SequentialWorkflowBuilderTests</c>,
/// <c>ConcurrentWorkflowBuilderTests</c>, <c>GroupChatWorkflowBuilderTests</c>, etc.).
/// </summary>
public static class OrchestrationTestHelpers
{
internal class DoubleEchoAgent(string name) : AIAgent
{
public override string Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var contents = messages.SelectMany(m => m.Contents).ToList();
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
}
}
internal sealed class DoubleEchoAgentSession() : AgentSession();
internal sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
{
barrier.Value!.SetResult(true);
}
await barrier.Value!.Task.ConfigureAwait(false);
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
{
await Task.Yield();
yield return update;
}
}
}
internal sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
internal static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return await ProcessWorkflowRunAsync(run);
}
internal static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
{
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
output = e;
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
break;
case SuperStepCompletedEvent stepCompleted:
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
break;
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
}
internal static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
}
@@ -0,0 +1,184 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.UnitTests.Futures;
using Microsoft.Extensions.AI;
using Xunit;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class SequentialWorkflowBuilderTests
{
[Fact]
public void Test_SequentialWorkflowBuilder_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => new SequentialWorkflowBuilder(null!));
Assert.Throws<ArgumentException>("agents", () => new SequentialWorkflowBuilder().Build());
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task Test_SequentialWorkflowBuilder_AgentsRunInOrderAsync(int numAgents)
{
var workflow = new SequentialWorkflowBuilder(
from i in Enumerable.Range(1, numAgents)
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"))
.Build();
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[numAgents + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < numAgents + 1; i++)
{
string id = $"agent{((i - 1) % numAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
[Fact]
public void Test_SequentialWorkflowBuilder_DefaultDesignationsMatchSpec()
{
Workflow workflow = new SequentialWorkflowBuilder(
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent2"),
new OrchestrationTestHelpers.DoubleEchoAgent("agent3"))
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Where(kvp => kvp.Value.Count == 0)
.Should().ContainSingle("OutputMessagesExecutor is the sole terminal output by default");
designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate))
.Should().HaveCount(3, "every pipeline agent is designated intermediate by default");
}
[Fact]
public void Test_SequentialWorkflowBuilder_ExplicitDesignationsReplaceDefaults()
{
OrchestrationTestHelpers.DoubleEchoAgent a1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent a2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent a3 = new("agent3");
Workflow workflow = new SequentialWorkflowBuilder(a1, a2, a3)
.WithOutputFrom(a1)
.WithIntermediateOutputFrom([a2])
.Build();
Dictionary<string, HashSet<OutputTag>> designations = workflow.OutputExecutors;
designations.Should().HaveCount(2,
"only the two explicitly-designated agents land on the inner builder; the end default is suppressed");
designations.Values.Where(tags => tags.Count == 0)
.Should().ContainSingle("agent1 is the only terminal designation");
designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate))
.Should().ContainSingle("agent2 is the only intermediate designation");
}
[Fact]
public void Test_SequentialWorkflowBuilder_DesignationForNonParticipantThrows()
{
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
SequentialWorkflowBuilder builder = new SequentialWorkflowBuilder(participant)
.WithIntermediateOutputFrom([stranger]);
Action build = () => builder.Build();
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
}
[Fact]
public void Test_SequentialWorkflowBuilder_WithNamePropagatesToWorkflow()
{
Workflow workflow = new SequentialWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithName("named-sequential")
.Build();
workflow.Name.Should().Be("named-sequential");
}
[Fact]
public void Test_SequentialWorkflowBuilder_WithDescriptionPropagatesToWorkflow()
{
Workflow workflow = new SequentialWorkflowBuilder(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithDescription("describes the sequential pipeline")
.Build();
workflow.Description.Should().Be("describes the sequential pipeline");
}
[Collection(FuturesSerialCollection.Name)]
public class AsAgentForwarding
{
[Fact]
public async Task Test_SequentialWorkflowBuilder_AsAgent_OnlyTerminalDesignationSurfacesAsync()
{
using FuturesScope _ = new(enabled: true);
OrchestrationTestHelpers.DoubleEchoAgent agent1 = new("agent1");
OrchestrationTestHelpers.DoubleEchoAgent agent2 = new("agent2");
OrchestrationTestHelpers.DoubleEchoAgent agent3 = new("agent3");
// Explicitly designate ONLY the last agent — defaults (which would tag every agent
// intermediate) are suppressed, so under Futures-on, agent1/agent2 produce no
// AgentResponse(Update)Events and nothing of theirs reaches the AsAgent stream.
Workflow workflow = new SequentialWorkflowBuilder(agent1, agent2, agent3)
.WithOutputFrom(agent3)
.Build();
List<AgentResponseUpdate> updates = await workflow
.AsAIAgent("WorkflowAgent")
.RunStreamingAsync(new ChatMessage(ChatRole.User, "abc"))
.ToListAsync();
// Filter by AuthorName — distinguishes which agent originated each update
// (text-content checks are unreliable because agent3 echoes earlier agents' markers
// as part of the cumulative pipeline payload).
HashSet<string> authoredBy = updates
.Select(u => u.AuthorName)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToHashSet();
authoredBy.Should().Contain("agent3", "the terminal agent must surface");
authoredBy.Should().NotContain("agent1",
"the intermediate agent must not surface when only the terminal is designated");
authoredBy.Should().NotContain("agent2",
"the intermediate agent must not surface when only the terminal is designated");
}
}
}