diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs
index 9e96bc3ea3..b38b5167bb 100644
--- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs
+++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs
@@ -9,9 +9,9 @@ namespace WorkflowCheckpointWithHumanInTheLoopSample;
/// checkpointing support. The workflow plays a number guessing game where the user provides
/// guesses based on feedback from the workflow. The workflow state is checkpointed at the end
/// of each super step, allowing it to be restored and resumed later.
-/// Each InputPort request and response cycle takes two super steps:
-/// 1. The InputPort sends a RequestInfoEvent to request input from the external world.
-/// 2. The external world sends a response back to the InputPort.
+/// Each RequestPort request and response cycle takes two super steps:
+/// 1. The RequestPort sends a RequestInfoEvent to request input from the external world.
+/// 2. The external world sends a response back to the RequestPort.
/// Thus, two checkpoints are created for each human-in-the-loop interaction.
///
///
diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs
index 1a7f51d33c..42f77ae387 100644
--- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs
+++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs
@@ -14,13 +14,13 @@ internal static class WorkflowHelper
internal static ValueTask> GetWorkflowAsync()
{
// Create the executors
- InputPort numberInputPort = InputPort.Create("GuessNumber");
+ RequestPort numberRequest = RequestPort.Create("GuessNumber");
JudgeExecutor judgeExecutor = new(42);
// Build the workflow by connecting executors in a loop
- return new WorkflowBuilder(numberInputPort)
- .AddEdge(numberInputPort, judgeExecutor)
- .AddEdge(judgeExecutor, numberInputPort)
+ return new WorkflowBuilder(numberRequest)
+ .AddEdge(numberRequest, judgeExecutor)
+ .AddEdge(judgeExecutor, numberRequest)
.WithOutputFrom(judgeExecutor)
.BuildAsync();
}
diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs
index 14ad717049..1da9fa37ae 100644
--- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs
+++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs
@@ -5,10 +5,10 @@ using Microsoft.Agents.AI.Workflows;
namespace WorkflowHumanInTheLoopBasicSample;
///
-/// This sample introduces the concept of InputPort and ExternalRequest to enable
+/// This sample introduces the concept of RequestPort and ExternalRequest to enable
/// human-in-the-loop interaction scenarios.
-/// An input port can be used as if it were an executor in the workflow graph. Upon receiving
-/// a message, the input port generates an RequestInfoEvent that gets emitted to the external world.
+/// A request port can be used as if it were an executor in the workflow graph. Upon receiving
+/// a message, the request port generates an RequestInfoEvent that gets emitted to the external world.
/// The external world can then respond to the request by sending an ExternalResponse back to
/// the workflow.
/// The sample implements a simple number guessing game where the external user tries to guess
diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs
index 79e547693c..8f0f8c7b35 100644
--- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs
+++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs
@@ -14,13 +14,13 @@ internal static class WorkflowHelper
internal static ValueTask> GetWorkflowAsync()
{
// Create the executors
- InputPort numberInputPort = InputPort.Create("GuessNumber");
+ RequestPort numberRequestPort = RequestPort.Create("GuessNumber");
JudgeExecutor judgeExecutor = new(42);
// Build the workflow by connecting executors in a loop
- return new WorkflowBuilder(numberInputPort)
- .AddEdge(numberInputPort, judgeExecutor)
- .AddEdge(judgeExecutor, numberInputPort)
+ return new WorkflowBuilder(numberRequestPort)
+ .AddEdge(numberRequestPort, judgeExecutor)
+ .AddEdge(judgeExecutor, numberRequestPort)
.WithOutputFrom(judgeExecutor)
.BuildAsync();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/InputPortAction.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/RequestPortAction.cs
similarity index 56%
rename from dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/InputPortAction.cs
rename to dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/RequestPortAction.cs
index 5f8d691efe..78f33a566e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/InputPortAction.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/RequestPortAction.cs
@@ -2,8 +2,8 @@
namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
-internal sealed class InputPortAction(InputPort port) : IModeledAction
+internal sealed class RequestPortAction(RequestPort port) : IModeledAction
{
public string Id => port.Id;
- public InputPort InputPort => port;
+ public RequestPort RequestPort => port;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
index 172f156371..33cd833811 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs
@@ -252,7 +252,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Define input action
string inputId = QuestionExecutor.Steps.Input(actionId);
- InputPortAction inputPort = new(InputPort.Create(inputId));
+ RequestPortAction inputPort = new(RequestPort.Create(inputId));
this._workflowModel.AddNode(inputPort, parentId);
this._workflowModel.AddLinkFromPeer(parentId, inputId);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs
index dfac6e0543..1d3826371d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowModelBuilder.cs
@@ -27,7 +27,7 @@ internal sealed class WorkflowModelBuilder : IModelBuilder>
private static ExecutorIsh GetExecutorIsh(IModeledAction action) =>
action switch
{
- InputPortAction port => port.InputPort,
+ RequestPortAction port => port.RequestPort,
Executor executor => executor,
_ => throw new DeclarativeModelException($"Unsupported modeled action: {action.GetType().Name}.")
};
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs
new file mode 100644
index 0000000000..99f8a1fa62
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Execution;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+internal sealed class AsyncBarrier()
+{
+ private readonly InitLocked> _completionSource = new();
+
+ public async ValueTask JoinAsync(CancellationToken cancellation = default)
+ {
+ this._completionSource.Init(() => new TaskCompletionSource
+[DebuggerDisplay("[{Data.Id}]: {Kind}Edge({Data.Connection})")]
public sealed class Edge
{
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeId.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeId.cs
index 53957cdfec..75d384beaa 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeId.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeId.cs
@@ -56,4 +56,7 @@ public readonly struct EdgeId : IEquatable
///
public static bool operator !=(EdgeId left, EdgeId right) => !left.Equals(right);
+
+ ///
+ public override string ToString() => this.EdgeIndex.ToString();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs
new file mode 100644
index 0000000000..a983126c44
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Checkpointing;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal sealed class AsyncRunHandle : ICheckpointingHandle, IAsyncDisposable, IInputCoordinator
+{
+ private readonly AsyncCoordinator _waitForResponseCoordinator = new();
+ private readonly ISuperStepRunner _stepRunner;
+ private readonly ICheckpointingHandle _checkpointingHandle;
+
+ private readonly LockstepRunEventStream _eventStream;
+ private readonly CancellationTokenSource _endRunSource = new();
+ private int _isDisposed;
+ private int _isEventStreamTaken;
+
+ internal AsyncRunHandle(ISuperStepRunner stepRunner, ICheckpointingHandle checkpointingHandle, ExecutionMode mode)
+ {
+ this._stepRunner = Throw.IfNull(stepRunner);
+ this._checkpointingHandle = Throw.IfNull(checkpointingHandle);
+
+ this._eventStream = mode switch
+ {
+ //ExecutionMode.OffThread => Not supported yet
+ ExecutionMode.Lockstep => new LockstepRunEventStream(stepRunner),
+ _ => throw new ArgumentOutOfRangeException(nameof(mode), $"Unknown execution mode {mode}")
+ };
+ this._eventStream.Start();
+ }
+
+ public ValueTask WaitForNextInputAsync(CancellationToken cancellation = default)
+ => this._waitForResponseCoordinator.WaitForCoordinationAsync(cancellation);
+
+ public void ReleaseResponseWaiter() => this._waitForResponseCoordinator.MarkCoordinationPoint();
+
+ public string RunId => this._stepRunner.RunId;
+
+ public IReadOnlyList Checkpoints => this._checkpointingHandle.Checkpoints;
+
+ public ValueTask GetStatusAsync(CancellationToken cancellation = default)
+ => this._eventStream.GetStatusAsync(cancellation);
+
+ public async IAsyncEnumerable TakeEventStreamAsync(bool breakOnHalt, [EnumeratorCancellation] CancellationToken cancellation = default)
+ {
+ // Create a linked cancellation token that combines the provided token with the end-run token
+ using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellation, this._endRunSource.Token);
+
+ // Only one enumerator of this is allowed at a time
+ if (Interlocked.CompareExchange(ref this._isEventStreamTaken, 1, 0) != 0)
+ {
+ throw new InvalidOperationException("The event stream has already been taken. Only one enumerator is allowed at a time.");
+ }
+
+ try
+ {
+ await foreach (WorkflowEvent @event in this._eventStream.TakeEventStreamAsync(linkedSource.Token)
+ .ConfigureAwait(false))
+ {
+ yield return @event;
+ }
+ }
+ finally
+ {
+ Volatile.Write(ref this._isEventStreamTaken, 0);
+ }
+ }
+
+ public ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default)
+ => this._stepRunner.IsValidInputTypeAsync(cancellation);
+
+ public async ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default)
+ {
+ if (message is ExternalResponse response)
+ {
+ // EnqueueResponseAsync marks the coordination point itself
+ await this.EnqueueResponseAsync(response, cancellation)
+ .ConfigureAwait(false);
+
+ return true;
+ }
+
+ bool result = await this._stepRunner.EnqueueMessageAsync(message, cancellation)
+ .ConfigureAwait(false);
+
+ this._waitForResponseCoordinator.MarkCoordinationPoint();
+
+ return result;
+ }
+
+ public async ValueTask EnqueueMessageUntypedAsync([NotNull] object message, Type? declaredType = null, CancellationToken cancellation = default)
+ {
+ if (declaredType?.IsInstanceOfType(message) == false)
+ {
+ throw new ArgumentException($"Message is not of the declared type {declaredType}. Actual type: {message.GetType()}", nameof(message));
+ }
+
+ if (declaredType != null && typeof(ExternalResponse).IsAssignableFrom(declaredType))
+ {
+ // EnqueueResponseAsync marks the coordination point itself
+ await this.EnqueueResponseAsync((ExternalResponse)message, cancellation)
+ .ConfigureAwait(false);
+
+ return true;
+ }
+ else if (declaredType == null && message is ExternalResponse response)
+ {
+ // EnqueueResponseAsync marks the coordination point itself
+ await this.EnqueueResponseAsync(response, cancellation)
+ .ConfigureAwait(false);
+
+ return true;
+ }
+
+ bool result = await this._stepRunner.EnqueueMessageUntypedAsync(message, declaredType ?? message.GetType(), cancellation)
+ .ConfigureAwait(false);
+
+ this._waitForResponseCoordinator.MarkCoordinationPoint();
+
+ return result;
+ }
+
+ public async ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation = default)
+ {
+ await this._stepRunner.EnqueueResponseAsync(response, cancellation).ConfigureAwait(false);
+
+ this._waitForResponseCoordinator.MarkCoordinationPoint();
+ }
+
+ public ValueTask RequestEndRunAsync()
+ {
+ this._endRunSource.Cancel();
+ return this._stepRunner.RequestEndRunAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref this._isDisposed, 1) == 0)
+ {
+ this._endRunSource.Cancel();
+ await this.RequestEndRunAsync().ConfigureAwait(false);
+ this._endRunSource.Dispose();
+
+ await this._eventStream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
+ => this._checkpointingHandle.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs
new file mode 100644
index 0000000000..65b60cd6a8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal static class AsyncRunHandleExtensions
+{
+ public async static ValueTask> WithCheckpointingAsync(this AsyncRunHandle runHandle, Func> prepareFunc)
+ {
+ TRunType run = await prepareFunc().ConfigureAwait(false);
+ return new Checkpointed(run, runHandle);
+ }
+
+ public static async ValueTask EnqueueAndStreamAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellation = default)
+ {
+ await runHandle.EnqueueMessageAsync(input, cancellation).ConfigureAwait(false);
+ return new(runHandle);
+ }
+
+ public static async ValueTask EnqueueUntypedAndStreamAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellation = default)
+ {
+ await runHandle.EnqueueMessageUntypedAsync(input, cancellation: cancellation).ConfigureAwait(false);
+ return new(runHandle);
+ }
+
+ public static async ValueTask EnqueueAndRunAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellation = default)
+ {
+ await runHandle.EnqueueMessageAsync(input, cancellation).ConfigureAwait(false);
+ Run run = new(runHandle);
+
+ await run.RunToNextHaltAsync(cancellation).ConfigureAwait(false);
+ return run;
+ }
+
+ public static async ValueTask EnqueueUntypedAndRunAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellation = default)
+ {
+ await runHandle.EnqueueMessageUntypedAsync(input, cancellation: cancellation).ConfigureAwait(false);
+ Run run = new(runHandle);
+
+ await run.RunToNextHaltAsync(cancellation).ConfigureAwait(false);
+ return run;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ConcurrentEventSink.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ConcurrentEventSink.cs
new file mode 100644
index 0000000000..1f81d8f4c5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ConcurrentEventSink.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal interface IEventSink
+{
+ ValueTask EnqueueAsync(WorkflowEvent workflowEvent);
+}
+
+internal class ConcurrentEventSink : IEventSink
+{
+ public ValueTask EnqueueAsync(WorkflowEvent workflowEvent)
+ {
+ return this.EventRaised?.Invoke(this, Throw.IfNull(workflowEvent)) ?? default;
+ }
+
+ public event Func? EventRaised;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DeliveryMapping.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DeliveryMapping.cs
index 04a7f2b9cd..78a884844f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DeliveryMapping.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/DeliveryMapping.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Shared.Diagnostics;
@@ -29,7 +30,11 @@ internal sealed class DeliveryMapping
{
foreach (Executor target in this._targets)
{
- nextStep.MessagesFor(target.Id).AddRange(this._envelopes.Select(envelope => envelope));
+ ConcurrentQueue messageQueue = nextStep.MessagesFor(target.Id);
+ foreach (MessageEnvelope envelope in this._envelopes)
+ {
+ messageQueue.Enqueue(envelope);
+ }
}
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeConnection.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeConnection.cs
index 27043ad443..8833907489 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeConnection.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeConnection.cs
@@ -114,4 +114,10 @@ public sealed class EdgeConnection : IEquatable
/// The unique identifiers of the sinks connected by this edge.
///
public List SinkIds { get; }
+
+ ///
+ public override string ToString()
+ {
+ return $"[{string.Join(",", this.SourceIds)}] => [{string.Join(",", this.SinkIds)}]";
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
index 6ed6214868..952f9c4748 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/EdgeMap.cs
@@ -12,9 +12,9 @@ internal sealed class EdgeMap
{
private readonly Dictionary _edgeRunners = [];
private readonly Dictionary _statefulRunners = [];
- private readonly Dictionary _portEdgeRunners;
+ private readonly Dictionary _portEdgeRunners;
- private readonly InputEdgeRunner _inputRunner;
+ private readonly ResponseEdgeRunner _inputRunner;
private readonly IStepTracer? _stepTracer;
public EdgeMap(IRunnerContext runContext,
@@ -29,7 +29,7 @@ internal sealed class EdgeMap
public EdgeMap(IRunnerContext runContext,
Dictionary> workflowEdges,
- IEnumerable workflowPorts,
+ IEnumerable workflowPorts,
string startExecutorId,
IStepTracer? stepTracer = null)
{
@@ -53,10 +53,10 @@ internal sealed class EdgeMap
this._portEdgeRunners = workflowPorts.ToDictionary(
port => port.Id,
- port => InputEdgeRunner.ForPort(runContext, port)
+ port => ResponseEdgeRunner.ForPort(runContext, port)
);
- this._inputRunner = new InputEdgeRunner(runContext, startExecutorId);
+ this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId);
this._stepTracer = stepTracer;
}
@@ -78,7 +78,7 @@ internal sealed class EdgeMap
public ValueTask PrepareDeliveryForResponseAsync(ExternalResponse response)
{
- if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out InputEdgeRunner? portRunner))
+ if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out ResponseEdgeRunner? portRunner))
{
throw new InvalidOperationException($"Port {response.PortInfo.PortId} not found in the edge map.");
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs
new file mode 100644
index 0000000000..ed9f4bf22d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal interface IInputCoordinator
+{
+ ValueTask WaitForNextInputAsync(CancellationToken cancellation = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs
new file mode 100644
index 0000000000..6b5a82868c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal interface IRunEventStream : IAsyncDisposable
+{
+ void Start();
+ ValueTask GetStatusAsync(CancellationToken cancellation = default);
+ IAsyncEnumerable TakeEventStreamAsync(CancellationToken cancellation = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs
index fab24f4085..b3a988306c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs
@@ -5,7 +5,7 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Execution;
-internal interface IRunnerContext : IExternalRequestSink
+internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext
{
ValueTask AddEventAsync(WorkflowEvent workflowEvent);
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs
new file mode 100644
index 0000000000..1e27fe6b4d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal interface ISuperStepJoinContext
+{
+ bool WithCheckpointing { get; }
+
+ ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation = default);
+ ValueTask SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation = default);
+
+ ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
index 7b892e9468..7384dfc4d0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs
@@ -15,12 +15,16 @@ internal interface ISuperStepRunner
bool HasUnservicedRequests { get; }
bool HasUnprocessedMessages { get; }
- ValueTask EnqueueResponseAsync(ExternalResponse response);
- ValueTask EnqueueMessageAsync(T message);
+ ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation = default);
- event EventHandler? WorkflowEvent;
+ ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default);
+ ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default);
+ ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default);
+
+ ConcurrentEventSink OutgoingEvents { get; }
ValueTask RunSuperStepAsync(CancellationToken cancellationToken);
+ // This cannot be cancelled
ValueTask RequestEndRunAsync();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs
new file mode 100644
index 0000000000..d2fe3268d2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal class InitLocked() where T : class
+{
+ private int _writers;
+ private T? _value;
+
+ public T? Get()
+ {
+ return this._value;
+ }
+
+ public bool Init(Func initializer)
+ {
+ if (Interlocked.Exchange(ref this._writers, 1) == 0)
+ {
+ try
+ {
+ if (this._value == null)
+ {
+ this._value = initializer();
+ return true;
+ }
+
+ return false;
+ }
+ finally
+ {
+ this._writers = 0;
+ }
+ }
+
+ return false;
+ }
+
+ public void Clear()
+ {
+ this._value = null;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs
new file mode 100644
index 0000000000..baec8236db
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Observability;
+
+namespace Microsoft.Agents.AI.Workflows.Execution;
+
+internal sealed class LockstepRunEventStream : IRunEventStream
+{
+ private static readonly string s_namespace = typeof(LockstepRunEventStream).Namespace!;
+ private static readonly ActivitySource s_activitySource = new(s_namespace);
+
+ public ValueTask GetStatusAsync(CancellationToken cancellation = default) => new(this.RunStatus);
+
+ public LockstepRunEventStream(ISuperStepRunner stepRunner)
+ {
+ this.StepRunner = stepRunner;
+ }
+
+ private RunStatus RunStatus { get; set; } = RunStatus.NotStarted;
+ private ISuperStepRunner StepRunner { get; }
+
+ public void Start()
+ {
+ // No-op for lockstep execution
+ }
+
+ public async IAsyncEnumerable TakeEventStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default)
+ {
+ ConcurrentQueue eventSink = [];
+
+ this.StepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
+
+ using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
+ activity?.SetTag(Tags.WorkflowId, this.StepRunner.StartExecutorId).SetTag(Tags.RunId, this.StepRunner.RunId);
+
+ try
+ {
+ this.RunStatus = RunStatus.Running;
+ activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
+
+ do
+ {
+ // Because we may be yielding out of this function, we need to ensure that the Activity.Current
+ // is set to our activity for the duration of this loop iteration.
+ Activity.Current = activity;
+
+ // Drain SuperSteps while there are steps to run
+ try
+ {
+ await this.StepRunner.RunSuperStepAsync(cancellation).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (activity is not null)
+ {
+ activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
+ { Tags.ErrorType, ex.GetType().FullName },
+ { Tags.BuildErrorMessage, ex.Message },
+ }));
+ activity.CaptureException(ex);
+ throw;
+ }
+
+ if (cancellation.IsCancellationRequested)
+ {
+ yield break; // Exit if cancellation is requested
+ }
+
+ bool hadRequestHaltEvent = false;
+ foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, []))
+ {
+ if (cancellation.IsCancellationRequested)
+ {
+ yield break; // Exit if cancellation is requested
+ }
+
+ // TODO: Do we actually want to interpret this as a termination request?
+ if (raisedEvent is RequestHaltEvent)
+ {
+ hadRequestHaltEvent = true;
+ }
+ else
+ {
+ yield return raisedEvent;
+ }
+ }
+
+ if (hadRequestHaltEvent)
+ {
+ // If we had a completion event, we are done.
+ yield break;
+ }
+ } while (this.StepRunner.HasUnprocessedMessages &&
+ !cancellation.IsCancellationRequested);
+
+ activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
+ }
+ finally
+ {
+ this.RunStatus = this.StepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
+ this.StepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
+ }
+
+ ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
+ {
+ eventSink.Enqueue(e);
+ return default;
+ }
+ }
+
+ public ValueTask DisposeAsync() => default;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputEdgeRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs
similarity index 81%
rename from dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputEdgeRunner.cs
rename to dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs
index 1174ac7ea2..55e85b8b14 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputEdgeRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ResponseEdgeRunner.cs
@@ -8,15 +8,15 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Execution;
-internal sealed class InputEdgeRunner(IRunnerContext runContext, string sinkId)
+internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkId)
: EdgeRunner(runContext, sinkId)
{
- public static InputEdgeRunner ForPort(IRunnerContext runContext, InputPort port)
+ public static ResponseEdgeRunner ForPort(IRunnerContext runContext, RequestPort port)
{
Throw.IfNull(port);
- // The port is an input port, so we can use the port's ID as the sink ID.
- return new InputEdgeRunner(runContext, port.Id);
+ // The port is an request port, so we can use the port's ID as the sink ID.
+ return new ResponseEdgeRunner(runContext, port.Id);
}
protected internal override async ValueTask ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
@@ -25,7 +25,7 @@ internal sealed class InputEdgeRunner(IRunnerContext runContext, string sinkId)
using var activity = s_activitySource.StartActivity(ActivityNames.EdgeGroupProcess);
activity?
- .SetTag(Tags.EdgeGroupType, nameof(InputEdgeRunner))
+ .SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
.SetTag(Tags.MessageSourceId, envelope.SourceId)
.SetTag(Tags.MessageTargetId, this.EdgeData);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StepContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StepContext.cs
index 5d0328a43d..30a92c6f9c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StepContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StepContext.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -8,18 +9,13 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class StepContext
{
- public Dictionary> QueuedMessages { get; } = [];
+ public ConcurrentDictionary> QueuedMessages { get; } = [];
- public bool HasMessages => this.QueuedMessages.Values.Any(messageList => messageList.Count > 0);
+ public bool HasMessages => this.QueuedMessages.Values.Any(messageQueue => !messageQueue.IsEmpty);
- public List MessagesFor(string target)
+ public ConcurrentQueue MessagesFor(string target)
{
- if (!this.QueuedMessages.TryGetValue(target, out var messages))
- {
- this.QueuedMessages[target] = messages = [];
- }
-
- return messages;
+ return this.QueuedMessages.GetOrAdd(target, _ => new ConcurrentQueue());
}
// TODO: Create a MessageEnvelope class that extends from the ExportedState object (with appropriate rename) to avoid
@@ -29,7 +25,8 @@ internal sealed class StepContext
return this.QueuedMessages.Keys.ToDictionary(
keySelector: identity => identity,
elementSelector: identity => this.QueuedMessages[identity]
- .ConvertAll(v => new PortableMessageEnvelope(v))
+ .Select(v => new PortableMessageEnvelope(v))
+ .ToList()
);
}
@@ -37,7 +34,7 @@ internal sealed class StepContext
{
foreach (string identity in messages.Keys)
{
- this.QueuedMessages[identity] = messages[identity].ConvertAll(UnwrapExportedState);
+ this.QueuedMessages[identity] = new(messages[identity].Select(UnwrapExportedState));
}
static MessageEnvelope UnwrapExportedState(PortableMessageEnvelope es) => es.ToMessageEnvelope();
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs
new file mode 100644
index 0000000000..0cfecc6773
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows;
+
+internal enum ExecutionMode
+{
+ OffThread,
+ Lockstep
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs
index e273b4b8a0..e1df4fb37e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs
@@ -24,8 +24,6 @@ public abstract class Executor : IIdentified
///
public string Id { get; }
- private readonly ExecutorOptions _options;
-
private static readonly string s_namespace = typeof(Executor).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
@@ -37,9 +35,14 @@ public abstract class Executor : IIdentified
protected Executor(string id, ExecutorOptions? options = null)
{
this.Id = id;
- this._options = options ?? ExecutorOptions.Default;
+ this.Options = options ?? ExecutorOptions.Default;
}
+ ///
+ /// Gets the configuration options for the executor.
+ ///
+ protected ExecutorOptions Options { get; }
+
///
/// Override this method to register handlers for the executor.
///
@@ -57,7 +60,7 @@ public abstract class Executor : IIdentified
///
protected virtual ISet ConfigureYieldTypes()
{
- if (this._options.AutoYieldOutputHandlerResultObject)
+ if (this.Options.AutoYieldOutputHandlerResultObject)
{
return this.Router.DefaultOutputTypes;
}
@@ -132,11 +135,11 @@ public abstract class Executor : IIdentified
}
// If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour?
- if (result.Result is not null && this._options.AutoSendMessageHandlerResultObject)
+ if (result.Result is not null && this.Options.AutoSendMessageHandlerResultObject)
{
await context.SendMessageAsync(result.Result).ConfigureAwait(false);
}
- if (result.Result is not null && this._options.AutoYieldOutputHandlerResultObject)
+ if (result.Result is not null && this.Options.AutoYieldOutputHandlerResultObject)
{
await context.YieldOutputAsync(result.Result).ConfigureAwait(false);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs
index 6ad3dfe4c7..59c26284d8 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs
@@ -29,7 +29,7 @@ public static class ExecutorIshConfigurationExtensions
/// An id for the executor to be instantiated.
/// An optional parameter specifying the options.
/// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.
- public static ExecutorIsh ConfigureFactory(this Func, ValueTask> factoryAsync, string id, TOptions? options = null)
+ public static ExecutorIsh ConfigureFactory(this Func, string, ValueTask> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
@@ -48,6 +48,27 @@ public static class ExecutorIshConfigurationExtensions
typeof(FunctionExecutor),
ExecutorIsh.Type.Function);
+ ///
+ /// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
+ ///
+ /// The workflow instance to be executed as a sub-workflow. Cannot be null.
+ /// A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.
+ /// Optional configuration options for the sub-workflow executor. If null, default options are used.
+ /// An ExecutorIsh instance representing the configured sub-workflow executor.
+ public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
+ {
+ object ownershipToken = new();
+ workflow.TakeOwnership(ownershipToken, subworkflow: true);
+
+ Configured configured = new(InitHostExecutorAsync, id, options, raw: workflow);
+ return new ExecutorIsh(configured.Super(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
+
+ ValueTask InitHostExecutorAsync(Config config, string runId)
+ {
+ return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
+ }
+ }
+
///
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
@@ -114,13 +135,17 @@ public sealed class ExecutorIsh :
///
Function,
///
- /// An for servicing external requests.
+ /// An for servicing external requests.
///
- InputPort,
+ RequestPort,
///
/// An instance.
///
Agent,
+ ///
+ /// A nested instance.
+ ///
+ Workflow,
}
///
@@ -133,7 +158,7 @@ public sealed class ExecutorIsh :
private readonly Configured? _configuredExecutor;
private readonly System.Type? _configuredExecutorType;
- internal readonly InputPort? _inputPortValue;
+ internal readonly RequestPort? _requestPortValue;
private readonly AIAgent? _aiAgentValue;
///
@@ -168,10 +193,10 @@ public sealed class ExecutorIsh :
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
///
/// The input port to associate to be wrapped.
- public ExecutorIsh(InputPort port)
+ public ExecutorIsh(RequestPort port)
{
- this.ExecutorType = Type.InputPort;
- this._inputPortValue = Throw.IfNull(port);
+ this.ExecutorType = Type.RequestPort;
+ this._requestPortValue = Throw.IfNull(port);
}
///
@@ -191,9 +216,10 @@ public sealed class ExecutorIsh :
{
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
Type.Executor => this._configuredExecutor!.Id,
- Type.InputPort => this._inputPortValue!.Id,
+ Type.RequestPort => this._requestPortValue!.Id,
Type.Agent => this._aiAgentValue!.Id,
Type.Function => this._configuredExecutor!.Id,
+ Type.Workflow => this._configuredExecutor!.Id,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -201,9 +227,10 @@ public sealed class ExecutorIsh :
{
Type.Unbound => this._idValue,
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
- Type.InputPort => this._inputPortValue,
+ Type.RequestPort => this._requestPortValue,
Type.Agent => this._aiAgentValue,
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
+ Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -219,9 +246,10 @@ public sealed class ExecutorIsh :
{
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutorType!,
- Type.InputPort => typeof(RequestInfoExecutor),
+ Type.RequestPort => typeof(RequestInfoExecutor),
Type.Agent => typeof(AIAgentHostExecutor),
Type.Function => this._configuredExecutorType!,
+ Type.Workflow => this._configuredExecutorType!,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -229,13 +257,14 @@ public sealed class ExecutorIsh :
/// Gets an that can be used to obtain an instance
/// corresponding to this .
///
- private Func> ExecutorProvider => this.ExecutorType switch
+ private Func> ExecutorProvider => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
- Type.InputPort => () => new(new RequestInfoExecutor(this._inputPortValue!)),
- Type.Agent => () => new(new AIAgentHostExecutor(this._aiAgentValue!)),
+ Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)),
+ Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)),
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
+ Type.Workflow => this._configuredExecutor!.BoundFactoryAsync,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -246,10 +275,10 @@ public sealed class ExecutorIsh :
public static implicit operator ExecutorIsh(Executor executor) => new(executor);
///
- /// Defines an implicit conversion from an to an instance.
+ /// Defines an implicit conversion from an to an instance.
///
- /// The to convert to an .
- public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort);
+ /// The to convert to an .
+ public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
///
/// Defines an implicit conversion from an to an instance.
@@ -294,9 +323,10 @@ public sealed class ExecutorIsh :
{
Type.Unbound => $"'{this.Id}':",
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
- Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})",
+ Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})",
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
+ Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}",
_ => $"'{this.Id}':"
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs
index e97bdf88c4..80f57680e6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs
@@ -4,7 +4,7 @@ using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
-using ExecutorFactoryF = System.Func>;
+using ExecutorFactoryF = System.Func>;
namespace Microsoft.Agents.AI.Workflows;
@@ -12,7 +12,7 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo
{
public string Id { get; } = Throw.IfNullOrEmpty(id);
public Type ExecutorType { get; } = Throw.IfNull(executorType);
- public ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
+ private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
public bool IsNotExecutorInstance { get; } = rawData is not Executor;
public bool IsUnresettableSharedInstance { get; } = rawData is Executor && rawData is not IResettableExecutor;
@@ -58,5 +58,5 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo
return executor;
}
- public async ValueTask CreateInstanceAsync() => this.CheckId(await this.ProviderAsync().ConfigureAwait(false));
+ public async ValueTask CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
index 37986e6e86..2dbba50bf1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalRequest.cs
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// The port to invoke.
/// A unique identifier for this request instance.
/// The data contained in the request.
-public record ExternalRequest(InputPortInfo PortInfo, string RequestId, PortableValue Data)
+public record ExternalRequest(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
///
/// Attempts to retrieve the underlying data as the specified type.
@@ -29,6 +29,13 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable
/// true if the underlying data is of type TValue; otherwise, false.
public bool DataIs() => this.Data.Is();
+ ///
+ /// Determines whether the underlying data is of the specified type and outputs the value if it is.
+ ///
+ /// The type to compare with the underlying data.
+ /// true if the underlying data is of type TValue; otherwise, false.
+ public bool DataIs([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
+
///
/// Creates a new for the specified input port and data payload.
///
@@ -37,7 +44,7 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable
/// An optional unique identifier for this request instance. If null, a UUID will be generated.
/// An instance containing the specified port, data, and request identifier.
/// Thrown when the input data object does not match the expected request type.
- public static ExternalRequest Create(InputPort port, [NotNull] object data, string? requestId = null)
+ public static ExternalRequest Create(RequestPort port, [NotNull] object data, string? requestId = null)
{
if (!port.Request.IsInstanceOfType(Throw.IfNull(data)))
{
@@ -58,7 +65,7 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable
/// The data payload to include in the request. Must not be null.
/// An optional identifier for the request. If null, a default identifier may be assigned.
/// An instance containing the specified port, data, and request identifier.
- public static ExternalRequest Create(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId);
+ public static ExternalRequest Create(RequestPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId);
///
/// Creates a new corresponding to the request, with the speicified data payload.
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalResponse.cs
index bd69bbba8f..f01668dfa5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalResponse.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExternalResponse.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows;
@@ -11,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// The port invoked.
/// The unique identifier of the corresponding request.
/// The data contained in the response.
-public record ExternalResponse(InputPortInfo PortInfo, string RequestId, PortableValue Data)
+public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
///
/// Attempts to retrieve the underlying data as the specified type.
@@ -27,6 +28,15 @@ public record ExternalResponse(InputPortInfo PortInfo, string RequestId, Portabl
/// true if the underlying data is of type TValue; otherwise, false.
public bool DataIs() => this.Data.Is();
+ ///
+ /// Determines whether the underlying data can be retrieved as the specified type.
+ ///
+ /// The type to which the underlying data is to be cast if available.
+ /// When this method returns, contains the value of type if the data is
+ /// available and compatible.
+ /// true if the data is present and can be cast to ; otherwise, false.
+ public bool DataIs([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
+
///
/// Attempts to retrieve the underlying data as the specified type.
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcStepTracer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcStepTracer.cs
index 6f0e1fe1cc..0affd47f00 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcStepTracer.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcStepTracer.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -16,11 +17,11 @@ internal sealed class InProcStepTracer : IStepTracer
public bool StateUpdated { get; private set; }
public CheckpointInfo? Checkpoint { get; private set; }
- public HashSet Instantiated { get; } = [];
- public HashSet Activated { get; } = [];
+ public ConcurrentDictionary Instantiated { get; } = new();
+ public ConcurrentDictionary Activated { get; } = new();
- public void TraceIntantiated(string executorId) => this.Instantiated.Add(executorId);
- public void TraceActivated(string executorId) => this.Activated.Add(executorId);
+ public void TraceIntantiated(string executorId) => this.Instantiated.TryAdd(executorId, executorId);
+ public void TraceActivated(string executorId) => this.Activated.TryAdd(executorId, executorId);
public void TraceStatePublished() => this.StateUpdated = true;
public void TraceCheckpointCreated(CheckpointInfo checkpoint) => this.Checkpoint = checkpoint;
@@ -60,7 +61,7 @@ internal sealed class InProcStepTracer : IStepTracer
});
}
- public SuperStepCompletedEvent Complete(bool nextStepHasActions, bool hasPendingRequests) => new(this.StepNumber, new SuperStepCompletionInfo(this.Activated, this.Instantiated)
+ public SuperStepCompletedEvent Complete(bool nextStepHasActions, bool hasPendingRequests) => new(this.StepNumber, new SuperStepCompletionInfo(this.Activated.Keys, this.Instantiated.Keys)
{
HasPendingMessages = nextStepHasActions,
HasPendingRequests = hasPendingRequests,
@@ -72,19 +73,19 @@ internal sealed class InProcStepTracer : IStepTracer
{
StringBuilder sb = new();
- if (this.Instantiated.Count != 0)
+ if (!this.Instantiated.IsEmpty)
{
- sb.Append("Instantiated: ").Append(string.Join(", ", this.Instantiated.OrderBy(id => id, StringComparer.Ordinal)));
+ sb.Append("Instantiated: ").Append(string.Join(", ", this.Instantiated.Keys.OrderBy(id => id, StringComparer.Ordinal)));
}
- if (this.Activated.Count != 0)
+ if (!this.Activated.IsEmpty)
{
if (sb.Length != 0)
{
sb.AppendLine();
}
- sb.Append("Activated: ").Append(string.Join(", ", this.Activated.OrderBy(id => id, StringComparer.Ordinal)));
+ sb.Append("Activated: ").Append(string.Join(", ", this.Activated.Keys.OrderBy(id => id, StringComparer.Ordinal)));
}
return sb.ToString();
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
index bc0df57c36..2a0c74327b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -18,18 +19,20 @@ namespace Microsoft.Agents.AI.Workflows.InProc;
/// enables step-by-step execution of a workflow graph entirely
/// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or
/// scenarios where workflow execution does not require executor distribution.
-internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
+internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
{
- public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, params Type[] knownValidInputTypes)
+ public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? workflowOwnership = null, bool subworkflow = false, IEnumerable? knownValidInputTypes = null)
{
this.RunId = runId ?? Guid.NewGuid().ToString("N");
this.StartExecutorId = workflow.StartExecutorId;
this.Workflow = Throw.IfNull(workflow);
- this.RunContext = new InProcessRunnerContext(workflow, this.RunId, this.StepTracer);
+ this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, workflowOwnership, subworkflow);
this.CheckpointManager = checkpointManager;
- this._knownValidInputTypes = [.. knownValidInputTypes];
+ this._knownValidInputTypes = knownValidInputTypes != null
+ ? [.. knownValidInputTypes]
+ : [];
// Initialize the runners for each of the edges, along with the state for edges that
// need it.
@@ -43,7 +46,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
public string StartExecutorId { get; }
private readonly HashSet _knownValidInputTypes;
- public async ValueTask IsValidInputTypeAsync(Type messageType)
+ public async ValueTask IsValidInputTypeAsync(Type messageType, CancellationToken cancellation = default)
{
if (this._knownValidInputTypes.Contains(messageType))
{
@@ -60,7 +63,10 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
return false;
}
- private async ValueTask EnqueueMessageInternalAsync(object message, Type messageType)
+ public ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default)
+ => this.IsValidInputTypeAsync(typeof(T), cancellation);
+
+ public async ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(message);
@@ -72,22 +78,22 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
// Check that the type of the incoming message is compatible with the starting executor's
// input type.
- if (!await this.IsValidInputTypeAsync(messageType).ConfigureAwait(false))
+ if (!await this.IsValidInputTypeAsync(declaredType, cancellation).ConfigureAwait(false))
{
return false;
}
- await this.RunContext.AddExternalMessageAsync(message, messageType).ConfigureAwait(false);
+ await this.RunContext.AddExternalMessageAsync(message, declaredType).ConfigureAwait(false);
return true;
}
- public ValueTask EnqueueMessageAsync(T message)
- => this.EnqueueMessageInternalAsync(Throw.IfNull(message), typeof(T));
+ public ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default)
+ => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), typeof(T), cancellation);
- public ValueTask EnqueueMessageAsync(object message)
- => this.EnqueueMessageInternalAsync(Throw.IfNull(message), message.GetType());
+ public ValueTask EnqueueMessageAsync(object message, CancellationToken cancellation = default)
+ => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), message.GetType(), cancellation);
- ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response)
+ ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation)
{
// TODO: Check that there exists a corresponding input port?
return this.RunContext.AddExternalResponseAsync(response);
@@ -95,78 +101,32 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
private InProcStepTracer StepTracer { get; } = new();
private Workflow Workflow { get; init; }
- private InProcessRunnerContext RunContext { get; init; }
+ internal InProcessRunnerContext RunContext { get; init; }
private ICheckpointManager? CheckpointManager { get; }
private EdgeMap EdgeMap { get; init; }
- event EventHandler? ISuperStepRunner.WorkflowEvent
- {
- add => this.WorkflowEvent += value;
- remove => this.WorkflowEvent -= value;
- }
+ public ConcurrentEventSink OutgoingEvents { get; } = new();
- private event EventHandler? WorkflowEvent;
+ private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent)
+ => this.OutgoingEvents.EnqueueAsync(workflowEvent);
- private void RaiseWorkflowEvent(WorkflowEvent workflowEvent)
- {
- this.WorkflowEvent?.Invoke(this, workflowEvent);
- }
-
- public async ValueTask ResumeStreamAsync(CheckpointInfo checkpoint, CancellationToken cancellationToken = default)
+ public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
- Throw.IfNull(checkpoint);
+ return new(new AsyncRunHandle(this, this, mode));
+ }
+
+ public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellation = default)
+ {
+ this.RunContext.CheckEnded();
+ Throw.IfNull(fromCheckpoint);
if (this.CheckpointManager is null)
{
throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints.");
}
- await this.RestoreCheckpointAsync(checkpoint, cancellationToken).ConfigureAwait(false);
-
- return new StreamingRun(this);
- }
-
- public async ValueTask StreamAsync(object input, CancellationToken cancellationToken = default)
- {
- this.RunContext.CheckEnded();
- await this.EnqueueMessageAsync(input).ConfigureAwait(false);
-
- return new StreamingRun(this);
- }
-
- public async ValueTask StreamAsync(TInput input, CancellationToken cancellationToken = default)
- {
- this.RunContext.CheckEnded();
- await this.EnqueueMessageAsync(input).ConfigureAwait(false);
-
- return new StreamingRun(this);
- }
-
- internal async ValueTask ResumeAsync(CheckpointInfo checkpoint, CancellationToken cancellationToken = default)
- {
- this.RunContext.CheckEnded();
- StreamingRun streamingRun = await this.ResumeStreamAsync(checkpoint, cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
-
- return await Run.CaptureStreamAsync(streamingRun, cancellationToken).ConfigureAwait(false);
- }
-
- public async ValueTask RunAsync(object input, CancellationToken cancellationToken = default)
- {
- this.RunContext.CheckEnded();
- StreamingRun streamingRun = await this.StreamAsync(input, cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
-
- return await Run.CaptureStreamAsync(streamingRun, cancellationToken).ConfigureAwait(false);
- }
-
- public async ValueTask RunAsync(TInput input, CancellationToken cancellationToken = default)
- {
- this.RunContext.CheckEnded();
- StreamingRun streamingRun = await this.StreamAsync(input, cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
-
- return await Run.CaptureStreamAsync(streamingRun, cancellationToken).ConfigureAwait(false);
+ await this.RestoreCheckpointAsync(fromCheckpoint, cancellation).ConfigureAwait(false);
+ return new AsyncRunHandle(this, this, mode);
}
bool ISuperStepRunner.HasUnservicedRequests => this.RunContext.HasUnservicedRequests;
@@ -184,34 +144,23 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
StepContext currentStep = await this.RunContext.AdvanceAsync().ConfigureAwait(false);
- if (currentStep.HasMessages)
+ if (currentStep.HasMessages ||
+ this.RunContext.HasQueuedExternalDeliveries ||
+ this.RunContext.JoinedRunnersHaveActions)
{
await this.RunSuperstepAsync(currentStep).ConfigureAwait(false);
return true;
}
- this.EmitPendingEvents();
return false;
}
- private void EmitPendingEvents()
- {
- if (this.RunContext.QueuedEvents.Count > 0)
- {
- foreach (WorkflowEvent @event in this.RunContext.QueuedEvents)
- {
- this.RaiseWorkflowEvent(@event);
- }
- this.RunContext.QueuedEvents.Clear();
- }
- }
-
- private async ValueTask DeliverMessagesAsync(string receiverId, List envelopes)
+ private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue envelopes)
{
Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer).ConfigureAwait(false);
this.StepTracer.TraceActivated(receiverId);
- foreach (MessageEnvelope envelope in envelopes)
+ while (envelopes.TryDequeue(out var envelope))
{
await executor.ExecuteAsync(
envelope.Message,
@@ -223,7 +172,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
private async ValueTask RunSuperstepAsync(StepContext currentStep)
{
- this.RaiseWorkflowEvent(this.StepTracer.Advance(currentStep));
+ await this.RaiseWorkflowEventAsync(this.StepTracer.Advance(currentStep)).ConfigureAwait(false);
// Deliver the messages and queue the next step
List receiverTasks =
@@ -236,12 +185,22 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
// that we would need to avoid firing the tasks when we call InvokeEdgeAsync, or RouteExternalMessageAsync.
await Task.WhenAll(receiverTasks).ConfigureAwait(false);
- // After the message handler invocations, we may have some events to deliver
- this.EmitPendingEvents();
+ // When we have sub-workflows, sending a message to the WorkflowHostExecutor will only queue it into the
+ // subworkflow's input queue. In order to actually process the message and align the supersteps correctly,
+ // we need to drive the superstep of the subworkflow here.
+ // TODO: Investigate if we can fully pull in the subworkflow execution into the WorkflowHostExecutor itself.
+ List subworkflowTasks = new();
+ foreach (ISuperStepRunner subworkflowRunner in this.RunContext.JoinedSubworkflowRunners)
+ {
+ subworkflowTasks.Add(subworkflowRunner.RunSuperStepAsync(CancellationToken.None).AsTask());
+ }
+
+ await Task.WhenAll(subworkflowTasks).ConfigureAwait(false);
await this.CheckpointAsync().ConfigureAwait(false);
- this.RaiseWorkflowEvent(this.StepTracer.Complete(this.RunContext.NextStepHasActions, this.RunContext.HasUnservicedRequests));
+ await this.RaiseWorkflowEventAsync(this.StepTracer.Complete(this.RunContext.NextStepHasActions, this.RunContext.HasUnservicedRequests))
+ .ConfigureAwait(false);
}
private WorkflowInfo? _workflowInfoCache;
@@ -310,5 +269,5 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner
private bool CheckWorkflowMatch(Checkpoint checkpoint) =>
checkpoint.Workflow.IsMatch(this.Workflow);
- ValueTask ISuperStepRunner.RequestEndRunAsync() => this.RunContext.EndRunAsync();
+ public ValueTask RequestEndRunAsync() => this.RunContext.EndRunAsync();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
index 9743498700..f5808dd793 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs
@@ -4,10 +4,10 @@ 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;
@@ -32,17 +32,29 @@ internal sealed class InProcessRunnerContext : IRunnerContext
private readonly ConcurrentDictionary> _executors = new();
private readonly ConcurrentQueue> _queuedExternalDeliveries = new();
+ private readonly ConcurrentQueue _joinedSubworkflowRunners = new();
- private readonly Dictionary _externalRequests = [];
+ private readonly ConcurrentDictionary _externalRequests = new();
- public InProcessRunnerContext(Workflow workflow, string runId, IStepTracer? stepTracer, ILogger? logger = null)
+ public InProcessRunnerContext(
+ Workflow workflow,
+ string runId,
+ bool withCheckpointing,
+ IEventSink outgoingEvents,
+ IStepTracer? stepTracer,
+ object? workflowOwnership = null,
+ bool subworkflow = false,
+ ILogger? logger = null)
{
- workflow.TakeOwnership(this);
+ workflow.TakeOwnership(this, existingOwnershipSignoff: workflowOwnership);
this._workflow = workflow;
this._runId = runId;
this._edgeMap = new(this, this._workflow, stepTracer);
this._outputFilter = new(workflow);
+
+ this.WithCheckpointing = withCheckpointing;
+ this.OutgoingEvents = outgoingEvents;
}
public async ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer)
@@ -57,7 +69,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
- Executor executor = await registration.ProviderAsync().ConfigureAwait(false);
+ Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
tracer?.TraceActivated(executorId);
if (executor is RequestInfoExecutor requestInputExecutor)
@@ -65,12 +77,25 @@ internal sealed class InProcessRunnerContext : IRunnerContext
requestInputExecutor.AttachRequestSink(this);
}
+ if (executor is WorkflowHostExecutor workflowHostExecutor)
+ {
+ await workflowHostExecutor.AttachSuperStepContextAsync(this).ConfigureAwait(false);
+ }
+
return executor;
}
return await executorTask.ConfigureAwait(false);
}
+ public async ValueTask> GetStartingExecutorInputTypesAsync(CancellationToken cancellation = default)
+ {
+ Executor startingExecutor = await this.EnsureExecutorAsync(this._workflow.StartExecutorId, tracer: null)
+ .ConfigureAwait(false);
+
+ return startingExecutor.InputTypes;
+ }
+
public ValueTask AddExternalMessageAsync(object message, Type declaredType)
{
this.CheckEnded();
@@ -112,8 +137,13 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
}
- public bool NextStepHasActions => this._nextStep.HasMessages || !this._queuedExternalDeliveries.IsEmpty;
- public bool HasUnservicedRequests => this._externalRequests.Count > 0;
+ public bool HasQueuedExternalDeliveries => !this._queuedExternalDeliveries.IsEmpty;
+ public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnprocessedMessages);
+ public bool NextStepHasActions => this._nextStep.HasMessages ||
+ this.HasQueuedExternalDeliveries ||
+ this.JoinedRunnersHaveActions;
+ public bool HasUnservicedRequests => !this._externalRequests.IsEmpty ||
+ this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnservicedRequests);
public async ValueTask AdvanceAsync()
{
@@ -132,8 +162,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public ValueTask AddEventAsync(WorkflowEvent workflowEvent)
{
this.CheckEnded();
- this.QueuedEvents.Add(workflowEvent);
- return default;
+ return this.OutgoingEvents.EnqueueAsync(workflowEvent);
}
private static readonly string s_namespace = typeof(IWorkflowContext).Namespace!;
@@ -178,17 +207,21 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public ValueTask PostAsync(ExternalRequest request)
{
this.CheckEnded();
- this._externalRequests.Add(request.RequestId, request);
+ 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.Remove(requestId);
+ return this._externalRequests.TryRemove(requestId, out _);
}
- public readonly List QueuedEvents = [];
+ private IEventSink OutgoingEvents { get; }
internal StateManager StateManager { get; } = new();
@@ -239,7 +272,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public IReadOnlyDictionary? TraceContext => traceContext;
}
- internal Task PrepareForCheckpointAsync(CancellationToken cancellationToken = default)
+ public bool WithCheckpointing { get; }
+
+ internal Task PrepareForCheckpointAsync(CancellationToken cancellation = default)
{
this.CheckEnded();
@@ -248,7 +283,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task InvokeCheckpointingAsync(Task executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
- await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
+ await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellation).ConfigureAwait(false);
}
}
@@ -269,11 +304,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
{
this.CheckEnded();
- if (this.QueuedEvents.Count > 0)
- {
- throw new InvalidOperationException("Cannot export state when there are queued events. Please process or clear the events before exporting state.");
- }
-
Dictionary> queuedMessages = this._nextStep.ExportMessages();
RunnerStateData result = new(instantiatedExecutors: [.. this._executors.Keys],
queuedMessages,
@@ -300,11 +330,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
{
this.CheckEnded();
- if (this.QueuedEvents.Count > 0)
- {
- throw new InvalidOperationException("Cannot import state when there are queued events. Please process or clear the events before importing state.");
- }
-
RunnerStateData importedState = checkpoint.RunnerData;
Task[] executorTasks = importedState.InstantiatedExecutors
@@ -328,7 +353,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
await Task.WhenAll(executorTasks).ConfigureAwait(false);
}
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
+ [SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
Justification = "Does not exist in NetFx 4.7.2")]
internal void CheckEnded()
{
@@ -345,4 +370,20 @@ internal sealed class InProcessRunnerContext : IRunnerContext
await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false);
}
}
+
+ public IEnumerable JoinedSubworkflowRunners => this._joinedSubworkflowRunners;
+
+ public ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation = 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.
+ this._joinedSubworkflowRunners.Enqueue(superStepRunner);
+ return default;
+ }
+
+ ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation)
+ => this.AddEventAsync(workflowEvent);
+
+ ValueTask ISuperStepJoinContext.SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation)
+ => this.SendMessageAsync(senderId, Throw.IfNull(message));
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs
index 806e3567dd..99ab74b64b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs
@@ -1,7 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Checkpointing;
+using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.InProc;
namespace Microsoft.Agents.AI.Workflows;
@@ -10,71 +14,28 @@ namespace Microsoft.Agents.AI.Workflows;
/// Provides methods to initiate and manage in-process workflow executions, supporting both streaming and
/// non-streaming modes with asynchronous operations.
///
-public static class InProcessExecution
+public sealed class InProcessExecution
{
- internal static InProcessRunner CreateRunner(Workflow workflow, CheckpointManager? checkpointManager, string? runId)
- => new(workflow, checkpointManager, runId);
+ private static InProcessExecution DefaultInstance { get; } = new(ExecutionMode.Lockstep);
- internal static InProcessRunner CreateRunner(Workflow checkedWorkflow, CheckpointManager? checkpointManager, string? runId)
- where TInput : notnull
- => new(checkedWorkflow, checkpointManager, runId, [typeof(TInput)]);
-
- private static ValueTask StreamAsync(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
- => runner.StreamAsync(input, cancellationToken);
-
- private static ValueTask StreamAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
- => runner.StreamAsync(input, cancellationToken);
-
- private static ValueTask RunAsync(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
- => runner.RunAsync(input, cancellationToken);
-
- private static ValueTask RunAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
- => runner.RunAsync(input, cancellationToken);
-
- private static async ValueTask> StreamCheckpointedAsync(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
- where TInput : notnull
+ private readonly ExecutionMode _executionMode;
+ private InProcessExecution(ExecutionMode mode)
{
- StreamingRun run = await StreamAsync(runner, input, cancellationToken).ConfigureAwait(false);
- await runner.CheckpointAsync(cancellationToken).ConfigureAwait(false);
-
- return new(run, runner);
+ this._executionMode = mode;
}
- private static async ValueTask> StreamCheckpointedAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
- {
- StreamingRun run = await StreamAsync(runner, input, cancellationToken).ConfigureAwait(false);
- await runner.CheckpointAsync(cancellationToken).ConfigureAwait(false);
+ internal static ExecutionMode DefaultMode => DefaultInstance._executionMode;
- return new(run, runner);
+ internal ValueTask BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable knownValidInputTypes, CancellationToken cancellationToken)
+ {
+ InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes);
+ return runner.BeginStreamAsync(this._executionMode, cancellationToken);
}
- private static async ValueTask> RunCheckpointedAsync(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
- where TInput : notnull
+ internal ValueTask ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken)
{
- Run run = await RunAsync(runner, input, cancellationToken).ConfigureAwait(false);
- await runner.CheckpointAsync(cancellationToken).ConfigureAwait(false);
-
- return new(run, runner);
- }
-
- private static async ValueTask> RunCheckpointedAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
- {
- Run run = await RunAsync(runner, input, cancellationToken).ConfigureAwait(false);
- await runner.CheckpointAsync(cancellationToken).ConfigureAwait(false);
-
- return new(run, runner);
- }
-
- private static async ValueTask> ResumeStreamCheckpointedAsync(InProcessRunner runner, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default)
- {
- StreamingRun run = await runner.ResumeStreamAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false);
- return new(run, runner);
- }
-
- private static async ValueTask> ResumeRunCheckpointedAsync(InProcessRunner runner, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default)
- {
- Run run = await runner.ResumeAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false);
- return new(run, runner);
+ InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes);
+ return runner.ResumeStreamAsync(this._executionMode, fromCheckpoint, cancellationToken);
}
///
@@ -87,17 +48,19 @@ public static class InProcessExecution
/// The workflow to be executed. Must not be null.
/// The input message to be processed as part of the streaming run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask StreamAsync(
+ public static async ValueTask StreamAsync(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId);
- return StreamAsync(runner, (object)input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.EnqueueAndStreamAsync(input, cancellationToken).ConfigureAwait(false);
}
///
@@ -110,17 +73,19 @@ public static class InProcessExecution
/// The workflow to be executed. Must not be null.
/// The input message to be processed as part of the streaming run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask StreamAsync(
+ public static async ValueTask StreamAsync(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId);
- return StreamAsync(runner, input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.EnqueueAndStreamAsync(input, cancellationToken).ConfigureAwait(false);
}
///
@@ -134,18 +99,21 @@ public static class InProcessExecution
/// The input message to be processed as part of the streaming run.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> StreamAsync(
+ public static async ValueTask> StreamAsync(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return StreamCheckpointedAsync(runner, (object)input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager, runId: runId, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndStreamAsync(input, cancellationToken))
+ .ConfigureAwait(false);
}
///
@@ -159,18 +127,21 @@ public static class InProcessExecution
/// The input message to be processed as part of the streaming run.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> StreamAsync(
+ public static async ValueTask> StreamAsync(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return StreamCheckpointedAsync(runner, input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager, runId: runId, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndStreamAsync(input, cancellationToken))
+ .ConfigureAwait(false);
}
///
@@ -182,17 +153,20 @@ public static class InProcessExecution
/// The corresponding to the checkpoint from which to resume.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that provides access to the results of the streaming run.
- public static ValueTask> ResumeStreamAsync(
+ public static async ValueTask> ResumeStreamAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default)
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return ResumeStreamCheckpointedAsync(runner, fromCheckpoint, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.ResumeRunAsync(workflow, checkpointManager, runId: runId, fromCheckpoint, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => new(new StreamingRun(runHandle)))
+ .ConfigureAwait(false);
}
///
@@ -205,17 +179,20 @@ public static class InProcessExecution
/// The corresponding to the checkpoint from which to resume.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that provides access to the results of the streaming run.
- public static ValueTask> ResumeStreamAsync(
+ public static async ValueTask> ResumeStreamAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return ResumeStreamCheckpointedAsync(runner, fromCheckpoint, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.ResumeRunAsync(workflow, checkpointManager, runId: runId, fromCheckpoint, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => new(new StreamingRun(runHandle)))
+ .ConfigureAwait(false);
}
///
@@ -227,17 +204,19 @@ public static class InProcessExecution
/// The workflow to be executed. Must not be null.
/// The input message to be processed as part of the run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask RunAsync(
+ public static async ValueTask RunAsync(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId);
- return RunAsync(runner, (object)input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.EnqueueAndRunAsync(input, cancellationToken).ConfigureAwait(false);
}
///
@@ -249,17 +228,19 @@ public static class InProcessExecution
/// The workflow to be executed. Must not be null.
/// The input message to be processed as part of the run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask RunAsync(
+ public static async ValueTask RunAsync(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId);
- return RunAsync(runner, input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.EnqueueAndRunAsync(input, cancellationToken).ConfigureAwait(false);
}
///
@@ -272,18 +253,21 @@ public static class InProcessExecution
/// The input message to be processed as part of the run.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> RunAsync(
+ public static async ValueTask> RunAsync(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: checkpointManager, runId);
- return RunCheckpointedAsync(runner, (object)input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager, runId: runId, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndRunAsync(input, cancellationToken))
+ .ConfigureAwait(false);
}
///
@@ -296,18 +280,21 @@ public static class InProcessExecution
/// The input message to be processed as part of the run.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> RunAsync(
+ public static async ValueTask> RunAsync(
Workflow workflow,
TInput input,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager: checkpointManager, runId);
- return RunCheckpointedAsync(runner, input, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.BeginRunAsync(workflow, checkpointManager, runId: runId, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndRunAsync(input, cancellationToken))
+ .ConfigureAwait(false);
}
///
@@ -319,18 +306,21 @@ public static class InProcessExecution
/// The corresponding to the checkpoint from which to resume.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> ResumeAsync(
+ public static async ValueTask> ResumeAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default)
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return ResumeRunCheckpointedAsync(runner, fromCheckpoint, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.ResumeRunAsync(workflow, checkpointManager, runId: runId, fromCheckpoint, [], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => new(new Run(runHandle)))
+ .ConfigureAwait(false);
}
///
@@ -342,17 +332,20 @@ public static class InProcessExecution
/// The corresponding to the checkpoint from which to resume.
/// The to use with this run.
/// An optional unique identifier for the run. If not provided, a new identifier will be generated.
- /// The to monitor for cancellation requests. The default is .
+ /// The to monitor for cancellationToken requests. The default is .
/// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run.
- public static ValueTask> ResumeAsync(
+ public static async ValueTask> ResumeAsync(
Workflow workflow,
CheckpointInfo fromCheckpoint,
CheckpointManager checkpointManager,
string? runId = null,
CancellationToken cancellationToken = default) where TInput : notnull
{
- InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId);
- return ResumeRunCheckpointedAsync(runner, fromCheckpoint, cancellationToken);
+ AsyncRunHandle runHandle = await DefaultInstance.ResumeRunAsync(workflow, checkpointManager, runId: runId, fromCheckpoint, [typeof(TInput)], cancellationToken)
+ .ConfigureAwait(false);
+
+ return await runHandle.WithCheckpointingAsync(() => new(new Run(runHandle)))
+ .ConfigureAwait(false);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InputPort.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InputPort.cs
deleted file mode 100644
index b9212cdd29..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/InputPort.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-
-namespace Microsoft.Agents.AI.Workflows;
-
-///
-/// An external request port for a with the specified request and response types.
-///
-///
-///
-///
-public sealed record InputPort(string Id, Type Request, Type Response)
-{
- ///
- /// Creates a new instance configured for the specified request and response types.
- ///
- /// The type of the request messages that the input port will accept.
- /// The type of the response messages that the input port will produce.
- /// The unique identifier for the input port.
- /// An instance associated with the specified , configured to handle
- /// requests of type and responses of type .
- public static InputPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse));
-};
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs
index 3007303cd9..38865da089 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs
@@ -150,7 +150,7 @@ public sealed class PortableValue
/// When this method returns, contains the value cast or deserialized to type TValue
/// if the conversion succeeded, or null if the conversion failed.
/// true if the current instance can be assigned to targetType; otherwise, false.
- public bool IsType(Type targetType, out object? value)
+ public bool IsType(Type targetType, [NotNullWhen(true)] out object? value)
{
Throw.IfNull(targetType);
if (this.Value is IDelayedDeserialization delayedDeserialization)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPort.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPort.cs
new file mode 100644
index 0000000000..d77883b3e5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RequestPort.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// An external request port for a with the specified request and response types.
+///
+///
+///
+///
+public record RequestPort(string Id, Type Request, Type Response)
+{
+ ///
+ /// Creates a new instance configured for the specified request and response types.
+ ///
+ /// The type of the request messages that the input port will accept.
+ /// The type of the response messages that the input port will produce.
+ /// The unique identifier for the input port.
+ /// An instance associated with the specified , configured to handle
+ /// requests of type and responses of type .
+ public static RequestPort Create(string id) => new(id, typeof(TRequest), typeof(TResponse));
+};
+
+///
+/// An external request port for a with the specified request and response types.
+///
+///
+///
+///
+///
+public sealed record RequestPort(string Id, Type Request, Type Response, bool AllowWrapped = false) : RequestPort(Id, Request, Response);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs
index fe9ba12bcc..965de0cdaa 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs
@@ -73,7 +73,7 @@ public class RouteBuilder
return this;
}
- internal RouteBuilder AddHandler(Type type, Func handler, bool overwrite = false)
+ internal RouteBuilder AddHandlerUntyped(Type type, Func handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -86,7 +86,7 @@ public class RouteBuilder
}
}
- internal RouteBuilder AddHandler(Type type, Func> handler, bool overwrite = false)
+ internal RouteBuilder AddHandlerUntyped(Type type, Func> handler, bool overwrite = false)
{
Throw.IfNull(handler);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs
index 5773e1e243..0c83b4a970 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
@@ -12,6 +13,11 @@ namespace Microsoft.Agents.AI.Workflows;
///
public enum RunStatus
{
+ ///
+ /// The run has not yet started. This only occurs when running in "lockstep" mode.
+ ///
+ NotStarted,
+
///
/// The run has halted, has no outstanding requets, but has not received a .
///
@@ -22,11 +28,12 @@ public enum RunStatus
///
PendingRequests,
- // TODO: Figure out if we want to have some way to have a true "converged" state
- /////
- ///// The run has halted after converging.
- /////
- //Completed,
+ ///
+ /// The user has ended the run. No further events will be emitted, and no messages can be sent to it.
+ ///
+ ///
+ ///
+ Ended,
///
/// The workflow is currently running, and may receive events or requests.
@@ -40,49 +47,35 @@ public enum RunStatus
///
public sealed class Run
{
- internal static async ValueTask CaptureStreamAsync(StreamingRun run, CancellationToken cancellationToken = default)
- {
- Run result = new(run);
- await result.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false);
- return result;
- }
-
private readonly List _eventSink = [];
- private readonly StreamingRun _streamingRun;
- internal Run(StreamingRun streamingRun)
+ private readonly AsyncRunHandle _runHandle;
+ internal Run(AsyncRunHandle _runHandle)
{
- this._streamingRun = streamingRun;
+ this._runHandle = _runHandle;
}
internal async ValueTask RunToNextHaltAsync(CancellationToken cancellationToken = default)
{
bool hadEvents = false;
- this.Status = RunStatus.Running;
- await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken).ConfigureAwait(false))
+ await foreach (WorkflowEvent evt in this._runHandle.TakeEventStreamAsync(breakOnHalt: true, cancellationToken).ConfigureAwait(false))
{
hadEvents = true;
this._eventSink.Add(evt);
}
- // TODO: bookmark every halt for history visualization?
-
- this.Status =
- this._streamingRun.HasUnservicedRequests
- ? RunStatus.PendingRequests
- : RunStatus.Idle;
-
return hadEvents;
}
///
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
///
- public string RunId => this._streamingRun.RunId;
+ public string RunId => this._runHandle.RunId;
///
/// Gets the current execution status of the workflow run.
///
- public RunStatus Status { get; private set; }
+ public ValueTask GetStatusAsync(CancellationToken cancellation = default)
+ => this._runHandle.GetStatusAsync(cancellation);
///
/// Gets all events emitted by the workflow.
@@ -120,7 +113,7 @@ public sealed class Run
{
foreach (ExternalResponse response in responses)
{
- await this._streamingRun.SendResponseAsync(response).ConfigureAwait(false);
+ await this._runHandle.EnqueueResponseAsync(response, cancellationToken).ConfigureAwait(false);
}
return await this.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false);
@@ -129,25 +122,36 @@ public sealed class Run
///
/// Resume execution of the workflow with the provided external responses.
///
+ /// The to monitor for cancellation requests. The default is .
/// An array of messages to send to the workflow. Messages will only be sent if they are valid
/// input types to the starting executor or a .
- /// The to monitor for cancellation requests. The default is .
/// true if the workflow had any output events, false otherwise.
- public async ValueTask ResumeAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ public async ValueTask ResumeAsync(CancellationToken cancellationToken = default, params IEnumerable messages)
+ where T : notnull
{
if (messages is IEnumerable responses)
{
return await this.ResumeAsync(responses, cancellationToken).ConfigureAwait(false);
}
- foreach (T message in messages)
+ if (typeof(T) == typeof(object))
{
- await this._streamingRun.TrySendMessageAsync(message).ConfigureAwait(false);
+ foreach (object? message in messages)
+ {
+ await this._runHandle.EnqueueMessageUntypedAsync(message, cancellation: cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ foreach (T message in messages)
+ {
+ await this._runHandle.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
+ }
}
return await this.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false);
}
///
- public ValueTask EndRunAsync() => this._streamingRun.EndRunAsync();
+ public ValueTask EndRunAsync() => this._runHandle.RequestEndRunAsync();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
index 034dc702c9..6226b00db9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs
@@ -9,21 +9,26 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
+internal sealed class RequestPortOptions
+{
+}
+
internal sealed class RequestInfoExecutor : Executor
{
- private readonly Dictionary _wrappedRequests = [];
- private InputPort Port { get; }
+ private readonly Dictionary _wrappedRequests = new();
+ private RequestPort Port { get; }
private IExternalRequestSink? RequestSink { get; set; }
private static ExecutorOptions DefaultOptions => new()
{
// We need to be able to return the ExternalRequest/Result objects so they can be bubbled up
// through the event system, but we do not want to forward the Request message.
- AutoSendMessageHandlerResultObject = false
+ AutoSendMessageHandlerResultObject = false,
+ AutoYieldOutputHandlerResultObject = false
};
private readonly bool _allowWrapped;
- public RequestInfoExecutor(InputPort port, bool allowWrapped = true) : base(port.Id, DefaultOptions)
+ public RequestInfoExecutor(RequestPort port, bool allowWrapped = true) : base(port.Id, DefaultOptions)
{
this.Port = port;
@@ -34,7 +39,7 @@ internal sealed class RequestInfoExecutor : Executor
{
routeBuilder = routeBuilder
// Handle incoming requests (as raw request payloads)
- .AddHandler(this.Port.Request, this.HandleAsync)
+ .AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
if (this._allowWrapped)
@@ -45,12 +50,12 @@ internal sealed class RequestInfoExecutor : Executor
return routeBuilder
// Handle incoming responses (as wrapped Response object)
- .AddHandler(this.HandleAsync);
+ .AddHandler(this.HandleAsync);
}
internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink);
- public async ValueTask HandleCatchAllAsync(PortableValue message, IWorkflowContext context)
+ public async ValueTask HandleCatchAllAsync(PortableValue message, IWorkflowContext context)
{
Throw.IfNull(message);
@@ -61,9 +66,14 @@ internal sealed class RequestInfoExecutor : Executor
ExternalRequest request = ExternalRequest.Create(this.Port, maybeRequest!);
await this.RequestSink!.PostAsync(request).ConfigureAwait(false);
+ return request;
+ }
+ else if (message.Is(out ExternalRequest? request))
+ {
+ return await this.HandleAsync(request, context).ConfigureAwait(false);
}
- throw new InvalidOperationException($"Message type {message.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}");
+ return null;
}
public async ValueTask HandleAsync(ExternalRequest message, IWorkflowContext context)
@@ -71,7 +81,7 @@ internal sealed class RequestInfoExecutor : Executor
Debug.Assert(this._allowWrapped);
Throw.IfNull(message);
- if (!message.Data.IsType(this.Port.Request))
+ if (!message.Data.IsType(this.Port.Request, out var requestData))
{
throw new InvalidOperationException($"Message type {message.Data.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}");
}
@@ -81,9 +91,9 @@ internal sealed class RequestInfoExecutor : Executor
throw new InvalidOperationException($"Response type {this.Port.Response} is not a valid response for original request, whose expected response is {message.PortInfo.ResponseType}");
}
- ExternalRequest request = ExternalRequest.Create(this.Port, message);
+ ExternalRequest request = ExternalRequest.Create(this.Port, requestData, message.RequestId);
- this._wrappedRequests.Add(request.RequestId, message);
+ this._wrappedRequests.Add(message.RequestId, message);
await this.RequestSink!.PostAsync(request).ConfigureAwait(false);
@@ -101,11 +111,16 @@ internal sealed class RequestInfoExecutor : Executor
return request;
}
- public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context)
+ public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context)
{
Throw.IfNull(message);
Throw.IfNull(message.Data);
+ if (message.PortInfo.PortId != this.Port.Id)
+ {
+ return null;
+ }
+
object data = message.DataAs(this.Port.Response) ??
throw new InvalidOperationException(
$"Message type {message.Data.TypeId} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}.");
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
new file mode 100644
index 0000000000..74a4f87b32
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs
@@ -0,0 +1,266 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Checkpointing;
+using Microsoft.Agents.AI.Workflows.Execution;
+using Microsoft.Agents.AI.Workflows.InProc;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Workflows.Specialized;
+
+internal class WorkflowHostExecutor : Executor, IResettableExecutor
+{
+ private readonly string _runId;
+ private readonly Workflow _workflow;
+ private readonly object _ownershipToken;
+
+ private InProcessRunner? _activeRunner;
+ private InMemoryCheckpointManager? _checkpointManager;
+ private readonly ExecutorOptions _options;
+
+ private ISuperStepJoinContext? _joinContext;
+ private StreamingRun? _run;
+
+ [MemberNotNullWhen(true, nameof(_checkpointManager))]
+ private bool WithCheckpointing => this._checkpointManager != null;
+
+ public WorkflowHostExecutor(string id, Workflow workflow, string runId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
+ {
+ this._options = options ?? new();
+
+ Throw.IfNull(workflow);
+ this._runId = Throw.IfNull(runId);
+ this._ownershipToken = Throw.IfNull(ownershipToken);
+ this._workflow = Throw.IfNull(workflow);
+ }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder.AddCatchAll(this.QueueExternalMessageAsync);
+ }
+
+ private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context)
+ {
+ if (portableValue.Is(out ExternalResponse? response))
+ {
+ response = this.CheckAndUnqualifyResponse(response);
+ await this.EnsureRunSendMessageAsync(response).ConfigureAwait(false);
+ }
+ else
+ {
+ InProcessRunner runner = await this.EnsureRunnerAsync().ConfigureAwait(false);
+ IEnumerable validInputTypes = await runner.RunContext.GetStartingExecutorInputTypesAsync().ConfigureAwait(false);
+ foreach (Type candidateType in validInputTypes)
+ {
+ if (portableValue.IsType(candidateType, out object? message))
+ {
+ await this.EnsureRunSendMessageAsync(message, candidateType).ConfigureAwait(false);
+ return;
+ }
+ }
+ }
+ }
+
+ private ISuperStepJoinContext JoinContext => Throw.IfNull(this._joinContext, "Must attach to a join context before starting the run.");
+
+ internal async ValueTask EnsureRunnerAsync()
+ {
+ if (this._activeRunner == null)
+ {
+ if (this.JoinContext.WithCheckpointing)
+ {
+ // Use a seprate in-memory checkpoint manager for scoping purposes. We do not need to worry about
+ // serialization because we will be relying on the parent workflow's checkpoint manager to do that,
+ // if needed. For our purposes, all we need is to keep a faithful representation of the checkpointed
+ // objects so we can emit them back to the parent workflow on checkpoint creation.
+ this._checkpointManager = new InMemoryCheckpointManager();
+ }
+
+ this._activeRunner = new(this._workflow, this._checkpointManager, this._runId, this._ownershipToken, subworkflow: true);
+ }
+
+ return this._activeRunner;
+ }
+
+ internal async ValueTask EnsureRunSendMessageAsync(object? incomingMessage = null, Type? incomingMessageType = null, bool resume = false, CancellationToken cancellation = default)
+ {
+ Debug.Assert(this._joinContext != null, "Must attach to a join context before starting the run.");
+
+ if (this._run != null)
+ {
+ if (incomingMessage != null)
+ {
+ await this._run.TrySendMessageUntypedAsync(incomingMessage, incomingMessageType ?? incomingMessage.GetType()).ConfigureAwait(false);
+ }
+
+ return this._run;
+ }
+
+ InProcessRunner activeRunner = await this.EnsureRunnerAsync().ConfigureAwait(false);
+ AsyncRunHandle runHandle;
+
+ if (this.WithCheckpointing)
+ {
+ if (resume)
+ {
+ // Attempting to resume from checkpoint
+ if (!this._checkpointManager.TryGetLastCheckpoint(this._runId, out CheckpointInfo? lastCheckpoint))
+ {
+ throw new InvalidOperationException("No checkpoints available to resume from.");
+ }
+
+ runHandle = await activeRunner.ResumeStreamAsync(InProcessExecution.DefaultMode, lastCheckpoint!, cancellation)
+ .ConfigureAwait(false);
+ if (incomingMessage != null)
+ {
+ await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellation).ConfigureAwait(false);
+ }
+ }
+ else if (incomingMessage != null)
+ {
+ runHandle = await activeRunner.BeginStreamAsync(InProcessExecution.DefaultMode, cancellation)
+ .ConfigureAwait(false);
+
+ await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellation).ConfigureAwait(false);
+ }
+ else
+ {
+ throw new InvalidOperationException("Cannot start a checkpointed workflow run without an incoming message or resume flag.");
+ }
+ }
+ else
+ {
+ runHandle = await activeRunner.BeginStreamAsync(InProcessExecution.DefaultMode, cancellation).ConfigureAwait(false);
+
+ await runHandle.EnqueueMessageUntypedAsync(Throw.IfNull(incomingMessage), cancellation: cancellation).ConfigureAwait(false);
+ }
+
+ this._run = new(runHandle);
+
+ await this._joinContext.AttachSuperstepAsync(activeRunner, cancellation).ConfigureAwait(false);
+ activeRunner.OutgoingEvents.EventRaised += this.ForwardWorkflowEventAsync;
+
+ return this._run;
+ }
+
+ private ExternalResponse? CheckAndUnqualifyResponse([DisallowNull] ExternalResponse response)
+ {
+ if (!Throw.IfNull(response).PortInfo.PortId.StartsWith($"{this.Id}.", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ RequestPortInfo unqualifiedPort = response.PortInfo with { PortId = response.PortInfo.PortId.Substring(this.Id.Length + 1) };
+ return response with { PortInfo = unqualifiedPort };
+ }
+
+ private ExternalRequest QualifyRequestPortId(ExternalRequest internalRequest)
+ {
+ RequestPortInfo requestPort = internalRequest.PortInfo with { PortId = $"{this.Id}.{internalRequest.PortInfo.PortId}" };
+ return internalRequest with { PortInfo = requestPort };
+ }
+
+ private async ValueTask ForwardWorkflowEventAsync(object? sender, WorkflowEvent evt)
+ {
+ // Note that we are explicitly not using the checked JoinContext property here, because this is an async callback.
+ try
+ {
+ Task resultTask = Task.CompletedTask;
+ switch (evt)
+ {
+ case WorkflowStartedEvent:
+ case SuperStepStartedEvent:
+ case SuperStepCompletedEvent:
+ // These events are internal to the subworkflow and do not need to be forwarded.
+ break;
+ case RequestInfoEvent requestInfoEvt:
+ ExternalRequest request = requestInfoEvt.Request;
+ resultTask = this._joinContext?.SendMessageAsync(this.Id, this.QualifyRequestPortId(request)).AsTask() ?? Task.CompletedTask;
+ break;
+ case WorkflowErrorEvent errorEvent:
+ resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, errorEvent.Data as Exception)).AsTask() ?? Task.CompletedTask;
+ break;
+ case WorkflowOutputEvent outputEvent:
+ if (this._joinContext != null &&
+ this._options.AutoSendMessageHandlerResultObject
+ && outputEvent.Data != null)
+ {
+ resultTask = this._joinContext.SendMessageAsync(this.Id, outputEvent.Data).AsTask();
+ }
+ break;
+ case RequestHaltEvent requestHaltEvent:
+ resultTask = this._joinContext?.ForwardWorkflowEventAsync(new RequestHaltEvent()).AsTask() ?? Task.CompletedTask;
+ break;
+ case WorkflowWarningEvent warningEvent:
+ if (warningEvent.Data is string warningMessage)
+ {
+ resultTask = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowWarningEvent(this.Id, warningMessage)).AsTask() ?? Task.CompletedTask;
+ }
+ break;
+ default:
+ resultTask = this._joinContext?.ForwardWorkflowEventAsync(evt).AsTask() ?? Task.CompletedTask;
+ break;
+ }
+
+ await resultTask.ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ try
+ {
+ _ = this._joinContext?.ForwardWorkflowEventAsync(new SubworkflowErrorEvent(this.Id, ex)).AsTask();
+ }
+ catch
+ { }
+ }
+ }
+
+ internal async ValueTask AttachSuperStepContextAsync(ISuperStepJoinContext joinContext)
+ {
+ this._joinContext = Throw.IfNull(joinContext);
+ }
+
+ protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ await context.QueueStateUpdateAsync(nameof(CheckpointManager), this._checkpointManager).ConfigureAwait(false);
+
+ await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
+
+ InMemoryCheckpointManager manager = await context.ReadStateAsync(nameof(InMemoryCheckpointManager)).ConfigureAwait(false) ?? new();
+ if (this._checkpointManager == manager)
+ {
+ // We are restoring in the context of the same run; not need to rebuild the entire execution stack.
+ }
+ else
+ {
+ this._checkpointManager = manager;
+
+ await this.ResetAsync().ConfigureAwait(false);
+ }
+
+ StreamingRun run = await this.EnsureRunSendMessageAsync(cancellation: cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask ResetAsync()
+ {
+ this._run = null;
+
+ if (this._activeRunner != null)
+ {
+ this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync;
+ await this._activeRunner.RequestEndRunAsync().ConfigureAwait(false);
+
+ this._activeRunner = new(this._workflow, this._checkpointManager, this._runId);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
index 035f25bdfc..0d8e9f370c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs
@@ -2,13 +2,11 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
-using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -19,27 +17,26 @@ namespace Microsoft.Agents.AI.Workflows;
///
public sealed class StreamingRun
{
- private TaskCompletionSource? _waitForResponseSource;
- private readonly ISuperStepRunner _stepRunner;
+ private readonly AsyncRunHandle _runHandle;
- private static readonly string s_namespace = typeof(StreamingRun).Namespace!;
- private static readonly ActivitySource s_activitySource = new(s_namespace);
-
- ///
- /// Gets a value indicating whether there are any outstanding s for which a
- /// has not been sent.
- ///
- public bool HasUnservicedRequests => this._stepRunner.HasUnservicedRequests;
-
- internal StreamingRun(ISuperStepRunner stepRunner)
+ internal StreamingRun(AsyncRunHandle runHandle)
{
- this._stepRunner = Throw.IfNull(stepRunner);
+ this._runHandle = Throw.IfNull(runHandle);
}
+ private ValueTask WaitOnInputAsync(CancellationToken cancellation = default)
+ => this._runHandle.WaitForNextInputAsync(cancellation);
+
///
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
///
- public string RunId => this._stepRunner.RunId;
+ public string RunId => this._runHandle.RunId;
+
+ ///
+ /// Gets the current execution status of the workflow run.
+ ///
+ public ValueTask GetStatusAsync(CancellationToken cancellation = default)
+ => this._runHandle.GetStatusAsync(cancellation);
///
/// Asynchronously sends the specified response to the external system and signals completion of the current
@@ -49,11 +46,7 @@ public sealed class StreamingRun
/// The to send. Must not be null.
/// A that represents the asynchronous send operation.
public ValueTask SendResponseAsync(ExternalResponse response)
- {
- this._waitForResponseSource?.TrySetResult(new());
-
- return this._stepRunner.EnqueueResponseAsync(response);
- }
+ => this._runHandle.EnqueueResponseAsync(response);
///
/// Attempts to send the specified message asynchronously and returns a value indicating whether the operation was
@@ -65,18 +58,11 @@ public sealed class StreamingRun
/// A that represents the asynchronous send operation. It's
/// is if the message was sent
/// successfully; otherwise, .
- public async ValueTask TrySendMessageAsync(TMessage message)
- {
- Throw.IfNull(message);
+ public ValueTask TrySendMessageAsync(TMessage message)
+ => this._runHandle.EnqueueMessageAsync(message);
- if (message is ExternalResponse response)
- {
- await this.SendResponseAsync(response).ConfigureAwait(false);
- return true;
- }
-
- return await this._stepRunner.EnqueueMessageAsync(message).ConfigureAwait(false);
- }
+ internal ValueTask TrySendMessageUntypedAsync(object message, Type? declaredType = null)
+ => this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
///
/// Asynchronously streams workflow events as they occur during workflow execution.
@@ -96,94 +82,35 @@ public sealed class StreamingRun
bool blockOnPendingRequest,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- List eventSink = [];
+ RunStatus runStatus;
- this._stepRunner.WorkflowEvent += OnWorkflowEvent;
-
- using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
- activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this.RunId);
-
- try
+ do
{
- activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
- do
+ await foreach (WorkflowEvent @event in this._runHandle.TakeEventStreamAsync(breakOnHalt: true, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
{
- // Because we may be yielding out of this function, we need to ensure that the Activity.Current
- // is set to our activity for the duration of this loop iteration.
- Activity.Current = activity;
+ yield return @event;
+ }
- // Drain SuperSteps while there are steps to run
- try
- {
- await this._stepRunner.RunSuperStepAsync(cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex) when (activity is not null)
- {
- activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
- { Tags.ErrorType, ex.GetType().FullName },
- { Tags.BuildErrorMessage, ex.Message },
- }));
- activity.CaptureException(ex);
- throw;
- }
+ if (cancellationToken.IsCancellationRequested)
+ {
+ yield break; // We are done.
+ }
- if (cancellationToken.IsCancellationRequested)
- {
- yield break; // Exit if cancellation is requested
- }
+ runStatus = await this._runHandle.GetStatusAsync(cancellationToken).ConfigureAwait(false);
+ if (runStatus == RunStatus.Idle)
+ {
+ yield break; // We are done.
+ }
- bool hadCompletionEvent = false;
- foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, []))
- {
- if (cancellationToken.IsCancellationRequested)
- {
- yield break; // Exit if cancellation is requested
- }
-
- // TODO: Do we actually want to interpret this as a termination request?
- if (raisedEvent is RequestHaltEvent)
- {
- hadCompletionEvent = true;
- }
- else
- {
- yield return raisedEvent;
- }
- }
-
- if (hadCompletionEvent)
- {
- // If we had a completion event, we are done.
- yield break;
- }
-
- // If we do not have any actions to take on the Workflow, but have unprocessed
- // requests, wait for the responses to come in before exiting out of the workflow
- // execution.
- if (blockOnPendingRequest &&
- !this._stepRunner.HasUnprocessedMessages &&
- this._stepRunner.HasUnservicedRequests)
- {
- this._waitForResponseSource ??= new();
-
- using CancellationTokenRegistration registration = cancellationToken.Register(() => this._waitForResponseSource?.SetResult(new()));
-
- await this._waitForResponseSource.Task.ConfigureAwait(false);
- this._waitForResponseSource = null;
- }
- } while (this._stepRunner.HasUnprocessedMessages);
-
- activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
- }
- finally
- {
- this._stepRunner.WorkflowEvent -= OnWorkflowEvent;
- }
-
- void OnWorkflowEvent(object? sender, WorkflowEvent e)
- {
- eventSink.Add(e);
- }
+ if (blockOnPendingRequest && runStatus == RunStatus.PendingRequests)
+ {
+ // Although we are only doing this while there are pending requests, any input allows us to continue
+ // running, so we should not wait until the input is specifically an ExternalResponse.
+ await this.WaitOnInputAsync(cancellationToken).ConfigureAwait(false);
+ }
+ } while (runStatus == RunStatus.Running);
}
///
@@ -192,7 +119,10 @@ public sealed class StreamingRun
///
/// A ValueTask that represents the asynchronous operation. The task is complete when the run has
/// ended and cleanup is finished.
- public ValueTask EndRunAsync() => this._stepRunner.RequestEndRunAsync();
+ public async ValueTask EndRunAsync()
+ {
+ await this._runHandle.DisposeAsync().ConfigureAwait(false);
+ }
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowErrorEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowErrorEvent.cs
new file mode 100644
index 0000000000..094f9c5bfe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowErrorEvent.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Event triggered when a workflow encounters an error.
+///
+/// The ID of the subworkflow that encountered the error.
+/// Optionally, the representing the error.
+public sealed class SubworkflowErrorEvent(string subworkflowId, Exception? e) : WorkflowErrorEvent(e)
+{
+ ///
+ /// Gets the ID of the subworkflow that encountered the error.
+ ///
+ public string SubworkflowId { get; } = subworkflowId;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowWarningEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowWarningEvent.cs
new file mode 100644
index 0000000000..ef692a975b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SubworkflowWarningEvent.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows;
+
+///
+/// Event triggered when a subworkflow encounters a warning-confition.
+/// sub-workflow.
+///
+/// The warning message.
+/// The unique identifier of the sub-workflow that triggered the warning. Cannot be null or empty.
+public sealed class SubworkflowWarningEvent(string message, string subWorkflowId) : WorkflowWarningEvent(message)
+{
+ ///
+ /// The unique identifier of the sub-workflow that triggered the warning.
+ ///
+ public string SubWorkflowId { get; } = subWorkflowId;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SuperStepCompletionInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SuperStepCompletionInfo.cs
index da5d5cd59b..09f231b82a 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/SuperStepCompletionInfo.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SuperStepCompletionInfo.cs
@@ -8,17 +8,17 @@ namespace Microsoft.Agents.AI.Workflows;
///
/// Debug information about the SuperStep that finished running.
///
-public sealed class SuperStepCompletionInfo(HashSet activatedExecutors, HashSet? instantiatedExecutors = null)
+public sealed class SuperStepCompletionInfo(IEnumerable activatedExecutors, IEnumerable? instantiatedExecutors = null)
{
///
/// The unique identifiers of instances that processed messages during this SuperStep
///
- public HashSet ActivatedExecutors { get; } = Throw.IfNull(activatedExecutors);
+ public HashSet ActivatedExecutors { get; } = [.. Throw.IfNull(activatedExecutors)];
///
/// The unique identifiers of instances newly created during this SuperStep
///
- public HashSet InstantiatedExecutors { get; } = instantiatedExecutors ?? [];
+ public HashSet InstantiatedExecutors { get; } = [.. instantiatedExecutors ?? []];
///
/// A flag indicating whether the managed state was written to during this SuperStep. If the run was started
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
index 62b09ebfa5..67c6970bf3 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs
@@ -35,7 +35,7 @@ public class Workflow
);
}
- internal Dictionary Ports { get; init; } = [];
+ internal Dictionary Ports { get; init; } = [];
///
/// Gets the collection of external request ports, keyed by their ID.
@@ -43,7 +43,7 @@ public class Workflow
///
/// Each port has a corresponding entry in the dictionary.
///
- public Dictionary ReflectPorts()
+ public Dictionary ReflectPorts()
{
return this.Ports.Keys.ToDictionary(
keySelector: key => key,
@@ -84,7 +84,7 @@ public class Workflow
// TODO: Can we cache this somehow to avoid having to instantiate a new one when running?
// Does that break some user expectations?
- Executor startExecutor = await startRegistration.ProviderAsync().ConfigureAwait(false);
+ Executor startExecutor = await startRegistration.CreateInstanceAsync(string.Empty).ConfigureAwait(false);
if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(TInput))))
{
@@ -125,9 +125,16 @@ public class Workflow
}
private object? _ownerToken;
- internal void TakeOwnership(object ownerToken)
+ private bool _ownedAsSubworkflow;
+ internal void TakeOwnership(object ownerToken, bool subworkflow = false, object? existingOwnershipSignoff = null)
{
- object? maybeToken = Interlocked.CompareExchange(ref this._ownerToken, ownerToken, null);
+ object? maybeToken = Interlocked.CompareExchange(ref this._ownerToken, ownerToken, existingOwnershipSignoff);
+ if (maybeToken == null && existingOwnershipSignoff != null)
+ {
+ // We expected to already be owned, but we were not
+ throw new InvalidOperationException("Existing ownership token was provided, but the workflow is unowned.");
+ }
+
if (maybeToken == null && this._needsReset)
{
// There is no owner, but the workflow failed to reset on ownership release (because there are
@@ -137,14 +144,22 @@ public class Workflow
);
}
- if (maybeToken != null && !ReferenceEquals(maybeToken, ownerToken))
+ if (!ReferenceEquals(maybeToken, existingOwnershipSignoff) && !ReferenceEquals(maybeToken, ownerToken))
{
// Someone else owns the workflow
Debug.Assert(maybeToken != null);
- throw new InvalidOperationException("Cannot use a Workflow in multiple simultaneous (Streaming)Runs.");
+ throw new InvalidOperationException(
+ (subworkflow, this._ownedAsSubworkflow) switch
+ {
+ (true, true) => "Cannot use a Workflow as a subworkflow of multiple parent workflows.",
+ (true, false) => "Cannot use a running Workflow as a subworkflow.",
+ (false, true) => "Cannot directly run a Workflow that is a subworkflow of another workflow.",
+ (false, false) => "Cannot use a Workflow that is already owned by another runner or parent workflow.",
+ });
}
this._needsReset = true;
+ this._ownedAsSubworkflow = subworkflow;
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper",
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
index cab8d83823..676a3a02cf 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
@@ -32,7 +32,7 @@ public class WorkflowBuilder
private readonly Dictionary> _edges = [];
private readonly HashSet _unboundExecutors = [];
private readonly HashSet _conditionlessConnections = [];
- private readonly Dictionary _inputPorts = [];
+ private readonly Dictionary _inputPorts = [];
private readonly HashSet _outputExecutors = [];
private readonly string _startExecutorId;
@@ -88,9 +88,9 @@ public class WorkflowBuilder
}
}
- if (executorish.ExecutorType == ExecutorIsh.Type.InputPort)
+ if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort)
{
- InputPort port = executorish._inputPortValue!;
+ RequestPort port = executorish._requestPortValue!;
this._inputPorts[port.Id] = port;
}
@@ -151,11 +151,13 @@ public class WorkflowBuilder
///
/// The executor that acts as the source node of the edge. Cannot be null.
/// The executor that acts as the target node of the edge. Cannot be null.
+ /// If set to , adding the same edge multiple times will be a NoOp,
+ /// rather than an error.
/// The current instance of .
/// Thrown if an unconditional edge between the specified source and target
/// executors already exists.
- public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target)
- => this.AddEdge(source, target, null);
+ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
+ => this.AddEdge(source, target, null, idempotent);
internal static Func? CreateConditionFunc(Func? condition)
{
@@ -185,7 +187,13 @@ public class WorkflowBuilder
{
maybeObj = portableValue.AsType(typeof(T));
}
- return condition(maybeObj);
+
+ if (maybeObj is T typed)
+ {
+ return condition(typed);
+ }
+
+ return condition(null);
};
}
@@ -198,11 +206,13 @@ public class WorkflowBuilder
/// The executor that acts as the source node of the edge. Cannot be null.
/// The executor that acts as the target node of the edge. Cannot be null.
/// An optional predicate that determines whether the edge should be followed based on the input.
+ /// If set to , adding the same edge multiple times will be a NoOp,
+ /// rather than an error.
/// If null, the edge is always activated when the source sends a message.
/// The current instance of .
/// Thrown if an unconditional edge between the specified source and target
/// executors already exists.
- public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null)
+ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null, bool idempotent = false)
{
// Add an edge from source to target with an optional condition.
// This is a low-level builder method that does not enforce any specific executor type.
@@ -213,6 +223,11 @@ public class WorkflowBuilder
EdgeConnection connection = new(source.Id, target.Id);
if (condition is null && this._conditionlessConnections.Contains(connection))
{
+ if (idempotent)
+ {
+ return this;
+ }
+
throw new InvalidOperationException(
$"An edge from '{source.Id}' to '{target.Id}' already exists without a condition. " +
"You cannot add another edge without a condition for the same source and target.");
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs
index 49366a327f..a0d42ac35f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs
@@ -25,10 +25,24 @@ public static class WorkflowBuilderExtensions
/// The target executors to which messages will be forwarded.
/// The updated instance.
public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors)
+ => builder.ForwardMessage(source, condition: null, executors);
+
+ ///
+ /// Adds edges to the workflow that forward messages of the specified type from the source executor to
+ /// one or more target executors.
+ ///
+ /// The type of message to forward.
+ /// The to which the edges will be added.
+ /// The source executor from which messages will be forwarded.
+ /// An optional condition that messages must satisfy to be forwarded. If ,
+ /// all messages of type will be forwarded.
+ /// The target executors to which messages will be forwarded.
+ /// The updated instance.
+ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorIsh source, Func? condition = null, params IEnumerable executors)
{
Throw.IfNull(executors);
- Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!;
+ Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!;
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
@@ -43,7 +57,7 @@ public static class WorkflowBuilderExtensions
// The reason we can check for "not null" here is that CreateConditionFunc will do the correct unwrapping
// logic for PortableValues.
- static bool IsAllowedType(object? message) => message is not null;
+ bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message));
}
///
@@ -84,10 +98,11 @@ public static class WorkflowBuilderExtensions
/// executor in the order provided.
/// The workflow builder to which the executor chain will be added.
/// The initial executor in the chain. Cannot be null.
+ /// If set to , the same executor can be added to the chain multiple times.
/// An ordered array of executors to be added to the chain after the source.
/// The original workflow builder instance with the specified executor chain added.
/// Thrown if there is a cycle in the chain.
- public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable executors)
+ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable executors)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -98,13 +113,13 @@ public static class WorkflowBuilderExtensions
{
Throw.IfNull(executor, nameof(executors));
- if (seenExecutors.Contains(executor.Id))
+ if (!allowRepetition && seenExecutors.Contains(executor.Id))
{
throw new ArgumentException($"Executor '{executor.Id}' is already in the chain.", nameof(executors));
}
seenExecutors.Add(executor.Id);
- builder.AddEdge(source, executor);
+ builder.AddEdge(source, executor, idempotent: true);
source = executor;
}
@@ -130,7 +145,7 @@ public static class WorkflowBuilderExtensions
Throw.IfNull(source);
Throw.IfNull(portId);
- InputPort port = new(portId, typeof(TRequest), typeof(TResponse));
+ RequestPort port = new(portId, typeof(TRequest), typeof(TResponse));
return builder.AddEdge(source, port)
.AddEdge(port, source);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
index 84d8d6b45e..7af7efd0b9 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowErrorEvent.cs
@@ -10,4 +10,4 @@ namespace Microsoft.Agents.AI.Workflows;
///
/// Optionally, the representing the error.
///
-public sealed class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
+public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs
index 5adc952580..d4e177b338 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs
@@ -15,6 +15,6 @@ internal class WorkflowJsonDefinitionData
{
public string StartExecutorId { get; set; } = string.Empty;
public IEnumerable Edges { get; set; } = [];
- public IEnumerable Ports { get; set; } = [];
+ public IEnumerable Ports { get; set; } = [];
public IEnumerable OutputExecutors { get; set; } = [];
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowWarningEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowWarningEvent.cs
index a70a614ebe..75db2524e0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowWarningEvent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowWarningEvent.cs
@@ -6,4 +6,4 @@ namespace Microsoft.Agents.AI.Workflows;
/// Event triggered when a workflow encounters a warning-condition.
///
/// The warning message.
-public sealed class WorkflowWarningEvent(string message) : WorkflowEvent(message);
+public class WorkflowWarningEvent(string message) : WorkflowEvent(message);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
index ef1186a64f..33cf002517 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs
@@ -67,6 +67,7 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(CheckpointInfo))]
[JsonSerializable(typeof(PortableValue))]
[JsonSerializable(typeof(PortableMessageEnvelope))]
+ [JsonSerializable(typeof(InMemoryCheckpointManager))]
// Runtime State Types
[JsonSerializable(typeof(ScopeKey))]
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs
index 28756c6b91..fff53a8f55 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs
@@ -102,7 +102,8 @@ public class InProcessStateTests
Run run = await InProcessExecution.RunAsync(workflow, new());
- run.Status.Should().Be(RunStatus.Idle);
+ RunStatus status = await run.GetStatusAsync();
+ status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
@@ -132,8 +133,10 @@ public class InProcessStateTests
Checkpointed checkpointed = await InProcessExecution.RunAsync(workflow, new(), CheckpointManager.Default);
- checkpointed.Checkpoints.Should().HaveCount(5);
- checkpointed.Run.Status.Should().Be(RunStatus.Idle);
+ checkpointed.Checkpoints.Should().HaveCount(4);
+
+ RunStatus status = await checkpointed.Run.GetStatusAsync();
+ status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs
index 779943dbc8..7447f46128 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs
@@ -89,11 +89,11 @@ public class JsonSerializationTests
}
}
- private static InputPort TestPort => InputPort.Create("StringToInt");
- private static InputPortInfo TestPortInfo => TestPort.ToPortInfo();
+ private static RequestPort TestPort => RequestPort.Create("StringToInt");
+ private static RequestPortInfo TestPortInfo => TestPort.ToPortInfo();
[Fact]
- public void Test_InputPortInfo_JsonRoundtrip()
+ public void Test_RequestPortInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestPortInfo, predicate: TestPort.CreatePortInfoValidator());
}
@@ -152,16 +152,16 @@ public class JsonSerializationTests
private const string IntToStringId = nameof(IntToString);
private const string StringToIntId = nameof(StringToInt);
- private static InputPortInfo IntToString => InputPort.Create(IntToStringId).ToPortInfo();
- private static InputPortInfo StringToInt => InputPort.Create(StringToIntId).ToPortInfo();
+ private static RequestPortInfo IntToString => RequestPort.Create(IntToStringId).ToPortInfo();
+ private static RequestPortInfo StringToInt => RequestPort.Create(StringToIntId).ToPortInfo();
private static ValueTask> CreateTestWorkflowAsync()
{
ForwardMessageExecutor forwardString = new(ForwardStringId);
ForwardMessageExecutor forwardInt = new(ForwardIntId);
- InputPort stringToInt = InputPort.Create(StringToIntId);
- InputPort intToString = InputPort.Create(IntToStringId);
+ RequestPort stringToInt = RequestPort.Create(StringToIntId);
+ RequestPort intToString = RequestPort.Create(IntToStringId);
WorkflowBuilder builder = new(forwardString);
builder.AddEdge(forwardString, stringToInt)
@@ -181,7 +181,7 @@ public class JsonSerializationTests
private static void ValidateWorkflowInfo(WorkflowInfo actual, WorkflowInfo prototype)
{
ValidateExecutorDictionary(prototype.Executors, prototype.Edges, actual.Executors, actual.Edges);
- ValidateInputPorts(prototype.InputPorts, actual.InputPorts);
+ ValidateRequestPorts(prototype.RequestPorts, actual.RequestPorts);
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
@@ -225,7 +225,7 @@ public class JsonSerializationTests
}
}
- void ValidateInputPorts(HashSet expected, HashSet actual)
+ void ValidateRequestPorts(HashSet expected, HashSet actual)
=> actual.Should().HaveCount(expected.Count).And.IntersectWith(expected);
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs
index 656ce96aa3..85f5baee6a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs
@@ -35,15 +35,15 @@ public class RepresentationTests
throw new NotImplementedException();
}
- private static InputPort TestInputPort =>
- InputPort.Create("ExternalFunction");
+ private static RequestPort TestRequestPort =>
+ RequestPort.Create("ExternalFunction");
private static async ValueTask RunExecutorishInfoMatchTestAsync(ExecutorIsh target)
{
ExecutorRegistration registration = target.Registration;
ExecutorInfo info = registration.ToExecutorInfo();
- info.IsMatch(await registration.ProviderAsync()).Should().BeTrue();
+ info.IsMatch(await registration.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
}
[Fact]
@@ -51,8 +51,9 @@ public class RepresentationTests
{
int testsRun = 0;
await RunExecutorishTestAsync(new TestExecutor());
- await RunExecutorishTestAsync(TestInputPort);
+ await RunExecutorishTestAsync(TestRequestPort);
await RunExecutorishTestAsync(new TestAgent());
+ await RunExecutorishTestAsync(Step1EntryPoint.WorkflowInstance.ConfigureSubWorkflow(nameof(Step1EntryPoint)));
Func function = MessageHandlerAsync;
await RunExecutorishTestAsync(function.AsExecutor("FunctionExecutor"));
@@ -77,7 +78,7 @@ public class RepresentationTests
public async Task Test_SpecializedExecutor_InfosAsync()
{
await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
- await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestInputPort));
+ await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
}
private static string Source(int id) => $"Source/{id}";
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs
index 90e35bc6e2..fff05ae607 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs
@@ -17,7 +17,7 @@ internal static class Step1EntryPoint
ReverseTextExecutor reverse = new();
WorkflowBuilder builder = new(uppercase);
- builder.AddEdge(uppercase, reverse);
+ builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
return builder.Build();
}
@@ -49,7 +49,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutor("GuessNumber");
+ RequestPort guessNumber = RequestPort.Create("GuessNumber");
judge = new(JudgeId, 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs
index d630c44a85..4fd983f0e3 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs
@@ -37,6 +37,7 @@ internal static class Step5EntryPoint
CheckpointInfo targetCheckpoint = checkpoints[2];
+ Console.WriteLine($"Restoring to checkpoint {targetCheckpoint} from run {targetCheckpoint.RunId}");
if (rehydrateToRestore)
{
await handle.EndRunAsync().ConfigureAwait(false);
@@ -72,6 +73,7 @@ internal static class Step5EntryPoint
List requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync(cancellationSource.Token).ConfigureAwait(false))
{
+ Console.WriteLine($"!!! Processing event: {evt}");
switch (evt)
{
case WorkflowOutputEvent outputEvent:
@@ -91,11 +93,14 @@ internal static class Step5EntryPoint
break;
case RequestInfoEvent requestInputEvt:
+ Console.WriteLine($"!!! Queuing request: {requestInputEvt.Request}");
requests.Add(requestInputEvt.Request);
break;
case SuperStepCompletedEvent stepCompletedEvt:
+ Console.WriteLine($"*** Step {stepCompletedEvt.StepNumber} completed.");
CheckpointInfo? checkpoint = stepCompletedEvt.CompletionInfo!.Checkpoint;
+ Console.WriteLine($"*** Checkpoint: {checkpoint}");
if (checkpoint is not null)
{
checkpoints.Add(checkpoint);
@@ -105,17 +110,21 @@ internal static class Step5EntryPoint
if (maxStep.HasValue && stepCompletedEvt.StepNumber >= maxStep.Value - 1)
{
+ Console.WriteLine($"*** Max step {maxStep} reached, cancelling.");
cancellationSource.Cancel();
}
else
{
+ Console.WriteLine($"*** Processing {requests.Count} queued requests.");
foreach (ExternalRequest request in requests)
{
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
+ Console.WriteLine($"!!! Sending response: {response}");
await handle.SendResponseAsync(response).ConfigureAwait(false);
}
requests.Clear();
+ Console.WriteLine("*** Completed processing requests.");
}
break;
@@ -123,6 +132,7 @@ internal static class Step5EntryPoint
writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}");
break;
}
+ Console.WriteLine($"!!! Completed processing event: {evt.GetType()}");
}
if (cancellationSource.IsCancellationRequested)
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs
new file mode 100644
index 0000000000..3b47cafa39
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+
+namespace Microsoft.Agents.AI.Workflows.Sample;
+
+internal sealed record class TextProcessingRequest(string Text, string TaskId);
+internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount);
+
+internal sealed class AllTasksCompletedEvent(IEnumerable results) : WorkflowEvent(results);
+
+internal static class Step8EntryPoint
+{
+ public static List TextsToProcess => [
+ "Hello world! This is a simple test.",
+ "Python is a powerful programming language used for many applications.",
+ "Short text.",
+ "This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.",
+ "",
+ " Spaces around text ",
+ ];
+
+ public static async ValueTask> RunAsync(TextWriter writer, List textsToProcess)
+ {
+ Func processTextAsyncFunc = ProcessTextAsync;
+ ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor");
+
+ Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
+
+ ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor");
+ TextProcessingOrchestrator orchestrator = new();
+
+ Workflow workflow = new WorkflowBuilder(orchestrator)
+ .AddEdge(orchestrator, textProcessor)
+ .AddEdge(textProcessor, orchestrator)
+ .Build();
+
+ Run workflowRun = await InProcessExecution.RunAsync(workflow, textsToProcess);
+
+ RunStatus status = await workflowRun.GetStatusAsync();
+ status.Should().Be(RunStatus.Idle);
+
+ List results = orchestrator.Results;
+ results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId));
+
+ // This is a placeholder for the entry point of Step 8.
+ return results;
+ }
+
+ private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellation = default)
+ {
+ int wordCount = 0;
+ int charCount = 0;
+
+ if (request.Text.Length != 0)
+ {
+ wordCount = request.Text.Split([' '], StringSplitOptions.RemoveEmptyEntries).Length;
+ charCount = request.Text.Length;
+ }
+
+ return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount));
+ }
+
+ private sealed class TextProcessingOrchestrator() : Executor("TextOrchestrator")
+ {
+ public List Results { get; } = new();
+ public HashSet PendingTaskIds { get; } = new();
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
+ {
+ return routeBuilder.AddHandler>(this.StartProcessingAsync)
+ .AddHandler(this.CollectResultAsync);
+ }
+
+ private async ValueTask StartProcessingAsync(List texts, IWorkflowContext context)
+ {
+ foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}")))
+ {
+ this.PendingTaskIds.Add(request.TaskId);
+ await context.SendMessageAsync(request).ConfigureAwait(false);
+ }
+ }
+
+ private ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context)
+ {
+ if (this.PendingTaskIds.Remove(result.TaskId))
+ {
+ this.Results.Add(result);
+ }
+
+ if (this.PendingTaskIds.Count == 0)
+ {
+ return context.AddEventAsync(new AllTasksCompletedEvent(this.Results));
+ }
+
+ return default;
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs
new file mode 100644
index 0000000000..a238292df4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs
@@ -0,0 +1,467 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+
+namespace Microsoft.Agents.AI.Workflows.Sample;
+
+internal sealed record class UserRequest(string RequestType, string Type, int Amount, string Id, string? Priority = null, string? PolicyType = null)
+{
+ internal static int RequestCount;
+
+ public static string CreateId() => Interlocked.Increment(ref RequestCount).ToString();
+
+ public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") =>
+ new("resource", resourceType, amount, Priority: priority, Id: CreateId());
+
+ public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") =>
+ new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId());
+
+ public ResourceResponse CreateResourceResponse(int allocated, string source)
+ => new(this.Id, this.Type, allocated, source);
+
+ public PolicyResponse CreatePolicyResponse(bool approved, string reason)
+ => new(this.Id, approved, reason);
+
+ public RequestFinished CreateExpected(ResourceResponse response)
+ => new(this.Id, RequestType: "resource", ResourceResponse: response with { Id = this.Id });
+
+ public RequestFinished CreateExpectedResourceResponse(int allocated, string source)
+ => this.CreateExpected(this.CreateResourceResponse(allocated, source));
+
+ public RequestFinished CreateExpected(PolicyResponse response)
+ => new(this.Id, RequestType: "policy", PolicyResponse: response with { Id = this.Id });
+
+ public RequestFinished CreateExpectedPolicyResponse(bool approved, string reason)
+ => this.CreateExpected(this.CreatePolicyResponse(approved, reason));
+}
+
+internal sealed record class ResourceRequest(string Id, string ResourceType = "cpu", int Amount = 1, string Priority = "normal");
+internal sealed record class PolicyCheckRequest(string Id, string ResourceType, int Amount = 0, string PolicyType = "quota");
+internal sealed record class ResourceResponse(string Id, string ResourceType, int Allocated, string Source);
+internal sealed record class PolicyResponse(string Id, bool Approved, string Reason);
+internal sealed record class RequestFinished(string Id, string RequestType, ResourceResponse? ResourceResponse = null, PolicyResponse? PolicyResponse = null);
+
+internal static class Step9EntryPoint
+{
+ public static WorkflowBuilder AddPassthroughRequestHandler(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh filter, string? id = null)
+ {
+ id ??= typeof(TRequest).Name;
+
+ var requestPort = RequestPort.Create(id);
+
+ return builder.ForwardMessage(source, executors: [filter], condition: message => message.DataIs())
+ .ForwardMessage(filter, executors: [requestPort], condition: message => message.DataIs())
+ .ForwardMessage(requestPort, executors: [filter], condition: message => message.DataIs())
+ .ForwardMessage(filter, executors: [source], condition: message => message.DataIs());
+ }
+
+ public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, string? id = null)
+ => builder.AddExternalRequest(source, out RequestPort _, id);
+
+ public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, out RequestPort inputPort, string? id = null)
+ {
+ id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
+
+ inputPort = RequestPort.Create(id);
+
+ return builder.AddExternalRequest(source, inputPort);
+ }
+
+ public static WorkflowBuilder AddExternalRequest(this WorkflowBuilder builder, ExecutorIsh source, RequestPort inputPort)
+ {
+ return builder.ForwardMessage(source, inputPort)
+ .ForwardMessage(source, inputPort)
+ .ForwardMessage(inputPort, source)
+ .ForwardMessage(inputPort, source);
+ }
+
+ public static Workflow CreateSubWorkflow()
+ {
+ ResourceRequestor requestor = new();
+
+ return new WorkflowBuilder(requestor)
+ .AddExternalRequest(source: requestor)
+ .AddExternalRequest(source: requestor)
+ .WithOutputFrom(requestor)
+ .Build();
+ }
+
+ public static Workflow CreateWorkflow()
+ {
+ Coordinator coordinator = new();
+ ResourceCache cache = new();
+ QuotaPolicyEngine policyEngine = new();
+ ExecutorIsh subworkflow = CreateSubWorkflow().ConfigureSubWorkflow("ResourceWorkflow");
+
+ return new WorkflowBuilder(coordinator)
+ .AddChain(coordinator, allowRepetition: true, subworkflow, coordinator)
+ .AddPassthroughRequestHandler(subworkflow, cache)
+ .AddPassthroughRequestHandler(subworkflow, policyEngine)
+ .WithOutputFrom(coordinator)
+ .Build();
+ }
+
+ public static Workflow WorkflowInstance => CreateWorkflow();
+
+ public static UserRequest ResourceHitRequest1 = UserRequest.CreateResourceRequest(resourceType: "cpu", amount: 2, priority: "normal");
+ public static RequestFinished ResourceHitResponse1 = ResourceHitRequest1.CreateExpectedResourceResponse(allocated: 2, "cache");
+
+ public static UserRequest ResourceHitRequest2 = UserRequest.CreateResourceRequest(resourceType: "memory", amount: 15, priority: "normal");
+ public static RequestFinished ResourceHitResponse2 = ResourceHitRequest2.CreateExpectedResourceResponse(allocated: 15, "cache");
+
+ public static UserRequest PolicyHitRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 3, policyType: "quota");
+ public static RequestFinished PolicyHitResponse1 = PolicyHitRequest1.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (5)");
+
+ public static UserRequest PolicyHitRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "disk", amount: 500, policyType: "quota");
+ public static RequestFinished PolicyHitResponse2 = PolicyHitRequest2.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (1000)");
+
+ public static UserRequest ResourceMissRequest = UserRequest.CreateResourceRequest(resourceType: "gpu", amount: 2, priority: "high");
+ public static RequestFinished ResourceMissResponse = ResourceMissRequest.CreateExpectedResourceResponse(allocated: 1, "external");
+
+ public static UserRequest PolicyMissRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "memory", amount: 100, policyType: "quota");
+ public static RequestFinished PolicyMissResponse1 = PolicyMissRequest1.CreateExpectedPolicyResponse(approved: false, reason: "External Rejection");
+
+ public static UserRequest PolicyMissRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 1, policyType: "security");
+ public static RequestFinished PolicyMissResponse2 = PolicyMissRequest2.CreateExpectedPolicyResponse(approved: true, reason: "External Approval");
+
+ public static HashSet PolicyMissIds = [PolicyMissRequest1.Id, PolicyMissRequest2.Id];
+ public static HashSet ResourceMissIds = [ResourceMissRequest.Id];
+
+ public static Dictionary Part1FinishedResponses = new()
+ {
+ { ResourceHitRequest1.Id, ResourceHitResponse1 },
+ { ResourceHitRequest2.Id, ResourceHitResponse2 },
+
+ { PolicyHitRequest1.Id, PolicyHitResponse1 },
+ { PolicyHitRequest2.Id, PolicyHitResponse2 },
+ };
+
+ public static Dictionary Part2FinishedResponses = new()
+ {
+ { ResourceMissRequest.Id, ResourceMissResponse},
+
+ { PolicyMissRequest1.Id, PolicyMissResponse1 },
+ { PolicyMissRequest2.Id, PolicyMissResponse2 },
+ };
+
+ public static UserRequest[] RequestsToProcess => [
+ ResourceHitRequest1,
+ PolicyHitRequest1,
+ ResourceHitRequest1,
+ PolicyMissRequest1, // miss
+ ResourceMissRequest, // miss
+ PolicyHitRequest2,
+ PolicyMissRequest2, // miss
+ ];
+
+ public static List