mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
b000a2cf51
commit
8ed2159c4b
@@ -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;
|
||||
}
|
||||
|
||||
+122
@@ -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}<agent_id>`,
|
||||
@@ -55,8 +56,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
|
||||
private bool _emitAgentResponseUpdateEvents;
|
||||
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
|
||||
private bool _returnToPrevious;
|
||||
private string? _name;
|
||||
private string? _description;
|
||||
|
||||
// Autonomous mode configuration. When enabled, an agent's response that doesn't include a
|
||||
// handoff triggers another invocation of that same agent with the continuation prompt, up to
|
||||
@@ -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 — 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))]
|
||||
|
||||
+117
-557
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -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");
|
||||
}
|
||||
}
|
||||
+164
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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";
|
||||
}
|
||||
+477
@@ -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
|
||||
-47
@@ -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();
|
||||
}
|
||||
}
|
||||
+183
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-1
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user