From 59b6e1f6e09505b8c4d4ed772a6351e1b4a638a0 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 22 May 2026 12:57:54 -0400 Subject: [PATCH] feat: introduce OutputTag, Futures, and tag-aware WorkflowBuilder API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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?`; 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, 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)`: 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 -> Dictionary< string, HashSet>. The value set is empty for executors designated only via the untagged WithOutputFrom; contains Intermediate (and possibly future tags) otherwise. * `Workflow.OutputExecutors`: HashSet -> Dictionary>. * `OutputFilter.CanOutput`: `Contains(id)` -> `ContainsKey(id)`. * `WorkflowInfo.OutputExecutorIds`: HashSet -> Dictionary< string, HashSet>, 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> --- .../AgentResponseEvent.cs | 23 ++++ .../AgentResponseUpdateEvent.cs | 22 ++++ .../Checkpointing/WorkflowInfo.cs | 22 +++- .../WorkflowInfoOutputExecutorsConverter.cs | 122 ++++++++++++++++++ .../Execution/OutputFilter.cs | 4 +- .../Microsoft.Agents.AI.Workflows/Futures.cs | 34 +++++ .../OutputTag.cs | 52 ++++++++ .../OutputTagJsonConverter.cs | 43 ++++++ .../Microsoft.Agents.AI.Workflows/Workflow.cs | 4 +- .../WorkflowBuilder.cs | 83 ++++++++++-- .../WorkflowBuilderExtensions.cs | 24 ++++ .../WorkflowOutputEvent.cs | 43 +++++- .../WorkflowOutputEventExtensions.cs | 21 +++ .../WorkflowsJsonUtilities.cs | 5 +- .../JsonCheckpointSerializationTests.cs | 121 +++++++++++++++++ .../JsonSerializationTests.cs | 8 +- .../OutputTagTests.cs | 77 +++++++++++ .../WorkflowBuilderTests.cs | 110 +++++++++++++++- 18 files changed, 792 insertions(+), 26 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/BackwardsCompatibility/JsonCheckpointSerializationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputTagTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs index e57204ea4e..5d59366a20 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseEvent.cs @@ -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); } + /// + /// Initializes a new instance of the class with the given output tag. + /// + /// The identifier of the executor that generated this event. + /// The agent response. + /// The output tag to associate with this event. + public AgentResponseEvent(string executorId, AgentResponse response, OutputTag tag) : base(response, executorId, tag) + { + this.Response = Throw.IfNull(response); + } + + /// + /// Initializes a new instance of the class with the given output tags. + /// + /// The identifier of the executor that generated this event. + /// The agent response. + /// The output tags to associate with this event. May be or empty. + public AgentResponseEvent(string executorId, AgentResponse response, IEnumerable? tags) : base(response, executorId, tags) + { + this.Response = Throw.IfNull(response); + } + /// /// Gets the agent response. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs index 017dce1763..f3d5215ccd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentResponseUpdateEvent.cs @@ -20,6 +20,28 @@ public sealed class AgentResponseUpdateEvent : WorkflowOutputEvent this.Update = Throw.IfNull(update); } + /// + /// Initializes a new instance of the class with the given output tag. + /// + /// The identifier of the executor that generated this event. + /// The agent run response update. + /// The output tag to associate with this event. + public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, OutputTag tag) : base(update, executorId, tag) + { + this.Update = Throw.IfNull(update); + } + + /// + /// Initializes a new instance of the class with the given output tags. + /// + /// The identifier of the executor that generated this event. + /// The agent run response update. + /// The output tags to associate with this event. May be or empty. + public AgentResponseUpdateEvent(string executorId, AgentResponseUpdate update, IEnumerable? tags) : base(update, executorId, tags) + { + this.Update = Throw.IfNull(update); + } + /// /// Gets the agent run response update. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs index f40882265a..aac14fee35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfo.cs @@ -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> edges, HashSet requestPorts, string startExecutorId, - HashSet? outputExecutorIds) + Dictionary>? 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>(StringComparer.Ordinal); } public Dictionary Executors { get; } @@ -32,7 +33,15 @@ internal sealed class WorkflowInfo public TypeId? InputType { get; } public string StartExecutorId { get; } - public HashSet OutputExecutorIds { get; } + /// + /// Map of executor id to the set of s under which the executor is registered. + /// An empty set means the executor is registered as a regular (untagged) output source. + /// JSON shape: { "executorId": ["intermediate"], ... }. Legacy payloads using the + /// older string[] shape are read by and + /// each id is treated as registered with an empty tag set. + /// + [JsonConverter(typeof(WorkflowInfoOutputExecutorsConverter))] + public Dictionary> 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? tags) || + tags.Count != kvp.Value.Count || + !tags.SetEquals(kvp.Value))) { return false; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs new file mode 100644 index 0000000000..4f8c2d504d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/WorkflowInfoOutputExecutorsConverter.cs @@ -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; + +/// +/// JSON converter for that supports both the new +/// map shape ({ "id": ["intermediate"] }) and the legacy array shape +/// (["id1", "id2"]). 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. +/// +internal sealed class WorkflowInfoOutputExecutorsConverter : JsonConverter>> +{ + public override Dictionary> Read( + ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Dictionary> 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 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> value, + JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (KeyValuePair> kvp in value) + { + writer.WritePropertyName(kvp.Key); + writer.WriteStartArray(); + foreach (OutputTag tag in kvp.Value) + { + writer.WriteStringValue(tag.Value); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs index cecf1da9f8..c5bcf62466 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/OutputFilter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Workflows.Execution; @@ -6,6 +6,6 @@ internal sealed class OutputFilter(Workflow workflow) { public bool CanOutput(string sourceExecutorId, object output) { - return workflow.OutputExecutors.Contains(sourceExecutorId); + return workflow.OutputExecutors.ContainsKey(sourceExecutorId); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs new file mode 100644 index 0000000000..f19ba88832 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Futures.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Process-wide opt-in switches for in-development behavior changes that will become +/// the default in a future major release. Each flag defaults to +/// and should be toggled once at application startup. +/// +public static class Futures +{ + private static bool s_enableAgentResponseOutputTaggingAndFiltering; + + /// + /// When , and + /// payloads yielded by an executor participate + /// in the normal output-filter pipeline (i.e. they must be designated via + /// or + /// + /// to surface), and the resulting s carry + /// reflecting that designation. + /// + /// + /// When (the current default), the runner emits + /// and unconditionally, + /// bypassing the output filter (historical behavior). Lifecycle: opt-in today, marked + /// [Obsolete] in v2.0.0 when the new behavior becomes default, and removed in v3.0.0. + /// + public static bool EnableAgentResponseOutputTaggingAndFiltering + { + get => s_enableAgentResponseOutputTaggingAndFiltering; + set => s_enableAgentResponseOutputTaggingAndFiltering = value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs new file mode 100644 index 0000000000..81d8daa1f7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTag.cs @@ -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; + +/// +/// Identifies the kind of output that a represents. +/// A thin ChatRole-style wrapper around a normalized string , +/// with value equality and a closed set of well-known singletons (the constructor is +/// for now). +/// +[JsonConverter(typeof(OutputTagJsonConverter))] +public readonly struct OutputTag : IEquatable +{ + /// + /// The normalized string identifier of the tag. Compared with ordinal equality. + /// + public string? Value { get; } + + internal OutputTag(string value) + { + this.Value = Throw.IfNullOrEmpty(value); + } + + /// + /// The tag denoting an intermediate workflow output — emitted by executors + /// registered via . + /// Terminal (non-intermediate) outputs carry no tag. + /// + public static OutputTag Intermediate { get; } = new("intermediate"); + + /// + public bool Equals(OutputTag other) => string.Equals(this.Value, other.Value, StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => obj is OutputTag other && this.Equals(other); + + /// + public override int GetHashCode() => this.Value is null ? 0 : StringComparer.Ordinal.GetHashCode(this.Value); + + /// Determines whether two values are equal. + public static bool operator ==(OutputTag left, OutputTag right) => left.Equals(right); + + /// Determines whether two values are not equal. + public static bool operator !=(OutputTag left, OutputTag right) => !left.Equals(right); + + /// + public override string ToString() => this.Value ?? string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs new file mode 100644 index 0000000000..79eb8dfffe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/OutputTagJsonConverter.cs @@ -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; + +/// +/// JSON converter for that round-trips the underlying +/// as a bare JSON string. +/// +internal sealed class OutputTagJsonConverter : JsonConverter +{ + 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index eff1cfb9a3..03c8f6a920 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -24,7 +24,7 @@ public class Workflow internal Dictionary ExecutorBindings { get; init; } = []; internal Dictionary> Edges { get; init; } = []; - internal HashSet OutputExecutors { get; init; } = []; + internal Dictionary> OutputExecutors { get; init; } = new(StringComparer.Ordinal); /// /// 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> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask()); + IEnumerable> outputExecutorTasks = this.OutputExecutors.Keys.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask()); Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false); IEnumerable yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index e29abca5ab..869f440bd0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -33,7 +33,7 @@ public class WorkflowBuilder private readonly HashSet _unboundExecutors = []; private readonly HashSet _conditionlessConnections = []; private readonly Dictionary _requestPorts = []; - private readonly HashSet _outputExecutors = []; + private readonly Dictionary> _outputExecutors = new(StringComparer.Ordinal); private readonly string _startExecutorId; private string? _name; @@ -97,22 +97,89 @@ public class WorkflowBuilder } /// - /// Register executors as an output source. Executors can use to yield output values. - /// By default, message handlers with a non-void return type will also be yielded, unless - /// is set to . + /// Register executors as a source of terminal workflow outputs. Executors can use + /// to yield output values; yielded values from + /// registered executors are surfaced as (or one of its + /// subclasses) with an empty set. + /// By default, message handlers with a non-void return type will also be yielded, unless + /// is set to . /// - /// - /// + /// + /// AIAgent payloads ( / ) only + /// participate in this designation when + /// is + /// ; otherwise they are emitted unconditionally and untagged. + /// + /// The executors to register as output sources. + /// The current instance, enabling fluent configuration. 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; } + /// + /// Register executors as a source of workflow outputs carrying the given . + /// 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 + /// overload was used). + /// + /// + /// Forward-looking surface for when the constructor opens to + /// user-defined tags. Today, prefer + /// + /// for the case. + /// + /// The executors to register. + /// The tag to apply to events yielded by the listed executors. + /// The current instance, enabling fluent configuration. + public WorkflowBuilder WithOutputFrom(IEnumerable executors, OutputTag tag) + { + Throw.IfNull(executors); + + foreach (ExecutorBinding executor in executors) + { + this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag); + } + + return this; + } + + /// + /// Register a single executor as a source of workflow outputs carrying the given . + /// Convenience overload for the single-executor case; equivalent to passing a one-element sequence + /// to . + /// + /// The executor to register. + /// The tag to apply to events yielded by the executor. + /// The current instance, enabling fluent configuration. + public WorkflowBuilder WithOutputFrom(ExecutorBinding executor, OutputTag tag) + { + Throw.IfNull(executor); + + this.EnsureOutputExecutor(this.Track(executor).Id).Add(tag); + + return this; + } + + /// + /// Ensures the executor id is present in ; if newly added, + /// initializes with an empty tag set. Returns the tag set for the id (mutable). + /// + private HashSet EnsureOutputExecutor(string executorId) + { + if (!this._outputExecutors.TryGetValue(executorId, out HashSet? tags)) + { + tags = []; + this._outputExecutors[executorId] = tags; + } + return tags; + } + /// /// Sets the human-readable name for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index a22aa8e722..6db047255d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -211,4 +211,28 @@ public static class WorkflowBuilderExtensions return switchBuilder.ReduceToFanOut(builder, source); } + + /// + /// Register executors as a source of intermediate workflow outputs. The resulting + /// s carry in their + /// set, and + /// returns + /// . 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. + /// + /// + /// AIAgent payloads ( / ) only + /// participate in this designation when + /// is + /// ; otherwise they bypass the filter and are emitted untagged. + /// + /// The workflow builder to register executors on. + /// The executors to register as intermediate output sources. + /// The , enabling fluent configuration. + public static WorkflowBuilder WithIntermediateOutputFrom(this WorkflowBuilder builder, IEnumerable executors) + { + Throw.IfNull(builder); + return builder.WithOutputFrom(executors, OutputTag.Intermediate); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index f0fe884f6d..15ce4d9034 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; @@ -14,13 +15,36 @@ namespace Microsoft.Agents.AI.Workflows; public class WorkflowOutputEvent : WorkflowEvent { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with no tags. /// /// The output data. /// The identifier of the executor that yielded this output. - public WorkflowOutputEvent(object data, string executorId) : base(data) + public WorkflowOutputEvent(object data, string executorId) : this(data, executorId, tags: null) + { + } + + /// + /// Initializes a new instance of the class carrying the + /// given output tag. + /// + /// The output data. + /// The identifier of the executor that yielded this output. + /// The single output tag to associate with this event. + public WorkflowOutputEvent(object data, string executorId, OutputTag tag) : this(data, executorId, tags: new[] { tag }) + { + } + + /// + /// Initializes a new instance of the class carrying the + /// given output tags (deduplicated). + /// + /// The output data. + /// The identifier of the executor that yielded this output. + /// The output tags to associate with this event. May be or empty (the event is then untagged). + public WorkflowOutputEvent(object data, string executorId, IEnumerable? tags) : base(data) { this.ExecutorId = executorId; + this.Tags = tags is null ? new HashSet() : new HashSet(tags); } /// @@ -32,8 +56,21 @@ public class WorkflowOutputEvent : WorkflowEvent /// The unique identifier of the executor that yielded this output. /// [Obsolete("Use ExecutorId instead.")] + [JsonIgnore] public string SourceId => this.ExecutorId; + /// + /// The set of output tags associated with this event. Never ; + /// empty for terminal/regular outputs. The presence of + /// marks this event as an intermediate output. + /// + public HashSet Tags { get; } + + /// + /// Returns if this event carries the given tag. + /// + public bool HasTag(OutputTag tag) => this.Tags.Contains(tag); + /// /// Determines whether the underlying data is of the specified type or a derived type. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs new file mode 100644 index 0000000000..06993d23f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEventExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Extension helpers for inspecting tag membership. +/// +public static class WorkflowOutputEventExtensions +{ + /// + /// Returns if the event carries + /// in its . + /// + public static bool IsIntermediate(this WorkflowOutputEvent evt) + { + Throw.IfNull(evt); + return evt.HasTag(OutputTag.Intermediate); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index 3bf63b09be..0614aca36d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -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))] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/BackwardsCompatibility/JsonCheckpointSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/BackwardsCompatibility/JsonCheckpointSerializationTests.cs new file mode 100644 index 0000000000..dadaddaabd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/BackwardsCompatibility/JsonCheckpointSerializationTests.cs @@ -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; + +/// +/// Tests pinning the JSON shape of checkpoint-adjacent types so older payloads keep +/// deserializing correctly after the Outputs overhaul (see implementation-plan §5.7). +/// +public class JsonCheckpointSerializationTests +{ + private static readonly JsonSerializerOptions s_options = WorkflowsJsonUtilities.DefaultOptions; + + private static WorkflowInfo BuildInfoWithOutputExecutors(Dictionary> outputs) + => new( + executors: new Dictionary(), + edges: new Dictionary>(), + 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("\"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(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> outputs = new() + { + ["a"] = [], + ["b"] = [OutputTag.Intermediate], + }; + + WorkflowInfo info = BuildInfoWithOutputExecutors(outputs); + + string json = JsonSerializer.Serialize(info, s_options); + + WorkflowInfo? back = JsonSerializer.Deserialize(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"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 744c9264a2..8d053334f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -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> kvp in prototype.OutputExecutorIds) + { + actual.OutputExecutorIds.Should().ContainKey(kvp.Key); + actual.OutputExecutorIds[kvp.Key].Should().BeEquivalentTo(kvp.Value); + } void ValidateExecutorDictionary(Dictionary expected, Dictionary> expectedEdges, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputTagTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputTagTests.cs new file mode 100644 index 0000000000..da100baadf --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputTagTests.cs @@ -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("\"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 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("\"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("\"intermediate\"", WorkflowsJsonUtilities.DefaultOptions); + back.Should().Be(OutputTag.Intermediate); + + OutputTag fromUnknown = JsonSerializer.Deserialize("\"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(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs index 102f0114a2..bc56a5ecef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -455,4 +455,112 @@ public partial class WorkflowBuilderTests /// 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 }); + } }