mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
Jacob Alber
Unverified
parent
508aeefb6b
commit
3baf527909
@@ -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}<agent_id>`,
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
-77
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-90
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
-106
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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());
|
||||
}
|
||||
+184
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user