mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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>
This commit is contained in:
committed by
Jacob Alber
co-authored by
Copilot
parent
9eb8f70c95
commit
59b6e1f6e0
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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
|
||||
{
|
||||
private static bool s_enableAgentResponseOutputTaggingAndFiltering;
|
||||
|
||||
/// <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>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static bool EnableAgentResponseOutputTaggingAndFiltering
|
||||
{
|
||||
get => s_enableAgentResponseOutputTaggingAndFiltering;
|
||||
set => s_enableAgentResponseOutputTaggingAndFiltering = value;
|
||||
}
|
||||
}
|
||||
@@ -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 normalized 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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.
|
||||
// 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
|
||||
{
|
||||
/// <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 +56,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 HashSet<OutputTag> Tags { get; }
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user