From 6f31e32df60ebf444922b443561d62158dff220f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 22 May 2026 13:39:48 -0400 Subject: [PATCH] feat: tag-aware defaults and designation API on orchestration builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the .NET orchestration builders with Python's output / intermediate-output distinction. Each builder either applies a Python-aligned default designation set or replays the user's explicit `WithOutputFrom` / `WithIntermediateOutputFrom` calls, never both. Static `AgentWorkflowBuilder.BuildSequential` / `BuildConcurrent` apply defaults unconditionally (no user-facing fluent surface to take control through): - Sequential: terminal `end` + every agent designated intermediate. - Concurrent: terminal `end` + every agent and per-agent accumulator designated intermediate. The three fluent instance builders memoize agent-typed designation calls in a `Dictionary>` (empty set = terminal-only, non-empty = intermediate tag(s)) so repeated calls dedupe naturally. They replay the entries at `Build()` time, suppressing defaults when any call has been made: - `HandoffWorkflowBuilder` / `HandoffWorkflowBuilderCore` (also picked up by the obsolete `HandoffsWorkflowBuilder` via inheritance). Default: terminal `HandoffEnd` + every handoff agent intermediate. (Bug fix: legacy code relied on `WithOutputFrom(end)` to bind `HandoffEnd`. The new explicit-designation path bypasses that, so `Build()` now calls `BindExecutor(end)` unconditionally to keep validation happy.) - `GroupChatWorkflowBuilder` — default: terminal host + every participant intermediate. - `MagenticWorkflowBuilder` — default: terminal orchestrator + every team member intermediate. Designating a non-participant agent throws `InvalidOperationException`. The bare `WorkflowBuilder` default is unchanged — only the orchestration-style builders gain implicit defaults, matching the plan's non-goal. Tests ----- - `AgentWorkflowBuilder.SequentialTests` / `.ConcurrentTests`: one default-spec assertion each. - `GroupChatWorkflowBuilderTests`: defaults-match-spec, explicit-replaces-defaults, non-participant throws. - `HandoffWorkflowBuilderTests` (new file): same three. - `MagenticWorkflowBuilderTests` (new file): same three. 593/593 unit tests pass on net10.0 (582 baseline + 11 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentWorkflowBuilder.cs | 7 +- .../GroupChatWorkflowBuilder.cs | 87 ++++++++++++++- .../HandoffWorkflowBuilder.cs | 101 +++++++++++++++++- .../MagenticWorkflowBuilder.cs | 91 +++++++++++++++- .../AgentWorkflowBuilder.ConcurrentTests.cs | 22 ++++ .../AgentWorkflowBuilder.SequentialTests.cs | 24 +++++ .../GroupChatWorkflowBuilderTests.cs | 60 +++++++++++ .../HandoffWorkflowBuilderTests.cs | 77 +++++++++++++ .../MagenticWorkflowBuilderTests.cs | 79 ++++++++++++++ 9 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffWorkflowBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticWorkflowBuilderTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 9d7aa5b8c7..20eb6d903b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -58,7 +58,9 @@ public static partial class AgentWorkflowBuilder } OutputMessagesExecutor end = new(); - builder = builder.AddEdge(previous, end).WithOutputFrom(end); + builder = builder.AddEdge(previous, end) + .WithOutputFrom(end) + .WithIntermediateOutputFrom(agentExecutors); if (workflowName is not null) { builder = builder.WithName(workflowName); @@ -138,7 +140,8 @@ public static partial class AgentWorkflowBuilder builder.AddFanInBarrierEdge(accumulators, end); - builder = builder.WithOutputFrom(end); + builder = builder.WithOutputFrom(end) + .WithIntermediateOutputFrom([.. agentExecutors, .. accumulators]); if (workflowName is not null) { builder = builder.WithName(workflowName); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs index cb922616bb..253377137f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -19,6 +19,8 @@ public sealed class GroupChatWorkflowBuilder private string _name = string.Empty; private string _description = string.Empty; + private Dictionary>? _outputDesignations; + internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => this._managerFactory = managerFactory; @@ -66,6 +68,48 @@ public sealed class GroupChatWorkflowBuilder return this; } + /// + /// Designates the given as sources of terminal workflow output. + /// Calling any output-designation method (this or ) + /// suppresses the orchestration-specific defaults: only the user-specified designations + /// reach the inner . + /// + public GroupChatWorkflowBuilder WithOutputFrom(params IEnumerable 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; + } + + /// + /// Designates the given as sources of intermediate workflow output. + /// See for the defaults-suppression semantics. + /// + public GroupChatWorkflowBuilder WithIntermediateOutputFrom(IEnumerable 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? tags)) + { + tags = []; + this._outputDesignations[agent] = tags; + } + tags.Add(OutputTag.Intermediate); + } + return this; + } + /// /// Builds a composed of agents that operate via group chat, with the next /// agent to process messages selected by the group chat manager. @@ -110,6 +154,47 @@ public sealed class GroupChatWorkflowBuilder .AddEdge(participant, host); } - return builder.WithOutputFrom(host).Build(); + this.ApplyOutputDesignations(builder, host, agentMap); + return builder.Build(); + } + + private void ApplyOutputDesignations( + WorkflowBuilder builder, + ExecutorBinding host, + Dictionary agentMap) + { + if (this._outputDesignations is null) + { + // 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, HashSet tags) in this._outputDesignations) + { + 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."); + } + + if (tags.Count == 0) + { + builder.WithOutputFrom(binding); + } + else + { + foreach (OutputTag tag in tags) + { + builder.WithOutputFrom(binding, tag); + } + } + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index e5b08ce928..fe09a6beba 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -74,6 +74,16 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl // if true, the workflow ends (and the autonomous loop, if any, terminates). private Func, ValueTask>? _terminationCondition; + /// + /// Memoized output designations. means the user has not made any + /// explicit designation, and the orchestration-specific defaults will be applied at + /// time. A non-null (possibly empty) dictionary means the user took + /// control and only these designations will be replayed onto the inner + /// . An entry's value is the set of tags requested for the + /// agent — an empty set encodes a terminal-only designation. + /// + private Dictionary>? _outputDesignations; + /// /// Initializes a new instance of the class with no handoff relationships. /// @@ -175,6 +185,48 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } + /// + /// Designates the given as sources of terminal workflow output. + /// Calling any output-designation method (this or ) + /// suppresses the orchestration-specific defaults: only the user-specified designations + /// reach the inner . To restore defaults, build a fresh builder. + /// + public TBuilder WithOutputFrom(params IEnumerable 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; + } + + /// + /// Designates the given as sources of intermediate workflow + /// output. See for the defaults-suppression semantics. + /// + public TBuilder WithIntermediateOutputFrom(IEnumerable 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? tags)) + { + tags = []; + this._outputDesignations[agent] = tags; + } + tags.Add(OutputTag.Intermediate); + } + return (TBuilder)this; + } + /// /// Adds handoff relationships from a source agent to one or more target agents. /// @@ -641,6 +693,53 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.WithDescription(this._description); } - return builder.WithOutputFrom(end).Build(); + // 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(); + } + + private void ApplyOutputDesignations( + WorkflowBuilder builder, + HandoffEndExecutor end, + Dictionary executors) + { + if (this._outputDesignations is null) + { + // Defaults (matches Python's Handoff orchestration): + // end -> terminal output (Output) + // every handoff agent -> intermediate output (Intermediate) + builder.WithOutputFrom(end); + List agentBindings = [.. executors.Values]; + if (agentBindings.Count > 0) + { + builder.WithIntermediateOutputFrom(agentBindings); + } + return; + } + + // User took control — replay only their designations, in dictionary order. + foreach ((AIAgent agent, HashSet tags) in this._outputDesignations) + { + 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."); + } + + if (tags.Count == 0) + { + builder.WithOutputFrom(binding); + } + else + { + foreach (OutputTag tag in tags) + { + builder.WithOutputFrom(binding, tag); + } + } + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs index 4470c4ee9a..24926ceebb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MagenticWorkflowBuilder.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Specialized.Magentic; +using Microsoft.Shared.Diagnostics; using ExecutorFactoryFunc = System.Func, string, @@ -38,6 +39,8 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) private int? _maxResets; private bool _requirePlanSignoff = true; + private Dictionary>? _outputDesignations; + /// public MagenticWorkflowBuilder AddParticipants(params IEnumerable agents) { @@ -100,6 +103,48 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) return this; } + /// + /// Designates the given as sources of terminal workflow output. + /// Calling any output-designation method (this or ) + /// suppresses the orchestration-specific defaults: only the user-specified designations + /// reach the inner . + /// + public MagenticWorkflowBuilder WithOutputFrom(params IEnumerable 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; + } + + /// + /// Designates the given as sources of intermediate workflow output. + /// See for the defaults-suppression semantics. + /// + public MagenticWorkflowBuilder WithIntermediateOutputFrom(IEnumerable 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? 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 @@ -115,17 +160,19 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) ForwardIncomingMessages = false }; + Dictionary teamMap = new(AIAgentIDEqualityComparer.Instance); List teamBindings = []; foreach (AIAgent agent in team) { ExecutorBinding binding = agent.BindAsExecutor(options); teamBindings.Add(binding); + teamMap[agent] = binding; result.AddEdge(binding, orchestrator); } - result.AddFanOutEdge(orchestrator, teamBindings) - .WithOutputFrom(orchestrator); + result.AddFanOutEdge(orchestrator, teamBindings); + this.ApplyOutputDesignations(result, orchestrator, teamMap); if (!string.IsNullOrWhiteSpace(this._name)) { @@ -140,6 +187,46 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent) return result; } + private void ApplyOutputDesignations( + WorkflowBuilder builder, + ExecutorBinding orchestrator, + Dictionary teamMap) + { + if (this._outputDesignations is null) + { + // Defaults (matches Python Magentic orchestration): + // orchestrator -> terminal output + // team members -> intermediate output + builder.WithOutputFrom(orchestrator); + if (teamMap.Count > 0) + { + builder.WithIntermediateOutputFrom([.. teamMap.Values]); + } + return; + } + + foreach ((AIAgent agent, HashSet tags) in this._outputDesignations) + { + 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."); + } + + if (tags.Count == 0) + { + builder.WithOutputFrom(binding); + } + else + { + foreach (OutputTag tag in tags) + { + builder.WithOutputFrom(binding, tag); + } + } + } + } + /// public Workflow Build() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs index 863ccede63..eeb078cb0c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs @@ -2,9 +2,11 @@ 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 @@ -51,5 +53,25 @@ public static partial class AgentWorkflowBuilderTests 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> designations = workflow.OutputExecutors; + + List>> terminals = designations + .Where(kvp => kvp.Value.Count == 0) + .ToList(); + terminals.Should().ContainSingle("Concurrent has exactly one terminal output executor (ConcurrentEndExecutor)"); + + List>> 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"); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs index ae467a1352..773acfefdf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs @@ -4,6 +4,7 @@ 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; @@ -62,5 +63,28 @@ public static partial class AgentWorkflowBuilderTests 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> designations = workflow.OutputExecutors; + designations.Should().NotBeEmpty(); + + List>> terminals = designations + .Where(kvp => kvp.Value.Count == 0) + .ToList(); + terminals.Should().ContainSingle("Sequential has exactly one terminal output executor (OutputMessagesExecutor)"); + + List>> intermediates = designations + .Where(kvp => kvp.Value.Contains(OutputTag.Intermediate)) + .ToList(); + intermediates.Should().HaveCount(3, "every agent in the pipeline is designated intermediate"); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs index 3e201e89e6..1112bbe552 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; @@ -162,6 +163,65 @@ public class GroupChatWorkflowBuilderTests } } + [Fact] + public void Test_GroupChatWorkflowBuilder_DefaultDesignationsMatchSpec() + { + AgentWorkflowBuilderTests.DoubleEchoAgent a1 = new("agent1"); + AgentWorkflowBuilderTests.DoubleEchoAgent a2 = new("agent2"); + AgentWorkflowBuilderTests.DoubleEchoAgent a3 = new("agent3"); + + Workflow workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 }) + .AddParticipants(a1, a2, a3) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Where(kvp => kvp.Value.Count == 0) + .Should().ContainSingle("group-chat host is the sole terminal output executor by default"); + designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate)) + .Should().HaveCount(3, "every participant is designated intermediate by default"); + } + + [Fact] + public void Test_GroupChatWorkflowBuilder_ExplicitDesignationsReplaceDefaults() + { + AgentWorkflowBuilderTests.DoubleEchoAgent a1 = new("agent1"); + AgentWorkflowBuilderTests.DoubleEchoAgent a2 = new("agent2"); + AgentWorkflowBuilderTests.DoubleEchoAgent a3 = new("agent3"); + + Workflow workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 }) + .AddParticipants(a1, a2, a3) + .WithOutputFrom(a1) + .WithIntermediateOutputFrom([a2]) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Should().HaveCount(2, + "only the two explicitly-designated agents land on the inner builder; the host 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_GroupChatWorkflowBuilder_DesignationForNonParticipantThrows() + { + AgentWorkflowBuilderTests.DoubleEchoAgent participant = new("p1"); + AgentWorkflowBuilderTests.DoubleEchoAgent stranger = new("stranger"); + + GroupChatWorkflowBuilder builder = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 1 }) + .AddParticipants(participant) + .WithOutputFrom(stranger); + + Action build = () => builder.Build(); + build.Should().Throw().WithMessage("*stranger*"); + } + private sealed class RecordingAgent(string name) : AIAgent { public List> Invocations { get; } = []; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffWorkflowBuilderTests.cs new file mode 100644 index 0000000000..92f4aa633d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffWorkflowBuilderTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Tests focused on 's output-designation surface — +/// the Python-aligned defaults applied at +/// when the user has not made explicit designations, and the memoized +/// WithOutputFrom / WithIntermediateOutputFrom replay otherwise. +/// +#pragma warning disable MAAIW001 // Experimental: HandoffWorkflowBuilder +public class HandoffWorkflowBuilderTests +{ + [Fact] + public void Test_HandoffWorkflowBuilder_DefaultDesignationsMatchSpec() + { + AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator"); + AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist"); + + Workflow workflow = AgentWorkflowBuilder + .CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Where(kvp => kvp.Value.Count == 0) + .Should().ContainSingle("the handoff end executor is the sole terminal output by default"); + designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate)) + .Should().HaveCount(2, "both the coordinator and the specialist are designated intermediate by default"); + } + + [Fact] + public void Test_HandoffWorkflowBuilder_ExplicitDesignationsReplaceDefaults() + { + AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator"); + AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist"); + + Workflow workflow = AgentWorkflowBuilder + .CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithOutputFrom(coordinator) + .WithIntermediateOutputFrom([specialist]) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Should().HaveCount(2, + "only the user-specified designations land on the inner builder; the handoff-end default is suppressed"); + designations.Values.Where(tags => tags.Count == 0) + .Should().ContainSingle("coordinator is the only terminal designation"); + designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate)) + .Should().ContainSingle("specialist is the only intermediate designation"); + } + + [Fact] + public void Test_HandoffWorkflowBuilder_DesignationForNonParticipantThrows() + { + AgentWorkflowBuilderTests.DoubleEchoAgent coordinator = new("coordinator"); + AgentWorkflowBuilderTests.DoubleEchoAgent specialist = new("specialist"); + AgentWorkflowBuilderTests.DoubleEchoAgent stranger = new("stranger"); + + HandoffWorkflowBuilder builder = AgentWorkflowBuilder + .CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithIntermediateOutputFrom([stranger]); + + Action build = () => builder.Build(); + build.Should().Throw().WithMessage("*stranger*"); + } +} +#pragma warning restore MAAIW001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticWorkflowBuilderTests.cs new file mode 100644 index 0000000000..1b83708aef --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticWorkflowBuilderTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Tests focused on 's output-designation surface — +/// the Python-aligned defaults applied at when +/// the user has not made explicit designations, and the memoized +/// WithOutputFrom / WithIntermediateOutputFrom replay otherwise. +/// +#pragma warning disable MAAIW001 // Experimental: MagenticWorkflowBuilder +public class MagenticWorkflowBuilderTests +{ + [Fact] + public void Test_MagenticWorkflowBuilder_DefaultDesignationsMatchSpec() + { + TestReplayAgent manager = new(name: "Manager"); + TestEchoAgent member1 = new(name: "Worker1"); + TestEchoAgent member2 = new(name: "Worker2"); + + Workflow workflow = new MagenticWorkflowBuilder(manager) + .AddParticipants(member1, member2) + .RequirePlanSignoff(false) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Where(kvp => kvp.Value.Count == 0) + .Should().ContainSingle("the Magentic orchestrator is the sole terminal output by default"); + designations.Where(kvp => kvp.Value.Contains(OutputTag.Intermediate)) + .Should().HaveCount(2, "every team member is designated intermediate by default"); + } + + [Fact] + public void Test_MagenticWorkflowBuilder_ExplicitDesignationsReplaceDefaults() + { + TestReplayAgent manager = new(name: "Manager"); + TestEchoAgent member1 = new(name: "Worker1"); + TestEchoAgent member2 = new(name: "Worker2"); + + Workflow workflow = new MagenticWorkflowBuilder(manager) + .AddParticipants(member1, member2) + .RequirePlanSignoff(false) + .WithOutputFrom(member1) + .WithIntermediateOutputFrom([member2]) + .Build(); + + Dictionary> designations = workflow.OutputExecutors; + + designations.Should().HaveCount(2, + "only the user-specified designations land on the inner builder; the orchestrator default is suppressed"); + designations.Values.Where(tags => tags.Count == 0) + .Should().ContainSingle("member1 is the only terminal designation"); + designations.Values.Where(tags => tags.Contains(OutputTag.Intermediate)) + .Should().ContainSingle("member2 is the only intermediate designation"); + } + + [Fact] + public void Test_MagenticWorkflowBuilder_DesignationForNonParticipantThrows() + { + TestReplayAgent manager = new(name: "Manager"); + TestEchoAgent member = new(name: "Worker"); + TestEchoAgent stranger = new(name: "Stranger"); + + MagenticWorkflowBuilder builder = new MagenticWorkflowBuilder(manager) + .AddParticipants(member) + .RequirePlanSignoff(false) + .WithIntermediateOutputFrom([stranger]); + + Action build = () => builder.Build(); + build.Should().Throw().WithMessage("*Stranger*"); + } +} +#pragma warning restore MAAIW001