mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* test: reshuffle .NET Workflow tests in preparation for Outputs overhaul Phase 1 of the .NET Workflows outputs overhaul (see working/implementation-plan.md). Pure moves/renames in dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests; no production code changes, no new test cases. The split keeps each orchestration mode in its own source file so the upcoming tag-aware and orchestration-default test additions land on clean diffs. Renames: * WorkflowBuilderSmokeTests.cs -> WorkflowBuilderTests.cs (with class rename to match). The scope is no longer "smoke"-only once subsequent phases add tag-aware builder tests. * InputWaiterAndOutputFilterTests.cs -> InputWaiterTests.cs + OutputFilterTests.cs. The file already declared the two test classes separately; this split simply gives each its own file so the output-filter cases have a dedicated home for tag-aware additions. Split of AgentWorkflowBuilderTests.cs: * AgentWorkflowBuilderTests.cs is now the outer `public static partial class AgentWorkflowBuilderTests` holding the shared test helpers (DoubleEchoAgent + session + WithBarrier variant, WorkflowRunResult, RunWorkflow* methods) bumped from `private` to `internal` so the new top-level GroupChatWorkflowBuilderTests in the same assembly can reach them. * AgentWorkflowBuilder.SequentialTests.cs (nested SequentialTests): BuildSequential_InvalidArguments_Throws, BuildSequential_AgentsRunInOrderAsync. * AgentWorkflowBuilder.ConcurrentTests.cs (nested ConcurrentTests): BuildConcurrent_InvalidArguments_Throws, BuildConcurrent_AgentsRunInParallelAsync. Sequential and Concurrent are kept as nested classes because they're modes of the same `AgentWorkflowBuilder` static factory and do not produce dedicated builder types. New file: * GroupChatWorkflowBuilderTests.cs (top-level): the existing BuildGroupChat_* and GroupChatManager_* cases moved out of the old AgentWorkflowBuilderTests file. They exercise the `GroupChatWorkflowBuilder` type (returned by `AgentWorkflowBuilder.CreateGroupChatBuilderWith`), so a dedicated top-level test class - matching the convention reserved by the plan for HandoffWorkflowBuilderTests / MagenticWorkflowBuilderTests - is the right home. Cross-class helper references qualify with `AgentWorkflowBuilderTests.DoubleEchoAgent` and `AgentWorkflowBuilderTests.RunWorkflowAsync`. The outer partial class is `static` (and nested classes carry the instance test methods) because the outer holds only static helpers; this satisfies CA1052 without suppressions and is invisible to xUnit discovery, which finds tests on the nested classes as `AgentWorkflowBuilderTests.SequentialTests.*` etc. Validation: `dotnet build` clean on both target frameworks; all 547 tests in Microsoft.Agents.AI.Workflows.UnitTests pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce OutputTag, Futures, and tag-aware WorkflowBuilder API Phase 2 of the .NET Workflows outputs overhaul. Additive code change only - no observable runtime behavior change. The runner still uses the legacy bypass for AgentResponse / AgentResponseUpdate payloads, and the new `Futures.EnableAgentResponseOutputTaggingAndFiltering` flag defaults to false. Phase 3 will wire the flag into the runner; this commit only introduces the types and the builder API. New public surface: * `OutputTag` (readonly struct): wraps a string Value with ordinal equality (IEquatable, GetHashCode, == / !=) so it can participate as a HashSet element. Internal ctor closes the set. One public singleton: `OutputTag.Intermediate`. Terminal / regular outputs carry no tag (empty Tags set). JSON-serialized as a bare string via [JsonConverter(typeof(OutputTagJsonConverter))], with the converter rehydrating to the well-known singleton on read. * `Futures` (static class): hosts opt-in pre-GA behavior switches. First flag is `EnableAgentResponseOutputTaggingAndFiltering`; XML doc captures the v2.0.0 obsoletion / v3.0.0 removal lifecycle. * `WorkflowOutputEvent.Tags`: `HashSet<OutputTag>` exposed directly (concrete collection, matches the JSON-serialization convention used for `WorkflowInfo.OutputExecutorIds`). Never null; empty for legacy / terminal events. New ctors take a single `OutputTag` or `IEnumerable<OutputTag>?`; the existing (data, executorId) ctor remains and produces an untagged event. `HasTag(OutputTag)` helper. `AgentResponseEvent` and `AgentResponseUpdateEvent` gain matching tag-accepting ctors forwarding to the base. * `WorkflowOutputEventExtensions.IsIntermediate(this WorkflowOutputEvent)`: extension method returning `evt.HasTag(OutputTag.Intermediate)`. The preferred way to ask "is this an intermediate output?" without reaching into the Tags set. * `WorkflowBuilder.WithOutputFrom(IEnumerable<ExecutorBinding>, OutputTag)` and `WorkflowBuilder.WithOutputFrom(ExecutorBinding, OutputTag)`: forward-looking tagged overloads. The IEnumerable form is the primary tagged surface; the single-executor form is a convenience for the common one-executor case. Currently usable for the `OutputTag.Intermediate` singleton; will become the primary surface once the `OutputTag` constructor is opened to user-defined tags in a future release. Callers in this release should prefer the intent-specific `WithIntermediateOutputFrom` extension for the intermediate case. Tags accumulate across repeated calls; same tag repeated dedupes via the HashSet. * `WorkflowBuilderExtensions.WithIntermediateOutputFrom(this WorkflowBuilder, IEnumerable<ExecutorBinding>)`: helper that forwards to `WithOutputFrom(executors, OutputTag.Intermediate)`. Takes an IEnumerable (matching the tagged WithOutputFrom shape) - callers pass collection literals: `builder.WithIntermediateOutputFrom([a, b])`. XML doc remarks call out the Futures-flag interaction and the AIAgent-payload forwarding contract. Internal shape changes: * `WorkflowBuilder._outputExecutors`: HashSet<string> -> Dictionary< string, HashSet<OutputTag>>. The value set is empty for executors designated only via the untagged WithOutputFrom; contains Intermediate (and possibly future tags) otherwise. * `Workflow.OutputExecutors`: HashSet<string> -> Dictionary<string, HashSet<OutputTag>>. * `OutputFilter.CanOutput`: `Contains(id)` -> `ContainsKey(id)`. * `WorkflowInfo.OutputExecutorIds`: HashSet<string> -> Dictionary< string, HashSet<OutputTag>>, with a custom JsonConverter that reads both the new map shape (`{id: ["intermediate", ...]}`) and the legacy array shape (`[id1, id2]`, where each id is treated as an untagged output). Always writes the map shape. IsMatch updated to compare per-id tag sets. Tests landing in this commit (per the test-with-feature principle): * `OutputTagTests.cs` (6 tests): KnownValues, EqualityIsOrdinalOnValue, DefaultStructValueIsDistinct (default(OutputTag) does not collide with the Intermediate singleton in a HashSet), GetHashCodeMatchesEquals, JsonConverter_RoundtripsValueAsString, ConstructorIsInternal (reflection-based assertion that the (string) ctor is `internal`). * `WorkflowBuilderTests.cs` adds 7 new tests pinning the builder API contract: RegistersWithEmptyTagSet, AddsIntermediateTag, MultipleExecutorsAllUntagged, ThenIntermediate_AccumulatesTags, RepeatedDedupes, OnlyRegistersWithoutPriorWithOutputFrom, TracksExecutorBinding. * `BackwardsCompatibility/JsonCheckpointSerializationTests.cs` (new folder + file, 5 tests): event-level ctor contract tests (single-tag, no-tag, multi-tag — the last with a custom tag); IsIntermediate() asserted; load-bearing JSON BC tests for `WorkflowInfo.OutputExecutorIds` - `WorkflowOutputExecutorsReadsLegacyArrayShape` (legacy ids map to empty tag sets) and `WorkflowOutputExecutorsWritesMapShape`. The plan's three JSON round-trip tests for `WorkflowOutputEvent.Tags` were dropped: `WorkflowEvent` is not currently a serialized checkpoint shape (see the comment in WorkflowsJsonUtilities.cs about events not being persisted), so there is no real back-compat surface to pin through JSON. They are substituted with in-process ctor/property round-trip tests that exercise the `Tags` / `HasTag` / `IsIntermediate` contract. Validation: full `Microsoft.Agents.AI.Workflows.UnitTests` suite runs green on net10.0 (565 passing, 0 failing). Core library builds clean on net472, netstandard2.0, net8.0, net9.0, and net10.0. Test project builds clean on net472 + net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: route AgentResponse(Update) through the output filter under a Futures flag `InProcessRunnerContext.YieldOutputAsync` historically special-cased AgentResponse and AgentResponseUpdate payloads: it built the typed event subclass and emitted it directly, bypassing the output filter. Rewrites the method so that: - When `Futures.EnableAgentResponseOutputTaggingAndFiltering` is `false` (the current default), AgentResponse(Update) keep the legacy bypass — emitted as AgentResponseEvent / AgentResponseUpdateEvent with no tags. Existing callers see no behavior change. - When the flag is `true`, AIAgent payloads flow through the output filter just like every other payload type: undesignated sources are dropped, and the emitted event carries the source's tag set (empty for terminal `WithOutputFrom`, `{Intermediate}` for `WithIntermediateOutputFrom`, the set union when both designations apply). Non-AIAgent (POCO) outputs also now carry the source's tag set on the emitted WorkflowOutputEvent unconditionally — additive, since no existing assertion inspected Tags. Subclass events (`AgentResponseEvent` / `AgentResponseUpdateEvent`) continue to be emitted under both modes so `switch (evt) { case AgentResponseEvent: ... }` consumer code keeps matching. Adds `OutputFilter.TryGetTags` as the tag-aware lookup used by the runner. `OutputFilter.CanOutput` is kept (still used by the existing sync tests in `OutputFilterTests.cs`). Tests ----- - `Futures/Futures.AgentResponseOutputFilteringAndTaggingTests.cs` (new): the F1–F13 matrix from the plan, covering every combination of `(flag on/off) × (designation) × (payload shape)`. Uses a `FuturesScope` IDisposable + a `FuturesSerial` xUnit collection (DisableParallelization = true) to keep the process-global flag from leaking across parallel tests. - `OutputFilterTests.cs`: four new `Test_OutputFilter_…` cases for the `TryGetTags` surface (empty-tag-set for terminal designation, `{Intermediate}` for intermediate designation, union for accumulated designation, `false` for unregistered). 582/582 unit tests pass on net10.0 (565 baseline + 17 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: tag-aware defaults and designation API on orchestration builders Aligns the .NET orchestration builders with Python's output / intermediate-output distinction. Each builder either applies a Python-aligned default designation set or replays the user's explicit `WithOutputFrom` / `WithIntermediateOutputFrom` calls, never both. Static `AgentWorkflowBuilder.BuildSequential` / `BuildConcurrent` apply defaults unconditionally (no user-facing fluent surface to take control through): - Sequential: terminal `end` + every agent designated intermediate. - Concurrent: terminal `end` + every agent and per-agent accumulator designated intermediate. The three fluent instance builders memoize agent-typed designation calls in a `Dictionary<AIAgent, HashSet<OutputTag>>` (empty set = terminal-only, non-empty = intermediate tag(s)) so repeated calls dedupe naturally. They replay the entries at `Build()` time, suppressing defaults when any call has been made: - `HandoffWorkflowBuilder` / `HandoffWorkflowBuilderCore<TBuilder>` (also picked up by the obsolete `HandoffsWorkflowBuilder` via inheritance). Default: terminal `HandoffEnd` + every handoff agent intermediate. (Bug fix: legacy code relied on `WithOutputFrom(end)` to bind `HandoffEnd`. The new explicit-designation path bypasses that, so `Build()` now calls `BindExecutor(end)` unconditionally to keep validation happy.) - `GroupChatWorkflowBuilder` — default: terminal host + every participant intermediate. - `MagenticWorkflowBuilder` — default: terminal orchestrator + every team member intermediate. Designating a non-participant agent throws `InvalidOperationException`. The bare `WorkflowBuilder` default is unchanged — only the orchestration-style builders gain implicit defaults, matching the plan's non-goal. Tests ----- - `AgentWorkflowBuilder.SequentialTests` / `.ConcurrentTests`: one default-spec assertion each. - `GroupChatWorkflowBuilderTests`: defaults-match-spec, explicit-replaces-defaults, non-participant throws. - `HandoffWorkflowBuilderTests` (new file): same three. - `MagenticWorkflowBuilderTests` (new file): same three. 593/593 unit tests pass on net10.0 (582 baseline + 11 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: WorkflowHostAgent forwards AgentResponseEvent unconditionally under Futures-on Aligns the .NET Workflow-as-Agent surface with Python `as_agent`. Under `Futures.EnableAgentResponseOutputTaggingAndFiltering = true`, `WorkflowSession.InvokeStageAsync` now forwards `AgentResponseEvent` unconditionally — joining `AgentResponseUpdateEvent` in ignoring the host's `includeWorkflowOutputsInResponse` switch. That switch keeps governing the generic `WorkflowOutputEvent` path for non-AIAgent payloads, where it is further short-circuited by an `IsIntermediate()` check (tagged intermediate outputs always surface). Under Futures-off the legacy asymmetry is preserved: `AgentResponseUpdateEvent` always forwarded, `AgentResponseEvent` gated by `includeWorkflowOutputsInResponse`. Back-compat: with `Futures.EnableAgentResponseOutputTaggingAndFiltering` left at its default `false`, observable behavior is identical to before. `Futures` documentation gains a remark explaining the `Workflow.AsAIAgent()` interaction in both flag states. Runner fix ---------- `InProcessRunnerContext.YieldOutputAsync` now skips `Executor.CanOutput` for AgentResponse-shaped payloads under both Futures branches. `AIAgentHostExecutor` doesn't declare AgentResponse(Update) in its `Yields` set, so the historical legacy bypass had silently skipped the check; Phase 3's Futures-on path was running it and would reject AIAgent payloads. AIAgent-shaped payloads are now always a valid output shape, matching the legacy bypass semantics. Phase 4 follow-on ----------------- Switched the three orchestration-builder designation-replay loops to iterate `Dictionary.Keys` with a value lookup instead of constructing/destructuring `KeyValuePair<,>`. Cleaner shape and avoids the netstandard2.0 / net472 `KeyValuePair<,>.Deconstruct` unavailability that surfaced when this branch multi-TFM-built. Tests ----- `WorkflowHostSmokeTests.IntermediateForwarding` (new nested class, 6 tests): - intermediate AgentResponse forwarded past the include-outputs gate (Futures on) - terminal AgentResponse forwarded unconditionally (Futures on) - terminal AgentResponse gated by include flag (Futures off, legacy) - undesignated AIAgent executor emits no AgentResponseEvent under Futures-on - legacy bypass still emits AgentResponseEvent under Futures-off - intermediate tag is observable via `update.RawRepresentation` The class joins the `FuturesSerial` xUnit collection so the process-global flag is serialized against other Futures-toggling tests. 599/599 unit tests pass on net10.0 (593 baseline + 6 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: SequentialWorkflowBuilder and ConcurrentWorkflowBuilder, OrchestrationBuilderBase Promotes the Sequential and Concurrent orchestration shapes to first-class fluent builder classes, matching Handoff / GroupChat / Magentic. Users can call `WithOutputFrom(agents)` / `WithIntermediateOutputFrom(agents)` to control which agents are designated output / intermediate sources; when no designation call is made, the Python-aligned defaults apply (terminal aggregator output + every agent intermediate; Concurrent also tags per-agent accumulators). `AgentWorkflowBuilder.BuildSequential(...)` and `BuildConcurrent(...)` are kept and now delegate to the new builders; observable behavior unchanged. Five static factories now mirror each other: - `AgentWorkflowBuilder.CreateSequentialBuilderWith(params IEnumerable<AIAgent>)` - `AgentWorkflowBuilder.CreateConcurrentBuilderWith(params IEnumerable<AIAgent>)` - `AgentWorkflowBuilder.CreateHandoffBuilderWith(AIAgent)` (already existed) - `AgentWorkflowBuilder.CreateGroupChatBuilderWith(Func<...>)` (already existed) - `AgentWorkflowBuilder.CreateMagenticBuilderWith(AIAgent)` (new) OrchestrationBuilderBase ------------------------ New abstract `OrchestrationBuilderBase<TBuilder>` unifies the shared fluent surface across all five orchestration builders: `WithName`, `WithDescription`, `WithOutputFrom`, `WithIntermediateOutputFrom`, and the `ApplyOutputDesignations(builder, agentMap, kind, applyDefaults)` helper that either replays the user's designations or invokes the orchestration-specific defaults. Removes ~150 LOC of duplicated designation-management code from the four non-Handoff builders, plus the equivalent from `HandoffWorkflowBuilderCore`. Tests ----- - New `SequentialWorkflowBuilderTests.cs` / `ConcurrentWorkflowBuilderTests.cs` (replace the old `AgentWorkflowBuilder.{Sequential,Concurrent}Tests.cs` nested-class files). Method names normalized to `Test_<BuilderType>_<Scenario>[Async]`. - Shared helpers (`DoubleEchoAgent`, `DoubleEchoAgentWithBarrier`, `WorkflowRunResult`, `RunWorkflow*`) moved from the old `AgentWorkflowBuilderTests` partial class into a new `OrchestrationTestHelpers` static class in `OrchestrationTestHelpers.cs`. Downstream test files (Group Chat, Handoff, Sequential, Concurrent) updated to qualify with `OrchestrationTestHelpers.*`. - A new `AgentWorkflowBuilderTests.cs` covers the static surface directly: `BuildSequential` / `BuildConcurrent` invariants and aggregator wiring, plus null-rejection + round-trip checks for every `Create*BuilderWith` factory. - New AsAgent intermediate-suppression tests on a nested `AsAgentForwarding` class for each of Sequential and Concurrent: build with only the terminal agent designated via `WithOutputFrom`, run via `AsAIAgent(...)`, assert via `AgentResponseUpdate.AuthorName` that intermediate agents do not surface. Both join the `FuturesSerial` collection. - New `Test_<Builder>_WithDescriptionPropagatesToWorkflow` smoke tests on Sequential and Concurrent (newly available via the base class). 625/625 unit tests pass on net10.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: dotnet format * fixup: encoding * fixup: charset * fixup: Updates for PR feedback * fixup: format * fixup: merge issue * Fix intermediate filtering on .AsAgent() * fix filter logic * fix: Revert logic change and add comments --------- Co-authored-by: Jacob Alber <jalber@lokitoth.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
524 lines
22 KiB
C#
524 lines
22 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
|
using Microsoft.Agents.AI.Workflows.Execution;
|
|
using Microsoft.Agents.AI.Workflows.Observability;
|
|
using Microsoft.Agents.AI.Workflows.Specialized;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Shared.Diagnostics;
|
|
using OpenTelemetry;
|
|
using OpenTelemetry.Context.Propagation;
|
|
|
|
namespace Microsoft.Agents.AI.Workflows.InProc;
|
|
|
|
internal sealed class InProcessRunnerContext : IRunnerContext
|
|
{
|
|
private int _runEnded;
|
|
private readonly string _sessionId;
|
|
private readonly Workflow _workflow;
|
|
private readonly object? _previousOwnership;
|
|
private bool _ownsWorkflow;
|
|
|
|
private readonly EdgeMap _edgeMap;
|
|
private readonly OutputFilter _outputFilter;
|
|
|
|
private StepContext _nextStep = new();
|
|
|
|
private readonly ConcurrentDictionary<string, Task<Executor>> _executors = new();
|
|
private readonly ConcurrentQueue<Func<ValueTask>> _queuedExternalDeliveries = new();
|
|
private readonly ConcurrentDictionary<string, ISuperStepRunner> _joinedSubworkflowRunners = new();
|
|
|
|
private readonly ConcurrentDictionary<string, ExternalRequest> _externalRequests = new();
|
|
|
|
public InProcessRunnerContext(
|
|
Workflow workflow,
|
|
string sessionId,
|
|
bool checkpointingEnabled,
|
|
IEventSink outgoingEvents,
|
|
IStepTracer? stepTracer,
|
|
object? existingOwnershipSignoff = null,
|
|
bool subworkflow = false,
|
|
bool enableConcurrentRuns = false,
|
|
ILogger? logger = null)
|
|
{
|
|
if (enableConcurrentRuns)
|
|
{
|
|
workflow.CheckOwnership(existingOwnershipSignoff: existingOwnershipSignoff);
|
|
}
|
|
else
|
|
{
|
|
workflow.TakeOwnership(this, existingOwnershipSignoff: existingOwnershipSignoff);
|
|
this._previousOwnership = existingOwnershipSignoff;
|
|
this._ownsWorkflow = true;
|
|
}
|
|
|
|
this._workflow = workflow;
|
|
this._sessionId = sessionId;
|
|
|
|
this._edgeMap = new(this, this._workflow, stepTracer);
|
|
this._outputFilter = new(workflow);
|
|
|
|
this.IsCheckpointingEnabled = checkpointingEnabled;
|
|
this.ConcurrentRunsEnabled = enableConcurrentRuns;
|
|
this.OutgoingEvents = outgoingEvents;
|
|
}
|
|
public WorkflowTelemetryContext TelemetryContext => this._workflow.TelemetryContext;
|
|
|
|
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
|
|
{
|
|
if (!this._edgeMap.TryRegisterPort(this, executorId, port))
|
|
{
|
|
throw new InvalidOperationException($"A port with ID {port.Id} already exists.");
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
Task<Executor> executorTask = this._executors.GetOrAdd(executorId, CreateExecutorAsync);
|
|
|
|
async Task<Executor> CreateExecutorAsync(string id)
|
|
{
|
|
if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration))
|
|
{
|
|
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
|
|
}
|
|
|
|
Executor executor = await registration.CreateInstanceAsync(this._sessionId).ConfigureAwait(false);
|
|
executor.AttachRequestContext(this.BindExternalRequestContext(executorId));
|
|
|
|
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
tracer?.TraceActivated(executorId);
|
|
|
|
if (executor is RequestInfoExecutor requestInputExecutor)
|
|
{
|
|
requestInputExecutor.AttachRequestSink(this);
|
|
}
|
|
|
|
if (executor is WorkflowHostExecutor workflowHostExecutor)
|
|
{
|
|
await workflowHostExecutor.AttachSuperStepContextAsync(this).ConfigureAwait(false);
|
|
}
|
|
|
|
return executor;
|
|
}
|
|
|
|
return await executorTask.ConfigureAwait(false);
|
|
}
|
|
|
|
public async ValueTask<IEnumerable<Type>> GetStartingExecutorInputTypesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
Executor startingExecutor = await this.EnsureExecutorAsync(this._workflow.StartExecutorId, tracer: null, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return startingExecutor.InputTypes;
|
|
}
|
|
|
|
public ValueTask AddExternalMessageAsync(object message, Type declaredType)
|
|
{
|
|
this.CheckEnded();
|
|
Throw.IfNull(message);
|
|
|
|
this._queuedExternalDeliveries.Enqueue(PrepareExternalDeliveryAsync);
|
|
return default;
|
|
|
|
async ValueTask PrepareExternalDeliveryAsync()
|
|
{
|
|
DeliveryMapping? maybeMapping =
|
|
await this._edgeMap.PrepareDeliveryForInputAsync(new(message, ExecutorIdentity.None, declaredType))
|
|
.ConfigureAwait(false);
|
|
|
|
maybeMapping?.MapInto(this._nextStep);
|
|
}
|
|
}
|
|
|
|
public ValueTask AddExternalResponseAsync(ExternalResponse response)
|
|
{
|
|
this.CheckEnded();
|
|
Throw.IfNull(response);
|
|
|
|
this._queuedExternalDeliveries.Enqueue(PrepareExternalDeliveryAsync);
|
|
return default;
|
|
|
|
async ValueTask PrepareExternalDeliveryAsync()
|
|
{
|
|
if (!this.CompleteRequest(response.RequestId))
|
|
{
|
|
throw new InvalidOperationException($"No pending request with ID {response.RequestId} found in the workflow context.");
|
|
}
|
|
|
|
DeliveryMapping? maybeMapping =
|
|
await this._edgeMap.PrepareDeliveryForResponseAsync(response)
|
|
.ConfigureAwait(false);
|
|
|
|
maybeMapping?.MapInto(this._nextStep);
|
|
}
|
|
}
|
|
|
|
public bool HasQueuedExternalDeliveries => !this._queuedExternalDeliveries.IsEmpty;
|
|
public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnprocessedMessages);
|
|
|
|
public bool NextStepHasActions => this._nextStep.HasMessages ||
|
|
this.HasQueuedExternalDeliveries ||
|
|
this.JoinedRunnersHaveActions;
|
|
public bool HasUnservicedRequests => !this._externalRequests.IsEmpty ||
|
|
this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnservicedRequests);
|
|
|
|
public async ValueTask<StepContext> AdvanceAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
|
|
while (this._queuedExternalDeliveries.TryDequeue(out var deliveryPrep))
|
|
{
|
|
// It's important we do not try to run these in parallel, because they may be modifying
|
|
// inner edge state, etc.
|
|
await deliveryPrep().ConfigureAwait(false);
|
|
}
|
|
|
|
return Interlocked.Exchange(ref this._nextStep, new StepContext());
|
|
}
|
|
|
|
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
return this.OutgoingEvents.EnqueueAsync(workflowEvent);
|
|
}
|
|
|
|
public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
using Activity? activity = this._workflow.TelemetryContext.StartMessageSendActivity(sourceId, targetId, message);
|
|
|
|
// Create a carrier for trace context propagation
|
|
var traceContext = activity is null ? null : new Dictionary<string, string>();
|
|
if (traceContext is not null)
|
|
{
|
|
// Inject the current activity context into the carrier
|
|
Propagators.DefaultTextMapPropagator.Inject(
|
|
new PropagationContext(activity?.Context ?? default, Baggage.Current),
|
|
traceContext,
|
|
(carrier, key, value) => carrier[key] = value);
|
|
}
|
|
|
|
this.CheckEnded();
|
|
|
|
Debug.Assert(this._executors.ContainsKey(sourceId));
|
|
Executor source = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
|
|
TypeId? declaredType = source.Protocol.SendTypeTranslator.GetDeclaredType(message.GetType());
|
|
if (declaredType is null)
|
|
{
|
|
throw new InvalidOperationException($"Executor '{sourceId}' cannot send messages of type '{message.GetType().FullName}'.");
|
|
}
|
|
|
|
MessageEnvelope envelope = new(message, sourceId, declaredType, targetId: targetId, traceContext: traceContext);
|
|
|
|
if (this._workflow.Edges.TryGetValue(sourceId, out HashSet<Edge>? edges))
|
|
{
|
|
foreach (Edge edge in edges)
|
|
{
|
|
DeliveryMapping? maybeMapping =
|
|
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
maybeMapping?.MapInto(this._nextStep);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async ValueTask YieldOutputAsync(string sourceId, object output, CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
Throw.IfNull(output);
|
|
|
|
bool isAgentResponseShaped = output is AgentResponse or AgentResponseUpdate;
|
|
|
|
if (isAgentResponseShaped && !Futures.EnableAgentResponseOutputTaggingAndFiltering)
|
|
{
|
|
// Legacy bypass: AgentResponse/AgentResponseUpdate skip the output filter and are
|
|
// emitted as their typed event subclasses with no tags. Preserved verbatim for
|
|
// back-compat; once Futures.EnableAgentResponseOutputTaggingAndFiltering becomes the
|
|
// default in v2.0.0, this branch goes away.
|
|
WorkflowEvent typedEvent = output switch
|
|
{
|
|
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u),
|
|
AgentResponse r => new AgentResponseEvent(sourceId, r),
|
|
_ => throw new InvalidOperationException("Unexpected AIAgent-shaped payload type."),
|
|
};
|
|
await this.AddEventAsync(typedEvent, cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
Executor sourceExecutor = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
|
|
if (!isAgentResponseShaped && !sourceExecutor.CanOutput(output.GetType()))
|
|
{
|
|
// AIAgent-shaped payloads bypass the per-executor declared-yield check (matching the
|
|
// legacy bypass branch above). The AIAgent host executor relays the agent's output
|
|
// without declaring AgentResponse(Update) in its Yields set, so a CanOutput probe
|
|
// here would always reject — but those payloads are always a valid output shape.
|
|
throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}].");
|
|
}
|
|
|
|
if (!this._outputFilter.TryGetTags(sourceId, out HashSet<OutputTag>? tags))
|
|
{
|
|
// Not designated as an output source — drop silently.
|
|
return;
|
|
}
|
|
|
|
WorkflowOutputEvent evt = output switch
|
|
{
|
|
AgentResponseUpdate u => new AgentResponseUpdateEvent(sourceId, u, tags),
|
|
AgentResponse r => new AgentResponseEvent(sourceId, r, tags),
|
|
_ => new WorkflowOutputEvent(output, sourceId, tags),
|
|
};
|
|
await this.AddEventAsync(evt, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public IExternalRequestContext BindExternalRequestContext(string executorId)
|
|
{
|
|
this.CheckEnded();
|
|
return new BoundExternalRequestContext(this, executorId);
|
|
}
|
|
|
|
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
|
|
{
|
|
this.CheckEnded();
|
|
return new BoundWorkflowContext(this, executorId, traceContext);
|
|
}
|
|
|
|
public ValueTask PostAsync(ExternalRequest request)
|
|
{
|
|
this.CheckEnded();
|
|
if (!this._externalRequests.TryAdd(request.RequestId, request))
|
|
{
|
|
throw new ArgumentException($"Pending request with id '{request.RequestId}' already exists.");
|
|
}
|
|
|
|
return this.AddEventAsync(new RequestInfoEvent(request));
|
|
}
|
|
|
|
public bool CompleteRequest(string requestId)
|
|
{
|
|
this.CheckEnded();
|
|
return this._externalRequests.TryRemove(requestId, out _);
|
|
}
|
|
|
|
internal bool TryGetResponsePortExecutorId(string portId, [NotNullWhen(true)] out string? executorId)
|
|
=> this._edgeMap.TryGetResponsePortExecutorId(portId, out executorId);
|
|
|
|
private IEventSink OutgoingEvents { get; }
|
|
|
|
internal StateManager StateManager { get; } = new();
|
|
|
|
private sealed class BoundExternalRequestContext(
|
|
InProcessRunnerContext RunnerContext,
|
|
string ExecutorId) : IExternalRequestContext
|
|
{
|
|
public IExternalRequestSink RegisterPort(RequestPort port)
|
|
{
|
|
return RunnerContext.RegisterPort(ExecutorId, port);
|
|
}
|
|
}
|
|
|
|
private sealed class BoundWorkflowContext(
|
|
InProcessRunnerContext RunnerContext,
|
|
string ExecutorId,
|
|
Dictionary<string, string>? traceContext) : IWorkflowContext
|
|
{
|
|
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => RunnerContext.AddEventAsync(workflowEvent, cancellationToken);
|
|
|
|
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
return RunnerContext.SendMessageAsync(ExecutorId, Throw.IfNull(message), targetId, cancellationToken);
|
|
}
|
|
|
|
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
|
|
{
|
|
return RunnerContext.YieldOutputAsync(ExecutorId, Throw.IfNull(output), cancellationToken);
|
|
}
|
|
|
|
public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent());
|
|
|
|
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
=> RunnerContext.StateManager.ReadStateAsync<T>(ExecutorId, scopeName, key);
|
|
|
|
[return: NotNull]
|
|
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
=> RunnerContext.StateManager.ReadOrInitStateAsync(ExecutorId, scopeName, key, initialStateFactory);
|
|
|
|
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
|
=> RunnerContext.StateManager.ReadKeysAsync(ExecutorId, scopeName);
|
|
|
|
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
|
=> RunnerContext.StateManager.WriteStateAsync(ExecutorId, scopeName, key, value);
|
|
|
|
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
|
=> RunnerContext.StateManager.ClearStateAsync(ExecutorId, scopeName);
|
|
|
|
public IReadOnlyDictionary<string, string>? TraceContext => traceContext;
|
|
|
|
public bool ConcurrentRunsEnabled => RunnerContext.ConcurrentRunsEnabled;
|
|
}
|
|
|
|
public bool IsCheckpointingEnabled { get; }
|
|
public bool ConcurrentRunsEnabled { get; }
|
|
|
|
internal Task PrepareForCheckpointAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
|
|
return Task.WhenAll(this._executors.Values.Select(InvokeCheckpointingAsync));
|
|
|
|
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
|
|
{
|
|
Executor executor = await executorTask.ConfigureAwait(false);
|
|
await executor.OnCheckpointingAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
internal Task NotifyCheckpointLoadedAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
|
|
return Task.WhenAll(this._executors.Values.Select(InvokeCheckpointRestoredAsync));
|
|
|
|
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
|
|
{
|
|
Executor executor = await executorTask.ConfigureAwait(false);
|
|
await executor.OnCheckpointRestoredAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
internal ValueTask<RunnerStateData> ExportStateAsync()
|
|
{
|
|
this.CheckEnded();
|
|
|
|
Dictionary<string, List<PortableMessageEnvelope>> queuedMessages = this._nextStep.ExportMessages();
|
|
RunnerStateData result = new(instantiatedExecutors: [.. this._executors.Keys],
|
|
queuedMessages,
|
|
outstandingRequests: [.. this._externalRequests.Values]);
|
|
|
|
return new(result);
|
|
}
|
|
|
|
internal async ValueTask RepublishUnservicedRequestsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
this.CheckEnded();
|
|
|
|
if (this.HasUnservicedRequests)
|
|
{
|
|
foreach (string requestId in this._externalRequests.Keys)
|
|
{
|
|
await this.AddEventAsync(new RequestInfoEvent(this._externalRequests[requestId]), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal async ValueTask ImportStateAsync(Checkpoint checkpoint)
|
|
{
|
|
this.CheckEnded();
|
|
|
|
RunnerStateData importedState = checkpoint.RunnerData;
|
|
|
|
Task<Executor>[] executorTasks = importedState.InstantiatedExecutors
|
|
.Where(id => !this._executors.ContainsKey(id))
|
|
.Select(id => this.EnsureExecutorAsync(id, tracer: null).AsTask())
|
|
.ToArray();
|
|
|
|
// Discard queued external deliveries from the superseded timeline so a runtime
|
|
// restore cannot apply stale responses after importing the checkpoint state.
|
|
while (this._queuedExternalDeliveries.TryDequeue(out _))
|
|
{
|
|
}
|
|
|
|
this._nextStep = new StepContext();
|
|
this._nextStep.ImportMessages(importedState.QueuedMessages);
|
|
|
|
this._externalRequests.Clear();
|
|
|
|
foreach (ExternalRequest request in importedState.OutstandingRequests)
|
|
{
|
|
// TODO: Reduce the amount of data we need to store in the checkpoint by not storing the entire request object.
|
|
// For example, the Port object is not needed - we should be able to reconstruct it from the ID and the workflow
|
|
// definition.
|
|
this._externalRequests[request.RequestId] = request;
|
|
}
|
|
|
|
await Task.WhenAll(executorTasks).ConfigureAwait(false);
|
|
}
|
|
|
|
[SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
|
|
Justification = "Does not exist in NetFx 4.7.2")]
|
|
internal void CheckEnded()
|
|
{
|
|
if (Volatile.Read(ref this._runEnded) == 1)
|
|
{
|
|
throw new InvalidOperationException($"Workflow run for session '{this._sessionId}' has been ended. Please start a new Run or StreamingRun.");
|
|
}
|
|
}
|
|
|
|
public async ValueTask EndRunAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref this._runEnded, 1) == 0)
|
|
{
|
|
foreach (string executorId in this._executors.Keys)
|
|
{
|
|
Task<Executor> executorTask = this._executors[executorId];
|
|
Executor executor = await executorTask.ConfigureAwait(false);
|
|
|
|
if (executor is IAsyncDisposable asyncDisposable)
|
|
{
|
|
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
else if (executor is IDisposable disposable)
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
}
|
|
|
|
if (this._ownsWorkflow)
|
|
{
|
|
await this._workflow.ReleaseOwnershipAsync(this, this._previousOwnership).ConfigureAwait(false);
|
|
this._ownsWorkflow = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public IEnumerable<ISuperStepRunner> JoinedSubworkflowRunners => this._joinedSubworkflowRunners.Values;
|
|
|
|
public ValueTask<string> AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default)
|
|
{
|
|
// This needs to be a thread-safe ordered collection because we can potentially instantiate executors
|
|
// in parallel, which means multiple sub-workflows could be attaching at the same time.
|
|
string joinId;
|
|
do
|
|
{
|
|
joinId = Guid.NewGuid().ToString("N");
|
|
} while (!this._joinedSubworkflowRunners.TryAdd(joinId, superStepRunner));
|
|
|
|
return default;
|
|
}
|
|
|
|
public ValueTask<bool> DetachSuperstepAsync(string joinId) => new(this._joinedSubworkflowRunners.TryRemove(joinId, out _));
|
|
|
|
ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken)
|
|
=> this.AddEventAsync(workflowEvent, cancellationToken);
|
|
|
|
ValueTask ISuperStepJoinContext.SendMessageAsync<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken)
|
|
=> this.SendMessageAsync(senderId, Throw.IfNull(message), cancellationToken: cancellationToken);
|
|
|
|
ValueTask ISuperStepJoinContext.YieldOutputAsync<TOutput>(string senderId, [DisallowNull] TOutput output, CancellationToken cancellationToken)
|
|
=> this.YieldOutputAsync(senderId, Throw.IfNull(output), cancellationToken);
|
|
}
|