.NET: Workflow Outputs Overhaul: Support Tagging, Filtering Agent Outputs (#6045)

* test: reshuffle .NET Workflow tests in preparation for Outputs overhaul

Phase 1 of the .NET Workflows outputs overhaul (see
working/implementation-plan.md). Pure moves/renames in
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests; no production code
changes, no new test cases. The split keeps each orchestration mode in
its own source file so the upcoming tag-aware and orchestration-default
test additions land on clean diffs.

Renames:
* WorkflowBuilderSmokeTests.cs -> WorkflowBuilderTests.cs (with class
  rename to match). The scope is no longer "smoke"-only once subsequent
  phases add tag-aware builder tests.
* InputWaiterAndOutputFilterTests.cs -> InputWaiterTests.cs +
  OutputFilterTests.cs. The file already declared the two test classes
  separately; this split simply gives each its own file so the
  output-filter cases have a dedicated home for tag-aware additions.

Split of AgentWorkflowBuilderTests.cs:
* AgentWorkflowBuilderTests.cs is now the outer
  `public static partial class AgentWorkflowBuilderTests` holding the
  shared test helpers (DoubleEchoAgent + session + WithBarrier variant,
  WorkflowRunResult, RunWorkflow* methods) bumped from `private` to
  `internal` so the new top-level GroupChatWorkflowBuilderTests in the
  same assembly can reach them.
* AgentWorkflowBuilder.SequentialTests.cs (nested SequentialTests):
  BuildSequential_InvalidArguments_Throws,
  BuildSequential_AgentsRunInOrderAsync.
* AgentWorkflowBuilder.ConcurrentTests.cs (nested ConcurrentTests):
  BuildConcurrent_InvalidArguments_Throws,
  BuildConcurrent_AgentsRunInParallelAsync.

Sequential and Concurrent are kept as nested classes because they're
modes of the same `AgentWorkflowBuilder` static factory and do not
produce dedicated builder types.

New file:
* GroupChatWorkflowBuilderTests.cs (top-level): the existing
  BuildGroupChat_* and GroupChatManager_* cases moved out of the old
  AgentWorkflowBuilderTests file. They exercise the
  `GroupChatWorkflowBuilder` type (returned by
  `AgentWorkflowBuilder.CreateGroupChatBuilderWith`), so a dedicated
  top-level test class - matching the convention reserved by the plan
  for HandoffWorkflowBuilderTests / MagenticWorkflowBuilderTests - is
  the right home. Cross-class helper references qualify with
  `AgentWorkflowBuilderTests.DoubleEchoAgent` and
  `AgentWorkflowBuilderTests.RunWorkflowAsync`.

The outer partial class is `static` (and nested classes carry the
instance test methods) because the outer holds only static helpers;
this satisfies CA1052 without suppressions and is invisible to xUnit
discovery, which finds tests on the nested classes as
`AgentWorkflowBuilderTests.SequentialTests.*` etc.

Validation: `dotnet build` clean on both target frameworks; all 547
tests in Microsoft.Agents.AI.Workflows.UnitTests pass on net10.0.

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

* feat: introduce OutputTag, Futures, and tag-aware WorkflowBuilder API

Phase 2 of the .NET Workflows outputs overhaul. Additive code change
only - no observable runtime behavior change. The runner still uses the
legacy bypass for AgentResponse / AgentResponseUpdate payloads, and the
new `Futures.EnableAgentResponseOutputTaggingAndFiltering` flag defaults
to false. Phase 3 will wire the flag into the runner; this commit only
introduces the types and the builder API.

New public surface:
* `OutputTag` (readonly struct): wraps a string Value with ordinal
  equality (IEquatable, GetHashCode, == / !=) so it can participate as a
  HashSet element. Internal ctor closes the set. One public singleton:
  `OutputTag.Intermediate`. Terminal / regular outputs carry no tag
  (empty Tags set). JSON-serialized as a bare string via
  [JsonConverter(typeof(OutputTagJsonConverter))], with the converter
  rehydrating to the well-known singleton on read.
* `Futures` (static class): hosts opt-in pre-GA behavior switches.
  First flag is `EnableAgentResponseOutputTaggingAndFiltering`; XML doc
  captures the v2.0.0 obsoletion / v3.0.0 removal lifecycle.
* `WorkflowOutputEvent.Tags`: `HashSet<OutputTag>` exposed directly
  (concrete collection, matches the JSON-serialization convention used
  for `WorkflowInfo.OutputExecutorIds`). Never null; empty for legacy /
  terminal events. New ctors take a single `OutputTag` or
  `IEnumerable<OutputTag>?`; the existing (data, executorId) ctor
  remains and produces an untagged event. `HasTag(OutputTag)` helper.
  `AgentResponseEvent` and `AgentResponseUpdateEvent` gain matching
  tag-accepting ctors forwarding to the base.
* `WorkflowOutputEventExtensions.IsIntermediate(this WorkflowOutputEvent)`:
  extension method returning `evt.HasTag(OutputTag.Intermediate)`. The
  preferred way to ask "is this an intermediate output?" without
  reaching into the Tags set.
* `WorkflowBuilder.WithOutputFrom(IEnumerable<ExecutorBinding>, OutputTag)`
  and `WorkflowBuilder.WithOutputFrom(ExecutorBinding, OutputTag)`:
  forward-looking tagged overloads. The IEnumerable form is the primary
  tagged surface; the single-executor form is a convenience for the
  common one-executor case. Currently usable for the
  `OutputTag.Intermediate` singleton; will become the primary surface
  once the `OutputTag` constructor is opened to user-defined tags in
  a future release. Callers in this release should prefer the
  intent-specific `WithIntermediateOutputFrom` extension for the
  intermediate case. Tags accumulate across repeated calls; same tag
  repeated dedupes via the HashSet.
* `WorkflowBuilderExtensions.WithIntermediateOutputFrom(this WorkflowBuilder, IEnumerable<ExecutorBinding>)`:
  helper that forwards to `WithOutputFrom(executors, OutputTag.Intermediate)`.
  Takes an IEnumerable (matching the tagged WithOutputFrom shape) -
  callers pass collection literals: `builder.WithIntermediateOutputFrom([a, b])`.
  XML doc remarks call out the Futures-flag interaction and the
  AIAgent-payload forwarding contract.

Internal shape changes:
* `WorkflowBuilder._outputExecutors`: HashSet<string> -> Dictionary<
  string, HashSet<OutputTag>>. The value set is empty for executors
  designated only via the untagged WithOutputFrom; contains Intermediate
  (and possibly future tags) otherwise.
* `Workflow.OutputExecutors`: HashSet<string> -> Dictionary<string,
  HashSet<OutputTag>>.
* `OutputFilter.CanOutput`: `Contains(id)` -> `ContainsKey(id)`.
* `WorkflowInfo.OutputExecutorIds`: HashSet<string> -> Dictionary<
  string, HashSet<OutputTag>>, with a custom JsonConverter that reads
  both the new map shape (`{id: ["intermediate", ...]}`) and the legacy
  array shape (`[id1, id2]`, where each id is treated as an untagged
  output). Always writes the map shape. IsMatch updated to compare
  per-id tag sets.

Tests landing in this commit (per the test-with-feature principle):
* `OutputTagTests.cs` (6 tests): KnownValues, EqualityIsOrdinalOnValue,
  DefaultStructValueIsDistinct (default(OutputTag) does not collide
  with the Intermediate singleton in a HashSet),
  GetHashCodeMatchesEquals, JsonConverter_RoundtripsValueAsString,
  ConstructorIsInternal (reflection-based assertion that the (string)
  ctor is `internal`).
* `WorkflowBuilderTests.cs` adds 7 new tests pinning the builder
  API contract: RegistersWithEmptyTagSet, AddsIntermediateTag,
  MultipleExecutorsAllUntagged, ThenIntermediate_AccumulatesTags,
  RepeatedDedupes, OnlyRegistersWithoutPriorWithOutputFrom,
  TracksExecutorBinding.
* `BackwardsCompatibility/JsonCheckpointSerializationTests.cs`
  (new folder + file, 5 tests): event-level ctor contract tests
  (single-tag, no-tag, multi-tag — the last with a custom tag);
  IsIntermediate() asserted; load-bearing JSON BC tests for
  `WorkflowInfo.OutputExecutorIds` -
  `WorkflowOutputExecutorsReadsLegacyArrayShape` (legacy ids map to
  empty tag sets) and `WorkflowOutputExecutorsWritesMapShape`.

The plan's three JSON round-trip tests for `WorkflowOutputEvent.Tags`
were dropped: `WorkflowEvent` is not currently a serialized checkpoint
shape (see the comment in WorkflowsJsonUtilities.cs about events not
being persisted), so there is no real back-compat surface to pin
through JSON. They are substituted with in-process ctor/property
round-trip tests that exercise the `Tags` / `HasTag` / `IsIntermediate`
contract.

Validation: full `Microsoft.Agents.AI.Workflows.UnitTests` suite runs
green on net10.0 (565 passing, 0 failing). Core library builds clean
on net472, netstandard2.0, net8.0, net9.0, and net10.0. Test project
builds clean on net472 + net10.0.

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

* feat: route AgentResponse(Update) through the output filter under a Futures flag

`InProcessRunnerContext.YieldOutputAsync` historically special-cased AgentResponse and
AgentResponseUpdate payloads: it built the typed event subclass and emitted it directly,
bypassing the output filter. Rewrites the method so that:

- When `Futures.EnableAgentResponseOutputTaggingAndFiltering` is `false` (the current
  default), AgentResponse(Update) keep the legacy bypass — emitted as
  AgentResponseEvent / AgentResponseUpdateEvent with no tags. Existing callers see no
  behavior change.
- When the flag is `true`, AIAgent payloads flow through the output filter just like
  every other payload type: undesignated sources are dropped, and the emitted event
  carries the source's tag set (empty for terminal `WithOutputFrom`, `{Intermediate}`
  for `WithIntermediateOutputFrom`, the set union when both designations apply).

Non-AIAgent (POCO) outputs also now carry the source's tag set on the emitted
WorkflowOutputEvent unconditionally — additive, since no existing assertion inspected
Tags. Subclass events (`AgentResponseEvent` / `AgentResponseUpdateEvent`) continue to
be emitted under both modes so `switch (evt) { case AgentResponseEvent: ... }`
consumer code keeps matching.

Adds `OutputFilter.TryGetTags` as the tag-aware lookup used by the runner.
`OutputFilter.CanOutput` is kept (still used by the existing sync tests in
`OutputFilterTests.cs`).

Tests
-----
- `Futures/Futures.AgentResponseOutputFilteringAndTaggingTests.cs` (new): the F1–F13
  matrix from the plan, covering every combination of `(flag on/off) × (designation)
  × (payload shape)`. Uses a `FuturesScope` IDisposable + a `FuturesSerial` xUnit
  collection (DisableParallelization = true) to keep the process-global flag from
  leaking across parallel tests.
- `OutputFilterTests.cs`: four new `Test_OutputFilter_…` cases for the `TryGetTags`
  surface (empty-tag-set for terminal designation, `{Intermediate}` for intermediate
  designation, union for accumulated designation, `false` for unregistered).

582/582 unit tests pass on net10.0 (565 baseline + 17 new).

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

* feat: tag-aware defaults and designation API on orchestration builders

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<AIAgent, HashSet<OutputTag>>` (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<TBuilder>` (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>

* feat: WorkflowHostAgent forwards AgentResponseEvent unconditionally under Futures-on

Aligns the .NET Workflow-as-Agent surface with Python `as_agent`. Under
`Futures.EnableAgentResponseOutputTaggingAndFiltering = true`,
`WorkflowSession.InvokeStageAsync` now forwards `AgentResponseEvent`
unconditionally — joining `AgentResponseUpdateEvent` in ignoring the host's
`includeWorkflowOutputsInResponse` switch. That switch keeps governing the
generic `WorkflowOutputEvent` path for non-AIAgent payloads, where it is
further short-circuited by an `IsIntermediate()` check (tagged intermediate
outputs always surface).

Under Futures-off the legacy asymmetry is preserved: `AgentResponseUpdateEvent`
always forwarded, `AgentResponseEvent` gated by `includeWorkflowOutputsInResponse`.

Back-compat: with `Futures.EnableAgentResponseOutputTaggingAndFiltering` left at
its default `false`, observable behavior is identical to before.

`Futures` documentation gains a remark explaining the `Workflow.AsAIAgent()`
interaction in both flag states.

Runner fix
----------
`InProcessRunnerContext.YieldOutputAsync` now skips `Executor.CanOutput` for
AgentResponse-shaped payloads under both Futures branches. `AIAgentHostExecutor`
doesn't declare AgentResponse(Update) in its `Yields` set, so the historical
legacy bypass had silently skipped the check; Phase 3's Futures-on path was
running it and would reject AIAgent payloads. AIAgent-shaped payloads are now
always a valid output shape, matching the legacy bypass semantics.

Phase 4 follow-on
-----------------
Switched the three orchestration-builder designation-replay loops to iterate
`Dictionary.Keys` with a value lookup instead of constructing/destructuring
`KeyValuePair<,>`. Cleaner shape and avoids the netstandard2.0 / net472
`KeyValuePair<,>.Deconstruct` unavailability that surfaced when this branch
multi-TFM-built.

Tests
-----
`WorkflowHostSmokeTests.IntermediateForwarding` (new nested class, 6 tests):
- intermediate AgentResponse forwarded past the include-outputs gate (Futures on)
- terminal AgentResponse forwarded unconditionally (Futures on)
- terminal AgentResponse gated by include flag (Futures off, legacy)
- undesignated AIAgent executor emits no AgentResponseEvent under Futures-on
- legacy bypass still emits AgentResponseEvent under Futures-off
- intermediate tag is observable via `update.RawRepresentation`

The class joins the `FuturesSerial` xUnit collection so the process-global flag
is serialized against other Futures-toggling tests.

599/599 unit tests pass on net10.0 (593 baseline + 6 new).

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

* 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>

* chore: dotnet format

* fixup: encoding

* fixup: charset

* fixup: Updates for PR feedback

* fixup: format

* fixup: merge issue

* Fix intermediate filtering on .AsAgent()

* fix filter logic

* fix: Revert logic change and add comments

---------

Co-authored-by: Jacob Alber <jalber@lokitoth.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jacob Alber
2026-05-28 17:26:31 -04:00
committed by GitHub
Unverified
parent b000a2cf51
commit 8ed2159c4b
40 changed files with 3070 additions and 786 deletions
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -19,6 +20,28 @@ public sealed class AgentResponseEvent : WorkflowOutputEvent
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tag.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
/// <param name="tag">The output tag to associate with this event.</param>
public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag)
{
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseEvent"/> class with the given output tags.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="response">The agent response.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable<OutputTag>? tags) : base(response, executorId, tags)
{
this.Response = Throw.IfNull(response);
}
/// <summary>
/// Gets the agent response.
/// </summary>
@@ -20,6 +20,28 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tag.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
/// <param name="tag">The output tag to associate with this event.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag)
{
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentResponseUpdateEvent"/> class with the given output tags.
/// </summary>
/// <param name="executorId">The identifier of the executor that generated this event.</param>
/// <param name="update">The agent run response update.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty.</param>
public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable<OutputTag>? tags) : base(update, executorId, tags)
{
this.Update = Throw.IfNull(update);
}
/// <summary>
/// Gets the agent run response update.
/// </summary>
@@ -3,9 +3,6 @@
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,31 +34,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);
SequentialWorkflowBuilder builder = new(agents);
if (workflowName is not null)
{
builder = builder.WithName(workflowName);
builder.WithName(workflowName);
}
return builder.Build();
}
@@ -107,41 +83,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);
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();
}
@@ -179,4 +128,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);
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
@@ -15,14 +16,14 @@ internal sealed class WorkflowInfo
Dictionary<string, List<EdgeInfo>> edges,
HashSet<RequestPortInfo> requestPorts,
string startExecutorId,
HashSet<string>? outputExecutorIds)
Dictionary<string, HashSet<OutputTag>>? outputExecutorIds)
{
this.Executors = Throw.IfNull(executors);
this.Edges = Throw.IfNull(edges);
this.RequestPorts = Throw.IfNull(requestPorts);
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
this.OutputExecutorIds = outputExecutorIds ?? [];
this.OutputExecutorIds = outputExecutorIds ?? new Dictionary<string, HashSet<OutputTag>>(StringComparer.Ordinal);
}
public Dictionary<string, ExecutorInfo> Executors { get; }
@@ -32,7 +33,15 @@ internal sealed class WorkflowInfo
public TypeId? InputType { get; }
public string StartExecutorId { get; }
public HashSet<string> OutputExecutorIds { get; }
/// <summary>
/// Map of executor id to the set of <see cref="OutputTag"/>s under which the executor is registered.
/// An empty set means the executor is registered as a regular (untagged) output source.
/// JSON shape: <c>{ "executorId": ["intermediate"], ... }</c>. Legacy payloads using the
/// older <c>string[]</c> shape are read by <see cref="WorkflowInfoOutputExecutorsConverter"/> and
/// each id is treated as registered with an empty tag set.
/// </summary>
[JsonConverter(typeof(WorkflowInfoOutputExecutorsConverter))]
public Dictionary<string, HashSet<OutputTag>> OutputExecutorIds { get; }
public bool IsMatch(Workflow workflow)
{
@@ -80,9 +89,12 @@ internal sealed class WorkflowInfo
return false;
}
// Validate the outputs
// Validate the outputs (key set + tag set per id must match)
if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count ||
this.OutputExecutorIds.Any(id => !workflow.OutputExecutors.Contains(id)))
this.OutputExecutorIds.Any(kvp =>
!workflow.OutputExecutors.TryGetValue(kvp.Key, out HashSet<OutputTag>? tags) ||
tags.Count != kvp.Value.Count ||
!tags.SetEquals(kvp.Value)))
{
return false;
}
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <summary>
/// JSON converter for <see cref="WorkflowInfo.OutputExecutorIds"/> that supports both the new
/// map shape (<c>{ "id": ["intermediate"] }</c>) and the legacy array shape
/// (<c>["id1", "id2"]</c>). Legacy-shaped payloads are read as if every id had been registered
/// as a regular (untagged) output source; output is always written in the new map shape.
/// </summary>
internal sealed class WorkflowInfoOutputExecutorsConverter : JsonConverter<Dictionary<string, HashSet<OutputTag>>>
{
public override Dictionary<string, HashSet<OutputTag>> Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Dictionary<string, HashSet<OutputTag>> result = new(StringComparer.Ordinal);
if (reader.TokenType == JsonTokenType.Null)
{
return result;
}
if (reader.TokenType == JsonTokenType.StartArray)
{
// Legacy shape: a flat array of executor ids. Treat each as a registered
// (untagged) output executor.
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
return result;
}
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string in legacy outputExecutorIds array, got {reader.TokenType}.");
}
string id = reader.GetString()!;
result[id] = [];
}
throw new JsonException("Unexpected end of legacy outputExecutorIds array.");
}
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException($"Expected object or array for outputExecutorIds, got {reader.TokenType}.");
}
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
return result;
}
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException($"Expected property name in outputExecutorIds object, got {reader.TokenType}.");
}
string id = reader.GetString()!;
reader.Read();
HashSet<OutputTag> tags = [];
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"Expected a string tag, got {reader.TokenType}.");
}
tags.Add(ReadTag(reader.GetString()!));
}
}
else
{
throw new JsonException($"Expected array of tags for outputExecutorIds[{id}], got {reader.TokenType}.");
}
result[id] = tags;
}
throw new JsonException("Unexpected end of outputExecutorIds object.");
}
private static OutputTag ReadTag(string value)
{
if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal))
{
return OutputTag.Intermediate;
}
return new OutputTag(value);
}
public override void Write(
Utf8JsonWriter writer,
Dictionary<string, HashSet<OutputTag>> value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in value)
{
writer.WritePropertyName(kvp.Key);
writer.WriteStartArray();
foreach (OutputTag tag in kvp.Value)
{
writer.WriteStringValue(tag.Value);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
}
@@ -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(IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(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,11 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class OutputFilter(Workflow workflow)
{
public bool CanOutput(string sourceExecutorId, object output)
{
return workflow.OutputExecutors.Contains(sourceExecutorId);
return workflow.OutputExecutors.ContainsKey(sourceExecutorId);
}
public bool TryGetTags(string sourceExecutorId, [NotNullWhen(true)] out HashSet<OutputTag>? tags)
=> workflow.OutputExecutors.TryGetValue(sourceExecutorId, out tags);
}
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Process-wide opt-in switches for in-development behavior changes that will become
/// the default in a future major release. Each flag defaults to <see langword="false"/>
/// and should be toggled once at application startup.
/// </summary>
public static class Futures
{
/// <summary>
/// When <see langword="true"/>, <see cref="AgentResponse"/> and
/// <see cref="AgentResponseUpdate"/> payloads yielded by an executor participate
/// in the normal output-filter pipeline (i.e. they must be designated via
/// <see cref="WorkflowBuilder.WithOutputFrom(ExecutorBinding[])"/> or
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>
/// to surface), and the resulting <see cref="WorkflowOutputEvent"/>s carry
/// <see cref="WorkflowOutputEvent.Tags"/> reflecting that designation.
/// </summary>
/// <remarks>
/// <para>
/// When <see langword="false"/> (the current default), the runner emits
/// <see cref="AgentResponseEvent"/> and <see cref="AgentResponseUpdateEvent"/> unconditionally,
/// bypassing the output filter (historical behavior). Lifecycle: opt-in today, marked
/// <c>[Obsolete]</c> in v2.0.0 when the new behavior becomes default, and removed in v3.0.0.
/// </para>
/// <para>
/// <b>Interaction with <see cref="WorkflowHostingExtensions.AsAIAgent"/>.</b> When this flag
/// is <see langword="true"/>, <see cref="AgentResponseEvent"/> joins
/// <see cref="AgentResponseUpdateEvent"/> in being forwarded out of the agent surface
/// unconditionally — neither honors the host's <c>includeWorkflowOutputsInResponse</c>
/// switch. That switch only governs the generic <see cref="WorkflowOutputEvent"/> path for
/// non-AIAgent payloads. When this flag is <see langword="false"/>, the legacy asymmetry
/// is preserved: <see cref="AgentResponseUpdateEvent"/> is always forwarded but
/// <see cref="AgentResponseEvent"/> stays gated by <c>includeWorkflowOutputsInResponse</c>.
/// </para>
/// </remarks>
public static bool EnableAgentResponseOutputTaggingAndFiltering { get; set; }
}
@@ -12,12 +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;
internal GroupChatWorkflowBuilder(Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) =>
this._managerFactory = managerFactory;
@@ -44,28 +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>
/// 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.
@@ -93,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)
{
@@ -110,6 +78,15 @@ public sealed class GroupChatWorkflowBuilder
.AddEdge(participant, host);
}
return builder.WithOutputFrom(host).Build();
this.ApplyOutputDesignations(builder, agentMap, "group chat", () =>
{
builder.WithOutputFrom(host);
if (agentMap.Count > 0)
{
builder.WithIntermediateOutputFrom([.. agentMap.Values]);
}
});
return builder.Build();
}
}
@@ -39,7 +39,8 @@ public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkfl
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
/// </summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
public class HandoffWorkflowBuilderCore<TBuilder> : OrchestrationBuilderBase<TBuilder>
where TBuilder : HandoffWorkflowBuilderCore<TBuilder>
{
/// <summary>
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}&lt;agent_id&gt;`,
@@ -55,8 +56,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
private bool _returnToPrevious;
private string? _name;
private string? _description;
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
// handoff triggers another invocation of that same agent with the continuation prompt, up to
@@ -116,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"/>
@@ -631,16 +616,31 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
});
}
if (!string.IsNullOrWhiteSpace(this._name))
// 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);
// Build the AIAgent -> ExecutorBinding map the base helper expects.
Dictionary<AIAgent, ExecutorBinding> agentMap = new(AIAgentIDEqualityComparer.Instance);
foreach (AIAgent agent in this._allAgents)
{
builder.WithName(this._name);
agentMap[agent] = executors[agent.Id];
}
if (!string.IsNullOrWhiteSpace(this._description))
this.ApplyMetadata(builder);
this.ApplyOutputDesignations(builder, agentMap, "handoff", () =>
{
builder.WithDescription(this._description);
}
// Defaults (matches Python's Handoff orchestration):
// end -> terminal output
// every handoff agent -> intermediate output
builder.WithOutputFrom(end);
List<ExecutorBinding> agentBindings = [.. executors.Values];
if (agentBindings.Count > 0)
{
builder.WithIntermediateOutputFrom(agentBindings);
}
});
return builder.WithOutputFrom(end).Build();
return builder.Build();
}
}
@@ -241,30 +241,47 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this.CheckEnded();
Throw.IfNull(output);
// Special-case AgentResponse and AgentResponseUpdate to create their specific event types
// and bypass the output filter (for backwards compatibility - these events were previously
// emitted directly via AddEventAsync without filtering)
if (output is AgentResponseUpdate update)
bool isAgentResponseShaped = output is AgentResponse or AgentResponseUpdate;
if (isAgentResponseShaped && !Futures.EnableAgentResponseOutputTaggingAndFiltering)
{
await this.AddEventAsync(new AgentResponseUpdateEvent(sourceId, update), cancellationToken).ConfigureAwait(false);
return;
}
else if (output is AgentResponse response)
{
await this.AddEventAsync(new AgentResponseEvent(sourceId, response), cancellationToken).ConfigureAwait(false);
// Legacy bypass: AgentResponse/AgentResponseUpdate skip the output filter and are
// emitted as their typed event subclasses with no tags. Preserved verbatim for
// back-compat; once Futures.EnableAgentResponseOutputTaggingAndFiltering becomes the
// default in v2.0.0, this branch goes away.
WorkflowEvent typedEvent = output switch
{
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u),
AgentResponse r => new AgentResponseEvent(sourceId, r),
_ => throw new InvalidOperationException("Unexpected AIAgent-shaped payload type."),
};
await this.AddEventAsync(typedEvent, cancellationToken).ConfigureAwait(false);
return;
}
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
if (!sourceExecutor.CanOutput(output.GetType()))
if (!isAgentResponseShaped && !sourceExecutor.CanOutput(output.GetType()))
{
// AIAgent-shaped payloads bypass the per-executor declared-yield check (matching the
// legacy bypass branch above). The AIAgent host executor relays the agent's output
// without declaring AgentResponse(Update) in its Yields set, so a CanOutput probe
// here would always reject — but those payloads are always a valid output shape.
throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}].");
}
if (this._outputFilter.CanOutput(sourceId, output))
if (!this._outputFilter.TryGetTags(sourceId, out HashSet<OutputTag>? tags))
{
await this.AddEventAsync(new WorkflowOutputEvent(output, sourceId), cancellationToken).ConfigureAwait(false);
// Not designated as an output source — drop silently.
return;
}
WorkflowOutputEvent evt = output switch
{
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u, tags),
AgentResponse r => new AgentResponseEvent(sourceId, r, tags),
_ => new WorkflowOutputEvent(output, sourceId, tags),
};
await this.AddEventAsync(evt, cancellationToken).ConfigureAwait(false);
}
public IExternalRequestContext BindExternalRequestContext(string executorId)
@@ -28,11 +28,9 @@ 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;
@@ -45,20 +43,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>
@@ -115,28 +99,29 @@ public class MagenticWorkflowBuilder(AIAgent managerAgent)
ForwardIncomingMessages = false
};
Dictionary<AIAgent, ExecutorBinding> teamMap = new(AIAgentIDEqualityComparer.Instance);
List<ExecutorBinding> 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);
if (!string.IsNullOrWhiteSpace(this._name))
this.ApplyOutputDesignations(result, teamMap, "Magentic", () =>
{
result.WithName(this._name);
}
if (!string.IsNullOrWhiteSpace(this._description))
{
result.WithDescription(this._description);
}
result.WithOutputFrom(orchestrator);
if (teamMap.Count > 0)
{
result.WithIntermediateOutputFrom([.. teamMap.Values]);
}
});
this.ApplyMetadata(result);
return result;
}
@@ -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,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Identifies the kind of output that a <see cref="WorkflowOutputEvent"/> represents.
/// A thin <c>ChatRole</c>-style wrapper around a normalized string <see cref="Value"/>,
/// with value equality and a closed set of well-known singletons (the constructor is
/// <see langword="internal"/> for now).
/// </summary>
[JsonConverter(typeof(OutputTagJsonConverter))]
public readonly struct OutputTag : IEquatable<OutputTag>
{
/// <summary>
/// The string identifier of the tag. Compared with ordinal equality.
/// </summary>
public string? Value { get; }
internal OutputTag(string value)
{
this.Value = Throw.IfNullOrEmpty(value);
}
/// <summary>
/// The tag denoting an intermediate workflow output &#x2014; emitted by executors
/// registered via <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, System.Collections.Generic.IEnumerable{ExecutorBinding})"/>.
/// Terminal (non-intermediate) outputs carry no tag.
/// </summary>
public static OutputTag Intermediate { get; } = new("intermediate");
/// <inheritdoc />
public bool Equals(OutputTag other) => string.Equals(this.Value, other.Value, StringComparison.Ordinal);
/// <inheritdoc />
public override bool Equals(object? obj) => obj is OutputTag other && this.Equals(other);
/// <inheritdoc />
public override int GetHashCode() => this.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(this.Value);
/// <summary>Determines whether two <see cref="OutputTag"/> values are equal.</summary>
public static bool operator ==(OutputTag left, OutputTag right) => left.Equals(right);
/// <summary>Determines whether two <see cref="OutputTag"/> values are not equal.</summary>
public static bool operator !=(OutputTag left, OutputTag right) => !left.Equals(right);
/// <inheritdoc />
public override string ToString() => this.Value ?? string.Empty;
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// JSON converter for <see cref="OutputTag"/> that round-trips the underlying
/// <see cref="OutputTag.Value"/> as a bare JSON string.
/// </summary>
internal sealed class OutputTagJsonConverter : JsonConverter<OutputTag>
{
public override OutputTag Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? value = reader.GetString();
if (string.IsNullOrEmpty(value))
{
return default;
}
// Reuse the well-known singleton where possible so callers can do reference
// comparisons on the common case without paying the extra allocation cost.
if (string.Equals(value, OutputTag.Intermediate.Value, StringComparison.Ordinal))
{
return OutputTag.Intermediate;
}
return new OutputTag(value!);
}
public override void Write(Utf8JsonWriter writer, OutputTag value, JsonSerializerOptions options)
{
if (value.Value is null)
{
writer.WriteNullValue();
return;
}
writer.WriteStringValue(value.Value);
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
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="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(IEnumerable{AIAgent})"/>
/// or <see cref="OrchestrationBuilderBase{TBuilder}.WithIntermediateOutputFrom(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();
}
}
@@ -24,7 +24,7 @@ public class Workflow
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
internal HashSet<string> OutputExecutors { get; init; } = [];
internal Dictionary<string, HashSet<OutputTag>> OutputExecutors { get; init; } = new(StringComparer.Ordinal);
/// <summary>
/// Gets the collection of edges grouped by their source node identifier.
@@ -221,7 +221,7 @@ public class Workflow
startExecutor.AttachRequestContext(new NoOpExternalRequestContext());
ProtocolDescriptor inputProtocol = startExecutor.DescribeProtocol();
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Keys.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false);
IEnumerable<Type> yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields);
@@ -33,7 +33,7 @@ public class WorkflowBuilder
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, RequestPort> _requestPorts = [];
private readonly HashSet<string> _outputExecutors = [];
private readonly Dictionary<string, HashSet<OutputTag>> _outputExecutors = new(StringComparer.Ordinal);
private readonly string _startExecutorId;
private string? _name;
@@ -97,22 +97,89 @@ public class WorkflowBuilder
}
/// <summary>
/// Register executors as an output source. Executors can use <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values.
/// By default, message handlers with a non-void return type will also be yielded, unless <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/>
/// is set to <see langword="false"/>.
/// Register executors as a source of terminal workflow outputs. Executors can use
/// <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values; yielded values from
/// registered executors are surfaced as <see cref="WorkflowOutputEvent"/> (or one of its
/// subclasses) with an empty <see cref="WorkflowOutputEvent.Tags"/> set.
/// By default, message handlers with a non-void return type will also be yielded, unless
/// <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/> is set to <see langword="false"/>.
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
/// <remarks>
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
/// participate in this designation when
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
/// <see langword="true"/>; otherwise they are emitted unconditionally and untagged.
/// </remarks>
/// <param name="executors">The executors to register as output sources.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
{
foreach (ExecutorBinding executor in executors)
{
this._outputExecutors.Add(this.Track(executor).Id);
this.EnsureOutputExecutor(this.Track(executor).Id);
}
return this;
}
/// <summary>
/// Register executors as a source of workflow outputs carrying the given <paramref name="tag"/>.
/// Tags accumulate across repeated calls; the registered id always exists with the union of all
/// tags applied across all calls (and an empty set if only the untagged
/// <see cref="WithOutputFrom(ExecutorBinding[])"/> overload was used).
/// </summary>
/// <remarks>
/// Forward-looking surface for when the <see cref="OutputTag"/> constructor opens to
/// user-defined tags. Today, prefer
/// <see cref="WorkflowBuilderExtensions.WithIntermediateOutputFrom(WorkflowBuilder, IEnumerable{ExecutorBinding})"/>
/// for the <see cref="OutputTag.Intermediate"/> case.
/// </remarks>
/// <param name="executors">The executors to register.</param>
/// <param name="tag">The tag to apply to events yielded by the listed executors.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithOutputFrom(IEnumerable<ExecutorBinding> executors, OutputTag tag)
{
Throw.IfNull(executors);
foreach (ExecutorBinding executor in executors)
{
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
}
return this;
}
/// <summary>
/// Register a single executor as a source of workflow outputs carrying the given <paramref name="tag"/>.
/// Convenience overload for the single-executor case; equivalent to passing a one-element sequence
/// to <see cref="WithOutputFrom(IEnumerable{ExecutorBinding}, OutputTag)"/>.
/// </summary>
/// <param name="executor">The executor to register.</param>
/// <param name="tag">The tag to apply to events yielded by the executor.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithOutputFrom(ExecutorBinding executor, OutputTag tag)
{
Throw.IfNull(executor);
this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag);
return this;
}
/// <summary>
/// Ensures the executor id is present in <see cref="_outputExecutors"/>; if newly added,
/// initializes with an empty tag set. Returns the tag set for the id (mutable).
/// </summary>
private HashSet<OutputTag> EnsureOutputExecutor(string executorId)
{
if (!this._outputExecutors.TryGetValue(executorId, out HashSet<OutputTag>? tags))
{
tags = [];
this._outputExecutors[executorId] = tags;
}
return tags;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
@@ -211,4 +211,28 @@ public static class WorkflowBuilderExtensions
return switchBuilder.ReduceToFanOut(builder, source);
}
/// <summary>
/// Register executors as a source of <b>intermediate</b> workflow outputs. The resulting
/// <see cref="WorkflowOutputEvent"/>s carry <see cref="OutputTag.Intermediate"/> in their
/// <see cref="WorkflowOutputEvent.Tags"/> set, and
/// <see cref="WorkflowOutputEventExtensions.IsIntermediate(WorkflowOutputEvent)"/> returns
/// <see langword="true"/>. Use this for progress updates, partial results, and other
/// non-terminal emissions that downstream consumers (DevUI, logging, Workflow-as-Agent
/// surfaces) should see distinctly from the workflow's final output.
/// </summary>
/// <remarks>
/// AIAgent payloads (<see cref="AgentResponse"/> / <see cref="AgentResponseUpdate"/>) only
/// participate in this designation when
/// <see cref="Futures.EnableAgentResponseOutputTaggingAndFiltering"/> is
/// <see langword="true"/>; otherwise they bypass the filter and are emitted untagged.
/// </remarks>
/// <param name="builder">The workflow builder to register executors on.</param>
/// <param name="executors">The executors to register as intermediate output sources.</param>
/// <returns>The <paramref name="builder"/>, enabling fluent configuration.</returns>
public static WorkflowBuilder WithIntermediateOutputFrom(this WorkflowBuilder builder, IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(builder);
return builder.WithOutputFrom(executors, OutputTag.Intermediate);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
@@ -13,14 +14,39 @@ namespace Microsoft.Agents.AI.Workflows;
[JsonDerivedType(typeof(AgentResponseUpdateEvent))]
public class WorkflowOutputEvent : WorkflowEvent
{
private readonly HashSet<OutputTag> _tags;
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class.
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class with no tags.
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
public WorkflowOutputEvent(object data, string executorId) : base(data)
public WorkflowOutputEvent(object data, string executorId) : this(data, executorId, tags: null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
/// given output tag.
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
/// <param name="tag">The single output tag to associate with this event.</param>
public WorkflowOutputEvent(object data, string executorId, OutputTag tag) : this(data, executorId, tags: new[] { tag })
{
}
/// <summary>
/// Initializes a new instance of the <see cref="WorkflowOutputEvent"/> class carrying the
/// given output tags (deduplicated).
/// </summary>
/// <param name="data">The output data.</param>
/// <param name="executorId">The identifier of the executor that yielded this output.</param>
/// <param name="tags">The output tags to associate with this event. May be <see langword="null"/> or empty (the event is then untagged).</param>
public WorkflowOutputEvent(object data, string executorId, IEnumerable<OutputTag>? tags) : base(data)
{
this.ExecutorId = executorId;
this._tags = tags is null ? new HashSet<OutputTag>() : new HashSet<OutputTag>(tags);
}
/// <summary>
@@ -32,8 +58,21 @@ public class WorkflowOutputEvent : WorkflowEvent
/// The unique identifier of the executor that yielded this output.
/// </summary>
[Obsolete("Use ExecutorId instead.")]
[JsonIgnore]
public string SourceId => this.ExecutorId;
/// <summary>
/// The set of output tags associated with this event. Never <see langword="null"/>;
/// empty for terminal/regular outputs. The presence of <see cref="OutputTag.Intermediate"/>
/// marks this event as an intermediate output.
/// </summary>
public IEnumerable<OutputTag> Tags => this._tags;
/// <summary>
/// Returns <see langword="true"/> if this event carries the given tag.
/// </summary>
public bool HasTag(OutputTag tag) => this._tags.Contains(tag);
/// <summary>
/// Determines whether the underlying data is of the specified type or a derived type.
/// </summary>
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension helpers for inspecting <see cref="WorkflowOutputEvent"/> tag membership.
/// </summary>
public static class WorkflowOutputEventExtensions
{
/// <summary>
/// Returns <see langword="true"/> if the event carries
/// <see cref="OutputTag.Intermediate"/> in its <see cref="WorkflowOutputEvent.Tags"/>.
/// </summary>
public static bool IsIntermediate(this WorkflowOutputEvent evt)
{
Throw.IfNull(evt);
return evt.HasTag(OutputTag.Intermediate);
}
}
@@ -520,11 +520,20 @@ internal sealed class WorkflowSession : AgentSession
goto default;
case AgentResponseEvent agentResponse:
if (!this._includeWorkflowOutputsInResponse)
// Under Futures.EnableAgentResponseOutputTaggingAndFiltering=true, mirror
// AgentResponseUpdateEvent's behavior: always forward, regardless of the
// _includeWorkflowOutputsInResponse host flag / "intermediate" tag. Under
// the legacy default, keep today's behavior — gated by the include flag.
if (!Futures.EnableAgentResponseOutputTaggingAndFiltering && !this._includeWorkflowOutputsInResponse)
{
goto default;
}
// Either EnableAgentResponseOutputTaggingAndFiltering -- so yield the Response
// regardless of whether it is tagged "intermediate" or whether the
// _includeWorkflowOutputInResponse flag is set. Reason being: The user specifies
// exclusion of an event by enabling filtering and then _not_ marking an Executor
// as an output executor.
foreach (ChatMessage message in agentResponse.Response.Messages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
@@ -539,7 +548,11 @@ internal sealed class WorkflowSession : AgentSession
_ => null
};
if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
// Same assymetry as with AgentResponseEvent, but there is no EnableFiltering flag
// to consider. If this made it here (and since it is not an AgentResponse[Update]),
// it means it is already been selected as an Output() from the user. Intermediate
// is irrelevant here.
if (updateMessages == null || !this._includeWorkflowOutputsInResponse)
{
goto default;
}
@@ -80,9 +80,8 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(ExecutorIdentity))]
[JsonSerializable(typeof(RunnerStateData))]
// Workflow Representation Types
[JsonSerializable(typeof(WorkflowInfo))]
[JsonSerializable(typeof(EdgeConnection))]
// Workflow Output Types
[JsonSerializable(typeof(OutputTag))]
// Workflow-as-Agent
[JsonSerializable(typeof(WorkflowChatHistoryProvider.StoreState))]
@@ -4,12 +4,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using FluentAssertions;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
@@ -17,601 +14,164 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Tests targeting the static <see cref="AgentWorkflowBuilder"/> helper surface —
/// <see cref="AgentWorkflowBuilder.BuildSequential(IEnumerable{AIAgent})"/>,
/// <see cref="AgentWorkflowBuilder.BuildConcurrent(IEnumerable{AIAgent}, Func{IList{List{ChatMessage}}, 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 class AgentWorkflowBuilderTests
{
[Fact]
public void BuildSequential_InvalidArguments_Throws()
public void Test_AgentWorkflowBuilder_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)]
public async Task Test_AgentWorkflowBuilder_BuildSequential_DelegatesToBuilderAsync(int numAgents)
{
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
from i in Enumerable.Range(1, numAgents)
select new OrchestrationTestHelpers.DoubleEchoAgent($"agent{i}"));
// 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")]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.NotEmpty(updateText);
}
[Fact]
public void BuildConcurrent_InvalidArguments_Throws()
public void Test_AgentWorkflowBuilder_BuildSequential_WithWorkflowNameSetsNameOnWorkflow()
{
Workflow workflow = AgentWorkflowBuilder.BuildSequential(
"static-sequential",
new OrchestrationTestHelpers.DoubleEchoAgent("agent1"));
workflow.Name.Should().Be("static-sequential");
}
[Fact]
public void Test_AgentWorkflowBuilder_BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
[Fact]
public void BuildGroupChat_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new 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 DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
manager.MaximumIterationCount = 30;
Assert.Equal(30, manager.MaximumIterationCount);
manager.MaximumIterationCount = 1;
Assert.Equal(1, manager.MaximumIterationCount);
manager.MaximumIterationCount = int.MaxValue;
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
}
[Fact]
public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription()
{
const string WorkflowName = "Test Group Chat";
const string WorkflowDescription = "A test group chat workflow";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Equal(WorkflowDescription, workflow.Description);
}
[Fact]
public void BuildGroupChat_WithNameOnly_SetsWorkflowName()
{
const string WorkflowName = "Named Group Chat";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"))
.WithName(WorkflowName)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Null(workflow.Description);
}
[Fact]
public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull()
{
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new DoubleEchoAgent("agent1"))
.Build();
Assert.Null(workflow.Name);
Assert.Null(workflow.Description);
}
[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;
}
}
private 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 };
}
}
private sealed class DoubleEchoAgentSession() : AgentSession();
[Fact]
public async Task BuildConcurrent_AgentsRunInParallelAsync()
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_DelegatesToBuilderAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
var workflow = AgentWorkflowBuilder.BuildConcurrent(
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new OrchestrationTestHelpers.DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
]);
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
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);
}
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
{
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.AddParticipants(new DoubleEchoAgent("agent3"))
.Build();
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(maxIterations + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
// The group-chat host broadcasts each new message (initial user input + each speaker's
// response) to every participant except the speaker that produced it. The selected
// speaker therefore sees only what's been broadcast to it since its previous turn.
string[] agentIds = ["agent1", "agent2", "agent3"];
List<string>[] buffers = new List<string>[NumAgents];
for (int a = 0; a < NumAgents; a++)
{
buffers[a] = [UserInput];
}
string[] texts = new string[maxIterations + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < maxIterations + 1; i++)
{
int speakerIdx = (i - 1) % NumAgents;
string id = agentIds[speakerIdx];
string concatReceived = string.Concat(buffers[speakerIdx]);
texts[i] = $"{id}{Double(concatReceived)}";
buffers[speakerIdx].Clear();
for (int a = 0; a < NumAgents; a++)
{
if (a == speakerIdx)
{
continue;
}
buffers[a].Add(texts[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;
}
}
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
private 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);
}
private 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);
}
private static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
private 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;
}
}
}
private sealed class RecordingAgent(string name) : AIAgent
{
public List<List<string>> Invocations { get; } = [];
public override string Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new RecordingAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new RecordingAgentSession());
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();
this.Invocations.Add(messages.Select(m => m.Text).ToList());
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id };
}
}
private sealed class RecordingAgentSession() : AgentSession();
[Fact]
public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync()
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
var agentC = new RecordingAgent("agentC");
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
.AddParticipants(agentA, agentB, agentC)
.Build();
const string UserInput = "hello";
(_, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
Assert.Equal(5, result.Count); // initial user input + 4 agent turns
Assert.Collection(
result,
m => Assert.Equal(UserInput, m.Text),
m => Assert.Equal("agentA", m.Text),
m => Assert.Equal("agentB", m.Text),
m => Assert.Equal("agentC", m.Text),
m => Assert.Equal("agentA", m.Text));
// Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts
// confirm only the chosen participant receives a TurnToken on each round.
Assert.Equal(2, agentA.Invocations.Count);
Assert.Single(agentB.Invocations);
Assert.Single(agentC.Invocations);
// Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so
// agentA's only buffered message is the user input.
Assert.Equal([UserInput], agentA.Invocations[0]);
// Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of
// agentA's response (agentA itself is excluded as the last speaker).
Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]);
// Turn 3: agentC. It also received every broadcast so far (it has never been excluded).
Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]);
// Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but
// received turn-3 (agentB's response) and turn-4 (agentC's response) deltas.
Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]);
Assert.Equal(2, result.Count);
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
}
[Fact]
public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync()
public void Test_AgentWorkflowBuilder_BuildConcurrent_WithWorkflowNameSetsNameOnWorkflow()
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
"static-concurrent",
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")]);
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 })
.AddParticipants(agentA, agentB)
.Build();
const string UserInput = "hello";
await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
// Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync
// prefixed.
Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]);
// Turn 2: agentB received both the initial broadcast and agentA's response — both passed
// through UpdateHistoryAsync before being broadcast.
Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]);
workflow.Name.Should().Be("static-concurrent");
}
[Fact]
public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync()
public async Task Test_AgentWorkflowBuilder_BuildConcurrent_AggregatorIsHonoredAsync()
{
const string UserInput = "hello";
const int MaxIterations = 6;
// 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")];
// --- Baseline: run the full conversation under checkpointing and capture every checkpoint
// plus the final transcript. The same workflow + agents are reused for the resume,
// because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. ---
BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations);
Workflow workflow = AgentWorkflowBuilder.BuildConcurrent(
[new OrchestrationTestHelpers.DoubleEchoAgent("agent1")],
aggregator: _ => sentinel);
// We need at least one mid-conversation checkpoint to resume from. The baseline produces a
// checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint
// captured roughly midway so the resumed run still has work to do.
Assert.True(baseline.Checkpoints.Count >= 5,
$"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}");
(_, List<ChatMessage>? result, _, _) =
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
int midIndex = baseline.Checkpoints.Count / 2;
CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex];
// Snapshot per-agent invocation counts before the resume so we can isolate the invocations
// produced after the checkpoint is restored.
int aPreCount = baseline.AgentA.Invocations.Count;
int bPreCount = baseline.AgentB.Invocations.Count;
int cPreCount = baseline.AgentC.Invocations.Count;
// --- Resume the same workflow from the mid-conversation checkpoint. ---
List<ChatMessage>? resumedResult = null;
await using (StreamingRun resumed = await baseline.Environment
.ResumeStreamingAsync(baseline.Workflow, midCheckpoint))
{
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is WorkflowOutputEvent o)
{
resumedResult = o.As<List<ChatMessage>>();
}
else if (evt is WorkflowErrorEvent err)
{
Assert.Fail($"Resumed workflow failed: {err.Exception}");
}
}
}
// (1) Iteration-count continuity: the resumed run terminates with exactly the same number
// of turns the baseline produced — proves IterationCount was rehydrated and the manager
// honored MaximumIterationCount across the boundary.
Assert.NotNull(resumedResult);
Assert.Equal(baseline.Result.Count, resumedResult!.Count);
// (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn,
// in order) matches the baseline — proves the round-robin cursor was restored.
List<string?> baselineTranscript = [.. baseline.Result.Select(m => m.Text)];
List<string?> resumedTranscript = [.. resumedResult.Select(m => m.Text)];
Assert.Equal(baselineTranscript, resumedTranscript);
// (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its
// own Name. Examine only the invocations recorded after the resume. If the host failed
// to exclude the current speaker from its post-resume broadcasts, an agent's next
// invocation buffer would contain its own previously produced response. Asserting that
// no post-resume invocation input contains the invoking agent's own name proves the
// exclusion was preserved through checkpoint+restore.
AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount);
AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount);
AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount);
// Sanity: at least one agent was actually invoked after the resume; otherwise the test
// would trivially pass even if the host stopped scheduling turns after restore.
int totalPost = baseline.AgentA.Invocations.Count - aPreCount
+ (baseline.AgentB.Invocations.Count - bPreCount)
+ (baseline.AgentC.Invocations.Count - cPreCount);
Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint");
static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount)
{
for (int i = preCount; i < agent.Invocations.Count; i++)
{
Assert.DoesNotContain(agent.Name, agent.Invocations[i]);
}
}
result.Should().NotBeNull().And.ContainSingle();
result![0].Text.Should().Be("custom-aggregator-result");
}
private sealed record BaselineRunResult(
Workflow Workflow,
InProcessExecutionEnvironment Environment,
RecordingAgent AgentA,
RecordingAgent AgentB,
RecordingAgent AgentC,
List<ChatMessage> Result,
List<CheckpointInfo> Checkpoints,
CheckpointManager CheckpointManager);
private static async Task<BaselineRunResult> RunGroupChatBaselineAsync(string userInput, int maxIterations)
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_RejectsNull()
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
var agentC = new RecordingAgent("agentC");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(agentA, agentB, agentC)
.Build();
CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep
.ToWorkflowExecutionEnvironment()
.WithCheckpointing(checkpointMgr);
List<CheckpointInfo> checkpoints = [];
List<ChatMessage>? finalResult = null;
await using (StreamingRun run = await env.OpenStreamingAsync(workflow))
{
await run.TrySendMessageAsync(new List<ChatMessage> { new(ChatRole.User, userInput) });
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
{
switch (evt)
{
case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
checkpoints.Add(cp);
break;
case WorkflowOutputEvent o:
finalResult = o.As<List<ChatMessage>>();
break;
case WorkflowErrorEvent err:
Assert.Fail($"Baseline workflow failed: {err.Exception}");
break;
}
}
}
Assert.NotNull(finalResult);
return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr);
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateSequentialBuilderWith(null!));
}
private sealed class PrefixingGroupChatManager(IReadOnlyList<AIAgent> agents, string prefix) : RoundRobinGroupChatManager(agents)
[Fact]
public void Test_AgentWorkflowBuilder_CreateSequentialBuilderWith_ReturnsConfigurableBuilder()
{
protected internal override ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default)
{
IEnumerable<ChatMessage> prefixed =
history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName });
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
return new(prefixed);
}
SequentialWorkflowBuilder builder = AgentWorkflowBuilder.CreateSequentialBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.CreateConcurrentBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateConcurrentBuilderWith_ReturnsConfigurableBuilder()
{
OrchestrationTestHelpers.DoubleEchoAgent agent = new("agent1");
ConcurrentWorkflowBuilder builder = AgentWorkflowBuilder.CreateConcurrentBuilderWith(agent);
Workflow workflow = builder.WithName("via-factory").Build();
workflow.Name.Should().Be("via-factory");
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateHandoffBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
#pragma warning restore MAAIW001
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateGroupChatBuilderWith_RejectsNull()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
}
[Fact]
public void Test_AgentWorkflowBuilder_CreateMagenticBuilderWith_RejectsNull()
{
#pragma warning disable MAAIW001
Assert.Throws<ArgumentNullException>("managerAgent", () => AgentWorkflowBuilder.CreateMagenticBuilderWith(null!));
#pragma warning restore MAAIW001
}
}
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows.UnitTests.BackwardsCompatibility;
/// <summary>
/// Tests pinning the JSON shape of checkpoint-adjacent types so older payloads keep
/// deserializing correctly after the Outputs overhaul (see implementation-plan §5.7).
/// </summary>
public class JsonCheckpointSerializationTests
{
private static readonly JsonSerializerOptions s_options = WorkflowsJsonUtilities.DefaultOptions;
private static WorkflowInfo BuildInfoWithOutputExecutors(Dictionary<string, HashSet<OutputTag>> outputs)
=> new(
executors: new Dictionary<string, ExecutorInfo>(),
edges: new Dictionary<string, List<EdgeInfo>>(),
requestPorts: [],
startExecutorId: "start",
outputExecutorIds: outputs);
// ---------- WorkflowOutputEvent.Tags in-process round-trip (no JSON) ----------
[Fact]
public void Test_WorkflowOutputEvent_SingleTagCtorPopulatesTags()
{
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tag: OutputTag.Intermediate);
evt.ExecutorId.Should().Be("e1");
evt.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
evt.IsIntermediate().Should().BeTrue();
}
[Fact]
public void Test_WorkflowOutputEvent_NoTagsCtorIsUntagged()
{
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1");
evt.Tags.Should().BeEmpty();
evt.IsIntermediate().Should().BeFalse("an event with no tags is a terminal/regular output");
}
[Fact]
public void Test_WorkflowOutputEvent_MultiTagCtorPreservesAllTags()
{
OutputTag customTag = JsonSerializer.Deserialize<OutputTag>("\"custom\"", s_options);
WorkflowOutputEvent evt = new(data: "hello", executorId: "e1", tags: new[] { OutputTag.Intermediate, customTag });
evt.Tags.Should().HaveCount(2);
evt.HasTag(OutputTag.Intermediate).Should().BeTrue();
evt.HasTag(customTag).Should().BeTrue();
evt.IsIntermediate().Should().BeTrue();
}
// ---------- WorkflowInfo.OutputExecutorIds shape ----------
//
// Note: per the comment in WorkflowsJsonUtilities, WorkflowEvent / WorkflowOutputEvent
// is *not* currently a serialized checkpoint shape (events are not persisted into
// checkpoints today), so we do not pin a JSON round-trip for Tags on the event itself
// here. The tag JSON round-trip is exercised by OutputTagTests; the
// OutputExecutorIds map shape is the actually-load-bearing back-compat surface.
[Fact]
public void Test_JsonCheckpoint_WorkflowOutputExecutorsReadsLegacyArrayShape()
{
const string LegacyJson = """
{
"executors": {},
"edges": {},
"requestPorts": [],
"startExecutorId": "start",
"outputExecutorIds": ["a", "b"]
}
""";
WorkflowInfo? info = JsonSerializer.Deserialize<WorkflowInfo>(LegacyJson, s_options);
info.Should().NotBeNull();
info!.OutputExecutorIds.Should().HaveCount(2);
info.OutputExecutorIds["a"].Should().BeEmpty("legacy ids are untagged regular outputs");
info.OutputExecutorIds["b"].Should().BeEmpty();
}
[Fact]
public void Test_JsonCheckpoint_WorkflowOutputExecutorsWritesMapShape()
{
Dictionary<string, HashSet<OutputTag>> outputs = new()
{
["a"] = [],
["b"] = [OutputTag.Intermediate],
};
WorkflowInfo info = BuildInfoWithOutputExecutors(outputs);
string json = JsonSerializer.Serialize(info, s_options);
WorkflowInfo? back = JsonSerializer.Deserialize<WorkflowInfo>(json, s_options);
back.Should().NotBeNull();
back!.OutputExecutorIds.Should().HaveCount(2);
back.OutputExecutorIds["a"].Should().BeEmpty();
back.OutputExecutorIds["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
// The map shape is detectable in the serialized JSON: the property value starts with `{`, not `[`.
int idx = json.IndexOf("\"outputExecutorIds\"", System.StringComparison.Ordinal);
idx.Should().BeGreaterThan(-1);
int colon = json.IndexOf(':', idx);
int firstNonSpace = colon + 1;
while (firstNonSpace < json.Length && char.IsWhiteSpace(json[firstNonSpace]))
{
firstNonSpace++;
}
json[firstNonSpace].Should().Be('{', "OutputExecutorIds is written in the new map shape");
}
}
@@ -0,0 +1,164 @@
// 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;
#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");
}
}
}
@@ -0,0 +1,289 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// Runner-level coverage for <see cref="Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering"/>.
/// Exercises every combination of (flag on/off) × (designation kind) × (payload shape) to pin the
/// runner's behavior in both the legacy bypass path and the unified filter-and-tag path.
/// </summary>
public static partial class FuturesTests
{
[Collection(FuturesSerialCollection.Name)]
public class AgentResponseOutputFilteringAndTaggingTests
{
private const string SourceId = "yielder";
private static AgentResponse SampleResponse(string text = "hi")
=> new(new ChatMessage(ChatRole.Assistant, text));
private static AgentResponseUpdate SampleUpdate(string text = "tick")
=> new(ChatRole.Assistant, text);
private static async Task<List<WorkflowEvent>> RunAsync<T>(Workflow workflow, T input) where T : notnull
{
List<WorkflowEvent> events = [];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
events.Add(evt);
}
return events;
}
private static Workflow BuildAgentResponseWorkflow(Action<WorkflowBuilder, YieldAgentResponseExecutor>? designate = null)
{
YieldAgentResponseExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
private static Workflow BuildAgentResponseUpdateWorkflow(Action<WorkflowBuilder, YieldAgentResponseUpdateExecutor>? designate = null)
{
YieldAgentResponseUpdateExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
private static Workflow BuildPocoWorkflow(Action<WorkflowBuilder, YieldPocoExecutor>? designate = null)
{
YieldPocoExecutor exec = new(SourceId);
WorkflowBuilder builder = new(exec);
designate?.Invoke(builder, exec);
return builder.Build();
}
// F1
[Fact]
public async Task Test_Runner_LegacyAgentResponseBypass_RaisesUntaggedEventAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.ExecutorId.Should().Be(SourceId);
emitted.Tags.Should().BeEmpty("legacy bypass attaches no tags");
emitted.IsIntermediate().Should().BeFalse();
}
// F2
[Fact]
public async Task Test_Runner_LegacyAgentResponseUpdateBypass_RaisesUntaggedEventAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseUpdateWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseUpdateEvent emitted = events.OfType<AgentResponseUpdateEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty();
}
// F3
[Fact]
public async Task Test_Runner_LegacyBypassIgnoresDesignationAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("legacy bypass ignores the designation entirely");
emitted.IsIntermediate().Should().BeFalse("legacy bypass does not propagate tags");
}
// F4
[Fact]
public async Task Test_Runner_LegacyPocoIsFilteredAsync()
{
using FuturesScope _ = new(enabled: false);
Workflow workflow = BuildPocoWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
events.OfType<WorkflowOutputEvent>().Should().BeEmpty("POCO outputs always go through the filter; undesignated source is dropped");
}
// F5
[Fact]
public async Task Test_Runner_UndesignatedAgentResponseIsFilteredWhenFuturesOnAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(designate: null);
List<WorkflowEvent> events = await RunAsync(workflow, "go");
events.OfType<WorkflowOutputEvent>().Should().BeEmpty(
"with the future on, AgentResponse must be designated to surface");
}
// F6
[Fact]
public async Task Test_Runner_DesignatedTerminalAgentResponseHasEmptyTagsAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithOutputFrom(e));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("terminal designation carries no tag");
emitted.IsIntermediate().Should().BeFalse();
}
// F7
[Fact]
public async Task Test_Runner_DesignatedIntermediateAgentResponseHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F8
[Fact]
public async Task Test_Runner_DesignatedIntermediateAgentResponseUpdateHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseUpdateWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseUpdateEvent emitted = events.OfType<AgentResponseUpdateEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F9
[Fact]
public async Task Test_Runner_TagsAccumulateOutputThenIntermediateAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithOutputFrom(e);
b.WithIntermediateOutputFrom([e]);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate },
"terminal+intermediate union is {{ Intermediate }} (terminal contributes the entry but no tag)");
emitted.IsIntermediate().Should().BeTrue();
}
// F10
[Fact]
public async Task Test_Runner_TagsAccumulateIntermediateThenOutputAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithIntermediateOutputFrom([e]);
b.WithOutputFrom(e);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate }, "designation order is irrelevant");
emitted.IsIntermediate().Should().BeTrue();
}
// F11
[Fact]
public async Task Test_Runner_DesignatedIntermediatePocoHasIntermediateTagAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildPocoWorkflow(static (b, e) => b.WithIntermediateOutputFrom([e]));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
WorkflowOutputEvent emitted = events.OfType<WorkflowOutputEvent>().Should().ContainSingle().Subject;
emitted.Should().NotBeOfType<AgentResponseEvent>();
emitted.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
emitted.IsIntermediate().Should().BeTrue();
}
// F12
[Fact]
public async Task Test_Runner_DesignatedTerminalPocoHasEmptyTagsAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildPocoWorkflow(static (b, e) => b.WithOutputFrom(e));
List<WorkflowEvent> events = await RunAsync(workflow, "go");
WorkflowOutputEvent emitted = events.OfType<WorkflowOutputEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty();
emitted.IsIntermediate().Should().BeFalse();
}
// F13
[Fact]
public async Task Test_Runner_RepeatedTerminalDesignationDedupesAsync()
{
using FuturesScope _ = new(enabled: true);
Workflow workflow = BuildAgentResponseWorkflow(static (b, e) =>
{
b.WithOutputFrom(e);
b.WithOutputFrom(e);
});
List<WorkflowEvent> events = await RunAsync(workflow, "go");
AgentResponseEvent emitted = events.OfType<AgentResponseEvent>().Should().ContainSingle().Subject;
emitted.Tags.Should().BeEmpty("repeated terminal designation contributes no tag");
}
// ---- Executors -----------------------------------------------------------
internal sealed class YieldAgentResponseExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, AgentResponse>(this.HandleAsync));
private ValueTask<AgentResponse> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(SampleResponse(input));
}
internal sealed class YieldAgentResponseUpdateExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, AgentResponseUpdate>(this.HandleAsync));
private ValueTask<AgentResponseUpdate> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(SampleUpdate(input));
}
public sealed record Poco(string Value);
internal sealed class YieldPocoExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(rb => rb.AddHandler<string, Poco>(this.HandleAsync));
private ValueTask<Poco> HandleAsync(string input, IWorkflowContext context, CancellationToken cancellationToken)
=> new(new Poco(input));
}
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// Sets <see cref="Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering"/> for
/// the lifetime of the scope, restoring the prior value on dispose. Pair every use with
/// <c>using</c> and run inside the <c>FuturesSerial</c> xUnit collection to avoid leaking
/// state across parallel tests.
/// </summary>
internal sealed class FuturesScope : IDisposable
{
private readonly bool _previous;
public FuturesScope(bool enabled)
{
this._previous = Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering;
Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering = enabled;
}
public void Dispose()
{
Workflows.Futures.EnableAgentResponseOutputTaggingAndFiltering = this._previous;
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.UnitTests.Futures;
/// <summary>
/// xUnit collection marker for tests that mutate the process-global
/// <see cref="Workflows.Futures"/> switches. Membership in this collection serializes
/// the tests against each other so that <see cref="FuturesScope"/> cannot leak state
/// into a concurrently running test.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix",
Justification = "xUnit's [CollectionDefinition] pattern names the marker type after the collection's purpose; the 'Collection' suffix is idiomatic.")]
public sealed class FuturesSerialCollection
{
public const string Name = "FuturesSerial";
}
@@ -0,0 +1,477 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
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;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class GroupChatWorkflowBuilderTests
{
[Fact]
public void BuildGroupChat_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
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 OrchestrationTestHelpers.DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new RoundRobinGroupChatManager([new OrchestrationTestHelpers.DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
manager.MaximumIterationCount = 30;
Assert.Equal(30, manager.MaximumIterationCount);
manager.MaximumIterationCount = 1;
Assert.Equal(1, manager.MaximumIterationCount);
manager.MaximumIterationCount = int.MaxValue;
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
}
[Fact]
public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription()
{
const string WorkflowName = "Test Group Chat";
const string WorkflowDescription = "A test group chat workflow";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"), new OrchestrationTestHelpers.DoubleEchoAgent("agent2"))
.WithName(WorkflowName)
.WithDescription(WorkflowDescription)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Equal(WorkflowDescription, workflow.Description);
}
[Fact]
public void BuildGroupChat_WithNameOnly_SetsWorkflowName()
{
const string WorkflowName = "Named Group Chat";
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.WithName(WorkflowName)
.Build();
Assert.Equal(WorkflowName, workflow.Name);
Assert.Null(workflow.Description);
}
[Fact]
public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull()
{
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 })
.AddParticipants(new OrchestrationTestHelpers.DoubleEchoAgent("agent1"))
.Build();
Assert.Null(workflow.Name);
Assert.Null(workflow.Description);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
{
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.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 OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
// The group-chat host broadcasts each new message (initial user input + each speaker's
// response) to every participant except the speaker that produced it. The selected
// speaker therefore sees only what's been broadcast to it since its previous turn.
string[] agentIds = ["agent1", "agent2", "agent3"];
List<string>[] buffers = new List<string>[NumAgents];
for (int a = 0; a < NumAgents; a++)
{
buffers[a] = [UserInput];
}
string[] texts = new string[maxIterations + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < maxIterations + 1; i++)
{
int speakerIdx = (i - 1) % NumAgents;
string id = agentIds[speakerIdx];
string concatReceived = string.Concat(buffers[speakerIdx]);
texts[i] = $"{id}{Double(concatReceived)}";
buffers[speakerIdx].Clear();
for (int a = 0; a < NumAgents; a++)
{
if (a == speakerIdx)
{
continue;
}
buffers[a].Add(texts[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_GroupChatWorkflowBuilder_DefaultDesignationsMatchSpec()
{
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 })
.AddParticipants(a1, a2, a3)
.Build();
Dictionary<string, HashSet<OutputTag>> 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()
{
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 })
.AddParticipants(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 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()
{
OrchestrationTestHelpers.DoubleEchoAgent participant = new("p1");
OrchestrationTestHelpers.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<InvalidOperationException>().WithMessage("*stranger*");
}
private sealed class RecordingAgent(string name) : AIAgent
{
public List<List<string>> Invocations { get; } = [];
public override string Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new RecordingAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new RecordingAgentSession());
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();
this.Invocations.Add(messages.Select(m => m.Text).ToList());
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id };
}
}
private sealed class RecordingAgentSession() : AgentSession();
[Fact]
public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync()
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
var agentC = new RecordingAgent("agentC");
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 })
.AddParticipants(agentA, agentB, agentC)
.Build();
const string UserInput = "hello";
(_, List<ChatMessage>? result, _, _) = await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(5, result.Count); // initial user input + 4 agent turns
Assert.Collection(
result,
m => Assert.Equal(UserInput, m.Text),
m => Assert.Equal("agentA", m.Text),
m => Assert.Equal("agentB", m.Text),
m => Assert.Equal("agentC", m.Text),
m => Assert.Equal("agentA", m.Text));
// Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts
// confirm only the chosen participant receives a TurnToken on each round.
Assert.Equal(2, agentA.Invocations.Count);
Assert.Single(agentB.Invocations);
Assert.Single(agentC.Invocations);
// Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so
// agentA's only buffered message is the user input.
Assert.Equal([UserInput], agentA.Invocations[0]);
// Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of
// agentA's response (agentA itself is excluded as the last speaker).
Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]);
// Turn 3: agentC. It also received every broadcast so far (it has never been excluded).
Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]);
// Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but
// received turn-3 (agentB's response) and turn-4 (agentC's response) deltas.
Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]);
}
[Fact]
public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync()
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
var workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 })
.AddParticipants(agentA, agentB)
.Build();
const string UserInput = "hello";
await OrchestrationTestHelpers.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
// Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync
// prefixed.
Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]);
// Turn 2: agentB received both the initial broadcast and agentA's response — both passed
// through UpdateHistoryAsync before being broadcast.
Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]);
}
[Fact]
public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync()
{
const string UserInput = "hello";
const int MaxIterations = 6;
// --- Baseline: run the full conversation under checkpointing and capture every checkpoint
// plus the final transcript. The same workflow + agents are reused for the resume,
// because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. ---
BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations);
// We need at least one mid-conversation checkpoint to resume from. The baseline produces a
// checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint
// captured roughly midway so the resumed run still has work to do.
Assert.True(baseline.Checkpoints.Count >= 5,
$"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}");
int midIndex = baseline.Checkpoints.Count / 2;
CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex];
// Snapshot per-agent invocation counts before the resume so we can isolate the invocations
// produced after the checkpoint is restored.
int aPreCount = baseline.AgentA.Invocations.Count;
int bPreCount = baseline.AgentB.Invocations.Count;
int cPreCount = baseline.AgentC.Invocations.Count;
// --- Resume the same workflow from the mid-conversation checkpoint. ---
List<ChatMessage>? resumedResult = null;
await using (StreamingRun resumed = await baseline.Environment
.ResumeStreamingAsync(baseline.Workflow, midCheckpoint))
{
await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is WorkflowOutputEvent o)
{
resumedResult = o.As<List<ChatMessage>>();
}
else if (evt is WorkflowErrorEvent err)
{
Assert.Fail($"Resumed workflow failed: {err.Exception}");
}
}
}
// (1) Iteration-count continuity: the resumed run terminates with exactly the same number
// of turns the baseline produced — proves IterationCount was rehydrated and the manager
// honored MaximumIterationCount across the boundary.
Assert.NotNull(resumedResult);
Assert.Equal(baseline.Result.Count, resumedResult!.Count);
// (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn,
// in order) matches the baseline — proves the round-robin cursor was restored.
List<string?> baselineTranscript = [.. baseline.Result.Select(m => m.Text)];
List<string?> resumedTranscript = [.. resumedResult.Select(m => m.Text)];
Assert.Equal(baselineTranscript, resumedTranscript);
// (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its
// own Name. Examine only the invocations recorded after the resume. If the host failed
// to exclude the current speaker from its post-resume broadcasts, an agent's next
// invocation buffer would contain its own previously produced response. Asserting that
// no post-resume invocation input contains the invoking agent's own name proves the
// exclusion was preserved through checkpoint+restore.
AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount);
AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount);
AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount);
// Sanity: at least one agent was actually invoked after the resume; otherwise the test
// would trivially pass even if the host stopped scheduling turns after restore.
int totalPost = baseline.AgentA.Invocations.Count - aPreCount
+ (baseline.AgentB.Invocations.Count - bPreCount)
+ (baseline.AgentC.Invocations.Count - cPreCount);
Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint");
static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount)
{
for (int i = preCount; i < agent.Invocations.Count; i++)
{
Assert.DoesNotContain(agent.Name, agent.Invocations[i]);
}
}
}
private sealed record BaselineRunResult(
Workflow Workflow,
InProcessExecutionEnvironment Environment,
RecordingAgent AgentA,
RecordingAgent AgentB,
RecordingAgent AgentC,
List<ChatMessage> Result,
List<CheckpointInfo> Checkpoints,
CheckpointManager CheckpointManager);
private static async Task<BaselineRunResult> RunGroupChatBaselineAsync(string userInput, int maxIterations)
{
var agentA = new RecordingAgent("agentA");
var agentB = new RecordingAgent("agentB");
var agentC = new RecordingAgent("agentC");
Workflow workflow = AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(agentA, agentB, agentC)
.Build();
CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory();
InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep
.ToWorkflowExecutionEnvironment()
.WithCheckpointing(checkpointMgr);
List<CheckpointInfo> checkpoints = [];
List<ChatMessage>? finalResult = null;
await using (StreamingRun run = await env.OpenStreamingAsync(workflow))
{
await run.TrySendMessageAsync(new List<ChatMessage> { new(ChatRole.User, userInput) });
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false))
{
switch (evt)
{
case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
checkpoints.Add(cp);
break;
case WorkflowOutputEvent o:
finalResult = o.As<List<ChatMessage>>();
break;
case WorkflowErrorEvent err:
Assert.Fail($"Baseline workflow failed: {err.Exception}");
break;
}
}
}
Assert.NotNull(finalResult);
return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr);
}
private sealed class PrefixingGroupChatManager(IReadOnlyList<AIAgent> agents, string prefix) : RoundRobinGroupChatManager(agents)
{
protected internal override ValueTask<IEnumerable<ChatMessage>> UpdateHistoryAsync(
IReadOnlyList<ChatMessage> history,
CancellationToken cancellationToken = default)
{
IEnumerable<ChatMessage> prefixed =
history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName });
return new(prefixed);
}
}
}
@@ -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;
/// <summary>
/// Tests focused on <see cref="HandoffWorkflowBuilder"/>'s output-designation surface —
/// the Python-aligned defaults applied at <see cref="HandoffWorkflowBuilderCore{TBuilder}.Build"/>
/// when the user has not made explicit designations, and the memoized
/// <c>WithOutputFrom</c> / <c>WithIntermediateOutputFrom</c> replay otherwise.
/// </summary>
#pragma warning disable MAAIW001 // Experimental: HandoffWorkflowBuilder
public class HandoffWorkflowBuilderTests
{
[Fact]
public void Test_HandoffWorkflowBuilder_DefaultDesignationsMatchSpec()
{
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
Workflow workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.Build();
Dictionary<string, HashSet<OutputTag>> 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()
{
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
Workflow workflow = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.WithOutputFrom(coordinator)
.WithIntermediateOutputFrom([specialist])
.Build();
Dictionary<string, HashSet<OutputTag>> 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()
{
OrchestrationTestHelpers.DoubleEchoAgent coordinator = new("coordinator");
OrchestrationTestHelpers.DoubleEchoAgent specialist = new("specialist");
OrchestrationTestHelpers.DoubleEchoAgent stranger = new("stranger");
HandoffWorkflowBuilder builder = AgentWorkflowBuilder
.CreateHandoffBuilderWith(coordinator)
.WithHandoff(coordinator, specialist)
.WithIntermediateOutputFrom([stranger]);
Action build = () => builder.Build();
build.Should().Throw<InvalidOperationException>().WithMessage("*stranger*");
}
}
#pragma warning restore MAAIW001
@@ -122,50 +122,3 @@ public sealed class InputWaiterTests : IDisposable
await waitTask;
}
}
public class OutputFilterTests
{
private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId)
{
NoOpExecutor start = new("start");
NoOpExecutor end = new("end");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, end)
.WithOutputFrom(outputExecutorId == "end" ? end : start)
.Build();
return new OutputFilter(workflow);
}
[Fact]
public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor");
}
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
}
@@ -187,8 +187,12 @@ public class JsonSerializationTests
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count)
.And.AllSatisfy(id => prototype.OutputExecutorIds.Contains(id));
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count);
foreach (KeyValuePair<string, HashSet<OutputTag>> kvp in prototype.OutputExecutorIds)
{
actual.OutputExecutorIds.Should().ContainKey(kvp.Key);
actual.OutputExecutorIds[kvp.Key].Should().BeEquivalentTo(kvp.Value);
}
void ValidateExecutorDictionary(Dictionary<string, ExecutorInfo> expected,
Dictionary<string, List<EdgeInfo>> expectedEdges,
@@ -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;
/// <summary>
/// Tests focused on <see cref="MagenticWorkflowBuilder"/>'s output-designation surface —
/// the Python-aligned defaults applied at <see cref="MagenticWorkflowBuilder.Build"/> when
/// the user has not made explicit designations, and the memoized
/// <c>WithOutputFrom</c> / <c>WithIntermediateOutputFrom</c> replay otherwise.
/// </summary>
#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<string, HashSet<OutputTag>> 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<string, HashSet<OutputTag>> 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<InvalidOperationException>().WithMessage("*Stranger*");
}
}
#pragma warning restore MAAIW001
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Container for shared test helpers used by every orchestration-builder test class —
/// the <c>DoubleEchoAgent</c> family and the <c>RunWorkflow*</c> methods. The actual
/// test methods live in per-builder files (<c>SequentialWorkflowBuilderTests</c>,
/// <c>ConcurrentWorkflowBuilderTests</c>, <c>GroupChatWorkflowBuilderTests</c>, etc.).
/// </summary>
public static class OrchestrationTestHelpers
{
internal class DoubleEchoAgent(string name) : AIAgent
{
public override string Name => name;
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> default;
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var contents = messages.SelectMany(m => m.Contents).ToList();
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
}
}
internal sealed class DoubleEchoAgentSession() : AgentSession();
internal sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
{
barrier.Value!.SetResult(true);
}
await barrier.Value!.Task.ConfigureAwait(false);
await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken))
{
await Task.Yield();
yield return update;
}
}
}
internal sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
internal static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return await ProcessWorkflowRunAsync(run);
}
internal static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
{
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
output = e;
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
break;
case SuperStepCompletedEvent stepCompleted:
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
break;
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
}
internal static Task<WorkflowRunResult> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
=> RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment());
}
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class OutputFilterTests
{
private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId)
{
NoOpExecutor start = new("start");
NoOpExecutor end = new("end");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, end)
.WithOutputFrom(outputExecutorId == "end" ? end : start)
.Build();
return new OutputFilter(workflow);
}
[Fact]
public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor");
}
[Fact]
public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor");
}
[Fact]
public void Test_OutputFilter_ReturnsEmptyTagSetWhenRegisteredViaWithOutputFrom()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
tags.Should().NotBeNull().And.BeEmpty("terminal designation carries no tag");
}
[Fact]
public void Test_OutputFilter_ReturnsIntermediateTagWhenRegisteredViaWithIntermediateOutputFrom()
{
NoOpExecutor start = new("start");
NoOpExecutor end = new("end");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, end)
.WithIntermediateOutputFrom([end])
.Build();
OutputFilter filter = new(workflow);
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_OutputFilter_ReturnsIntermediateTagForAccumulatedDesignation()
{
NoOpExecutor start = new("start");
NoOpExecutor end = new("end");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, end)
.WithOutputFrom(end)
.WithIntermediateOutputFrom([end])
.Build();
OutputFilter filter = new(workflow);
filter.TryGetTags("end", out HashSet<OutputTag>? tags).Should().BeTrue();
tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate },
"terminal designation contributes no tag; the union is the intermediate set");
}
[Fact]
public void Test_OutputFilter_TryGetTagsReturnsFalseForUnregisteredExecutor()
{
OutputFilter filter = CreateFilterWithOutputFrom("end");
filter.TryGetTags("start", out HashSet<OutputTag>? tags).Should().BeFalse();
tags.Should().BeNull();
}
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class OutputTagTests
{
[Fact]
public void Test_OutputTag_KnownValues()
{
OutputTag.Intermediate.Value.Should().Be("intermediate");
}
[Fact]
public void Test_OutputTag_EqualityIsOrdinalOnValue()
{
OutputTag.Intermediate.Should().Be(OutputTag.Intermediate);
(OutputTag.Intermediate == OutputTag.Intermediate).Should().BeTrue();
// Same Value via independent construction (via JSON round-trip below) is equal.
OutputTag rebuilt = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
rebuilt.Should().Be(OutputTag.Intermediate);
}
[Fact]
public void Test_OutputTag_DefaultStructValueIsDistinct()
{
OutputTag def = default;
def.Value.Should().BeNull();
def.Should().NotBe(OutputTag.Intermediate);
def.GetHashCode().Should().Be(0);
HashSet<OutputTag> set = [OutputTag.Intermediate];
set.Contains(def).Should().BeFalse("default(OutputTag) must not collide with the well-known singleton in a HashSet");
}
[Fact]
public void Test_OutputTag_GetHashCodeMatchesEquals()
{
OutputTag a = OutputTag.Intermediate;
OutputTag b = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
a.Equals(b).Should().BeTrue();
a.GetHashCode().Should().Be(b.GetHashCode());
}
[Fact]
public void Test_OutputTag_JsonConverter_RoundtripsValueAsString()
{
string intermediateJson = JsonSerializer.Serialize(OutputTag.Intermediate, WorkflowsJsonUtilities.DefaultOptions);
intermediateJson.Should().Be("\"intermediate\"");
OutputTag back = JsonSerializer.Deserialize<OutputTag>("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions);
back.Should().Be(OutputTag.Intermediate);
OutputTag fromUnknown = JsonSerializer.Deserialize<OutputTag>("\"custom\"", WorkflowsJsonUtilities.DefaultOptions);
fromUnknown.Value.Should().Be("custom");
}
[Fact]
public void Test_OutputTag_ConstructorIsInternal()
{
ConstructorInfo? ctor = typeof(OutputTag).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
types: [typeof(string)],
modifiers: null);
ctor.Should().NotBeNull("OutputTag(string) must exist as an internal constructor");
ctor!.IsAssembly.Should().BeTrue("OutputTag(string) must be `internal` so external assemblies cannot synthesize tags");
ctor.IsPublic.Should().BeFalse();
}
}
@@ -0,0 +1,183 @@
// 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;
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");
}
}
}
@@ -6,7 +6,7 @@ using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public partial class WorkflowBuilderSmokeTests
public partial class WorkflowBuilderTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
@@ -455,4 +455,112 @@ public partial class WorkflowBuilderSmokeTests
/// </summary>
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
// --- Tag-aware WithOutputFrom / WithIntermediateOutputFrom tests ---
[Fact]
public void Test_WithOutputFrom_RegistersWithEmptyTagSet()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithOutputFrom(b)
.Build();
workflow.OutputExecutors.Should().ContainKey("b");
workflow.OutputExecutors["b"].Should().BeEmpty("regular outputs are untagged");
}
[Fact]
public void Test_WithIntermediateOutputFrom_AddsIntermediateTag()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithOutputFrom_MultipleExecutorsAllUntagged()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
NoOpExecutor c = new("c");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b).AddEdge(a, c)
.WithOutputFrom(b, c)
.Build();
workflow.OutputExecutors.Should().HaveCount(2);
workflow.OutputExecutors["b"].Should().BeEmpty();
workflow.OutputExecutors["c"].Should().BeEmpty();
}
[Fact]
public void Test_WithOutputFrom_ThenIntermediate_AccumulatesTags()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithOutputFrom(b)
.WithIntermediateOutputFrom([b])
.Build();
// WithOutputFrom doesn't add a tag; WithIntermediateOutputFrom adds Intermediate.
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithIntermediateOutputFrom_RepeatedDedupes()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithIntermediateOutputFrom_OnlyRegistersWithoutPriorWithOutputFrom()
{
// WithIntermediateOutputFrom on its own is sufficient to register the executor as an
// output source — the call ensures the id is in the dict with the Intermediate tag.
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors.Should().ContainKey("b");
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithOutputFrom_TracksExecutorBinding()
{
// A placeholder binding referenced via WithOutputFrom must end up bound by the time we Build.
NoOpExecutor a = new("a");
NoOpExecutor future = new("future");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, "future")
.WithIntermediateOutputFrom(["future"])
.BindExecutor(future)
.Build();
workflow.OutputExecutors.Should().ContainKey("future");
workflow.OutputExecutors["future"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
}
@@ -824,4 +824,130 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
}
// ----- Phase 5: Workflow-as-Agent intermediate forwarding -----------------
[Collection(Futures.FuturesSerialCollection.Name)]
public class IntermediateForwarding
{
private const string InterText = "progress";
private const string FinalText = "final";
private static async Task<List<AgentResponseUpdate>> RunStreamingAsync(
Workflow workflow,
bool includeWorkflowOutputsInResponse = false)
{
return await workflow
.AsAIAgent("WorkflowAgent", includeWorkflowOutputsInResponse: includeWorkflowOutputsInResponse)
.RunStreamingAsync(new ChatMessage(ChatRole.User, "hi"))
.ToListAsync();
}
[Fact]
public async Task Test_WorkflowHostAgent_IntermediateAgentResponseForwardedInStreamingAsync()
{
using Futures.FuturesScope _ = new(enabled: true);
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
Workflow workflow = new WorkflowBuilder(binding)
.WithIntermediateOutputFrom([binding])
.Build();
// Under Futures-on, AgentResponseEvent mirrors AgentResponseUpdateEvent: always
// forwarded regardless of the include flag. The intermediate tag is observable on
// the surfaced event for consumers that care to distinguish.
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: false);
updates.Any(u => u.RawRepresentation is AgentResponseEvent are && are.IsIntermediate() && u.Text == InterText)
.Should().BeTrue("AgentResponseEvent is forwarded under Futures-on regardless of the include flag");
}
[Fact]
public async Task Test_WorkflowHostAgent_TerminalAgentResponseForwardedUnconditionallyWhenFuturesOnAsync()
{
using Futures.FuturesScope _ = new(enabled: true);
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(FinalText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
Workflow workflow = new WorkflowBuilder(binding)
.WithOutputFrom(binding)
.Build();
// Even a terminal-only designation surfaces without the include flag — the gating
// asymmetry between AgentResponse and AgentResponseUpdate is gone under Futures-on.
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: false);
updates.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
.Should().BeTrue("terminal AgentResponseEvent is forwarded under Futures-on regardless of the include flag");
}
[Fact]
public async Task Test_WorkflowHostAgent_TerminalAgentResponseGatedWhenFuturesOffAsync()
{
using Futures.FuturesScope _ = new(enabled: false);
static Workflow Build()
{
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(FinalText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
return new WorkflowBuilder(binding).WithOutputFrom(binding).Build();
}
// Legacy semantics: AgentResponseEvent stays behind the include flag when Futures
// is off. Two fresh workflows because in-process runs aren't reentrant.
List<AgentResponseUpdate> gated = await RunStreamingAsync(Build(), includeWorkflowOutputsInResponse: false);
gated.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
.Should().BeFalse("terminal AgentResponseEvent stays gated under Futures-off");
List<AgentResponseUpdate> included = await RunStreamingAsync(Build(), includeWorkflowOutputsInResponse: true);
included.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == FinalText)
.Should().BeTrue("opting in via includeWorkflowOutputsInResponse surfaces it");
}
[Fact]
public async Task Test_WorkflowHostAgent_UndesignatedExecutorEmitsNoAgentResponseEventWhenFuturesOnAsync()
{
using Futures.FuturesScope _ = new(enabled: true);
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
// No designation — under Futures-on, the AgentResponse is dropped by the filter.
Workflow workflow = new WorkflowBuilder(binding).Build();
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true);
updates.Any(u => u.RawRepresentation is AgentResponseEvent)
.Should().BeFalse("an undesignated AIAgent executor produces no AgentResponseEvent under Futures-on");
}
[Fact]
public async Task Test_WorkflowHostAgent_UndesignatedAgentResponseSurfacesWhenFuturesOffAsync()
{
using Futures.FuturesScope _ = new(enabled: false);
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
Workflow workflow = new WorkflowBuilder(binding).Build();
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow, includeWorkflowOutputsInResponse: true);
updates.Any(u => u.RawRepresentation is AgentResponseEvent && u.Text == InterText)
.Should().BeTrue("legacy bypass still emits AgentResponseEvent regardless of designation");
}
[Fact]
public async Task Test_WorkflowHostAgent_IntermediateTagAvailableViaRawRepresentationAsync()
{
using Futures.FuturesScope _ = new(enabled: true);
TestReplayAgent agent = new(TestReplayAgent.ToChatMessages(InterText));
ExecutorBinding binding = agent.BindAsExecutor(new AIAgentHostOptions { EmitAgentResponseEvents = true });
Workflow workflow = new WorkflowBuilder(binding)
.WithIntermediateOutputFrom([binding])
.Build();
List<AgentResponseUpdate> updates = await RunStreamingAsync(workflow);
AgentResponseUpdate progress = updates.First(u => u.RawRepresentation is AgentResponseEvent && u.Text == InterText);
AgentResponseEvent raw = (AgentResponseEvent)progress.RawRepresentation!;
raw.IsIntermediate().Should().BeTrue();
raw.Tags.Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
}
}