mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
59b6e1f6e0
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>
122 lines
4.7 KiB
C#
122 lines
4.7 KiB
C#
// 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");
|
|
}
|
|
}
|