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 });
+ }
}