.NET: Add support for Subworkflows and many threading fixes (#1066)

* feat: Add support for Workflow-as-Executor

* Fixes routing of 'object' compile-typed variables to properly take in type information
* Fixes a concurrency issue in StepTracer

* fix: Make Subworkflow ExternalRequests work properly

* fix: Threading and Concurrency fixes; prep for OffThread Mode

* refactor: Remove dead code around OffStreamRunEventStream

Currently not used, and will be replaced with a rewrite when brought back, so having it in the change is not valuable.

* ci: Work around issues with dotnet-format not properly analyzing the source

* fix: Fix the logic of AsyncCoordinator and AsyncBarrier

* Prevent individual wait cancellations from canceling the entire barrier
* Propagate information about whether the wait was completed or cancelled, and whether any waiters were present when released

* fix: Remove superfluous acces to .Keys in InProcStepTracer

* refactor: Clean up AsyncCoordinator's use of AsyncBarrier
This commit is contained in:
Jacob Alber
2025-10-03 13:26:30 -04:00
committed by GitHub
Unverified
parent 880a99ca05
commit 32e7ff00b5
76 changed files with 2151 additions and 584 deletions
@@ -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.
/// </summary>
/// <remarks>
@@ -14,13 +14,13 @@ internal static class WorkflowHelper
internal static ValueTask<Workflow<SignalWithNumber>> GetWorkflowAsync()
{
// Create the executors
InputPort numberInputPort = InputPort.Create<SignalWithNumber, int>("GuessNumber");
RequestPort numberRequest = RequestPort.Create<SignalWithNumber, int>("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<SignalWithNumber>();
}
@@ -5,10 +5,10 @@ using Microsoft.Agents.AI.Workflows;
namespace WorkflowHumanInTheLoopBasicSample;
/// <summary>
/// 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
@@ -14,13 +14,13 @@ internal static class WorkflowHelper
internal static ValueTask<Workflow<NumberSignal>> GetWorkflowAsync()
{
// Create the executors
InputPort numberInputPort = InputPort.Create<NumberSignal, int>("GuessNumber");
RequestPort numberRequestPort = RequestPort.Create<NumberSignal, int>("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<NumberSignal>();
}
@@ -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;
}
@@ -252,7 +252,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Define input action
string inputId = QuestionExecutor.Steps.Input(actionId);
InputPortAction inputPort = new(InputPort.Create<InputRequest, InputResponse>(inputId));
RequestPortAction inputPort = new(RequestPort.Create<InputRequest, InputResponse>(inputId));
this._workflowModel.AddNode(inputPort, parentId);
this._workflowModel.AddLinkFromPeer(parentId, inputId);
@@ -27,7 +27,7 @@ internal sealed class WorkflowModelBuilder : IModelBuilder<Func<object?, bool>>
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}.")
};
@@ -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<TaskCompletionSource<object>> _completionSource = new();
public async ValueTask<bool> JoinAsync(CancellationToken cancellation = default)
{
this._completionSource.Init(() => new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously));
TaskCompletionSource<object> completionSource = this._completionSource.Get()!;
// Create a new completion source to track cancellation, because cancelling a single waiter's join
// should not cancel the entire barrier.
TaskCompletionSource<object> cancellationSource = new();
using CancellationTokenRegistration registration = cancellation.Register(() => cancellationSource.SetResult(new()));
await Task.WhenAny(completionSource.Task, cancellationSource.Task).ConfigureAwait(false);
return !cancellation.IsCancellationRequested;
}
public bool ReleaseBarrier()
{
// If there is no completion source, then there are no waiters.
return this._completionSource.Get()?.TrySetResult(new()) ?? false;
}
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class AsyncCoordinator
{
private AsyncBarrier? _coordinationBarrier;
/// <summary>
/// Wait for the Coordination owner to mark the next coordination point, then continue execution.
/// </summary>
/// <param name="cancellation">A cancellation token that can be used to cancel the wait.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result is <see langword="true"/>
/// if the wait was completed; otherwise, for example, if the wait was cancelled, <see langword="false"/>.
/// </returns>
public async ValueTask<bool> WaitForCoordinationAsync(CancellationToken cancellation = default)
{
// There is a chance that we might get a stale barrier that is getting released if there is a
// release happening concurrently with this call. This is by design, and should be considered
// when using this class.
AsyncBarrier actualBarrier = this._coordinationBarrier
?? Interlocked.CompareExchange(ref this._coordinationBarrier, new(), null)
?? this._coordinationBarrier!; // Re-read after setting
return await actualBarrier.JoinAsync(cancellation).ConfigureAwait(false);
}
/// <summary>
/// Marks the coordination point and releases any waiting operations if a coordination barrier is present.
/// </summary>
/// <returns>true if a coordination barrier was released; otherwise, false.</returns>
public bool MarkCoordinationPoint()
{
AsyncBarrier? maybeBarrier = Interlocked.Exchange(ref this._coordinationBarrier, null);
return maybeBarrier?.ReleaseBarrier() ?? false;
}
}
@@ -16,9 +16,9 @@ namespace Microsoft.Agents.AI.Workflows;
/// <seealso cref="StreamingRun"/>
public class Checkpointed<TRun>
{
private readonly ICheckpointingRunner _runner;
private readonly ICheckpointingHandle _runner;
internal Checkpointed(TRun run, ICheckpointingRunner runner)
internal Checkpointed(TRun run, ICheckpointingHandle runner)
{
this.Run = Throw.IfNull(run);
this._runner = Throw.IfNull(runner);
@@ -31,7 +31,7 @@ public class Checkpointed<TRun>
/// <seealso cref="StreamingRun"/>
public TRun Run { get; }
/// <inheritdoc cref="ICheckpointingRunner.Checkpoints"/>
/// <inheritdoc cref="ICheckpointingHandle.Checkpoints"/>
public IReadOnlyList<CheckpointInfo> Checkpoints => this._runner.Checkpoints;
/// <summary>
@@ -46,7 +46,7 @@ public class Checkpointed<TRun>
}
}
/// <inheritdoc cref="ICheckpointingRunner.RestoreCheckpointAsync"/>
/// <inheritdoc cref="ICheckpointingHandle.RestoreCheckpointAsync"/>
public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellationToken = default)
=> this._runner.RestoreCheckpointAsync(checkpointInfo, cancellationToken);
}
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal interface ICheckpointingRunner
internal interface ICheckpointingHandle
{
// TODO: Convert this to a multi-timeline (e.g.: Live timeline + forks for orphaned checkpoints due to timetravel)
IReadOnlyList<CheckpointInfo> Checkpoints { get; }
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -12,6 +14,14 @@ internal sealed class InMemoryCheckpointManager : ICheckpointManager
{
private readonly Dictionary<string, RunCheckpointCache<Checkpoint>> _store = [];
public InMemoryCheckpointManager() { }
[JsonConstructor]
internal InMemoryCheckpointManager(Dictionary<string, RunCheckpointCache<Checkpoint>> store)
{
this._store = store;
}
private RunCheckpointCache<Checkpoint> GetRunStore(string runId)
{
if (!this._store.TryGetValue(runId, out RunCheckpointCache<Checkpoint>? runStore))
@@ -44,4 +54,9 @@ internal sealed class InMemoryCheckpointManager : ICheckpointManager
return new(value);
}
internal bool HasCheckpoints(string runId) => this.GetRunStore(runId).HasCheckpoints;
public bool TryGetLastCheckpoint(string runId, [NotNullWhen(true)] out CheckpointInfo? checkpoint)
=> this.GetRunStore(runId).TryGetLastCheckpointInfo(out checkpoint);
}
@@ -27,7 +27,7 @@ internal static class RepresentationExtensions
};
}
public static InputPortInfo ToPortInfo(this InputPort port)
public static RequestPortInfo ToPortInfo(this RequestPort port)
{
Throw.IfNull(port);
return new(new TypeId(port.Request), new TypeId(port.Response), port.Id);
@@ -46,7 +46,7 @@ internal static class RepresentationExtensions
keySelector: sourceId => sourceId,
elementSelector: sourceId => workflow.Edges[sourceId].Select(ToEdgeInfo).ToList());
HashSet<InputPortInfo> inputPorts = new(workflow.Ports.Values.Select(ToPortInfo));
HashSet<RequestPortInfo> inputPorts = new(workflow.Ports.Values.Select(ToPortInfo));
return new WorkflowInfo(executors, edges, inputPorts, inputType, workflow.StartExecutorId, workflow.OutputExecutors);
}
@@ -8,4 +8,4 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// <param name="RequestType"></param>
/// <param name="ResponseType"></param>
/// <param name="PortId"></param>
public record class InputPortInfo(TypeId RequestType, TypeId ResponseType, string PortId);
public record class RequestPortInfo(TypeId RequestType, TypeId ResponseType, string PortId);
@@ -2,17 +2,28 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal sealed class RunCheckpointCache<TStoreObject>
{
private readonly HashSet<CheckpointInfo> _checkpointIndex = [];
private readonly List<CheckpointInfo> _checkpointIndex = [];
private readonly Dictionary<CheckpointInfo, TStoreObject> _cache = [];
public RunCheckpointCache() { }
[JsonConstructor]
internal RunCheckpointCache(List<CheckpointInfo> checkpointIndex, Dictionary<CheckpointInfo, TStoreObject> cache)
{
this._checkpointIndex = checkpointIndex;
this._cache = cache;
}
[JsonIgnore]
public IEnumerable<CheckpointInfo> Index => this._checkpointIndex;
public bool IsInIndex(CheckpointInfo key) => this._checkpointIndex.Contains(key);
public bool IsInIndex(CheckpointInfo key) => this._cache.ContainsKey(key);
public bool TryGet(CheckpointInfo key, [MaybeNullWhen(false)] out TStoreObject value) => this._cache.TryGetValue(key, out value);
public CheckpointInfo Add(string runId, TStoreObject value)
@@ -29,12 +40,26 @@ internal sealed class RunCheckpointCache<TStoreObject>
public bool Add(CheckpointInfo key, TStoreObject value)
{
bool added = this._checkpointIndex.Add(key);
if (added)
if (this.IsInIndex(key))
{
this._cache[key] = value;
return false;
}
return added;
this._cache[key] = value;
this._checkpointIndex.Add(key);
return true;
}
[JsonIgnore]
public bool HasCheckpoints => this._checkpointIndex.Count > 0;
public bool TryGetLastCheckpointInfo([NotNullWhen(true)] out CheckpointInfo? checkpointInfo)
{
if (this.HasCheckpoints)
{
checkpointInfo = this._checkpointIndex[this._checkpointIndex.Count - 1];
return true;
}
checkpointInfo = default;
return false;
}
}
@@ -13,14 +13,14 @@ internal sealed class WorkflowInfo
internal WorkflowInfo(
Dictionary<string, ExecutorInfo> executors,
Dictionary<string, List<EdgeInfo>> edges,
HashSet<InputPortInfo> inputPorts,
HashSet<RequestPortInfo> requestPorts,
TypeId? inputType,
string startExecutorId,
HashSet<string>? outputExecutorIds)
{
this.Executors = Throw.IfNull(executors);
this.Edges = Throw.IfNull(edges);
this.InputPorts = Throw.IfNull(inputPorts);
this.RequestPorts = Throw.IfNull(requestPorts);
this.InputType = inputType;
this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId);
@@ -29,7 +29,7 @@ internal sealed class WorkflowInfo
public Dictionary<string, ExecutorInfo> Executors { get; }
public Dictionary<string, List<EdgeInfo>> Edges { get; }
public HashSet<InputPortInfo> InputPorts { get; }
public HashSet<RequestPortInfo> RequestPorts { get; }
public TypeId? InputType { get; }
public string StartExecutorId { get; }
@@ -73,9 +73,9 @@ internal sealed class WorkflowInfo
}
// Validate the input ports
if (workflow.Ports.Count != this.InputPorts.Count ||
this.InputPorts.Any(portInfo =>
!workflow.Ports.TryGetValue(portInfo.PortId, out InputPort? port) ||
if (workflow.Ports.Count != this.RequestPorts.Count ||
this.RequestPorts.Any(portInfo =>
!workflow.Ports.TryGetValue(portInfo.PortId, out RequestPort? port) ||
!portInfo.RequestType.IsMatch(port.Request) ||
!portInfo.ResponseType.IsMatch(port.Response)))
{
@@ -94,9 +94,4 @@ internal sealed class WorkflowInfo
public bool IsMatch<TInput>(Workflow<TInput> workflow) =>
this.IsMatch(workflow as Workflow) && this.InputType?.IsMatch<TInput>() == true;
//public bool IsMatch<TInput, TResult>(WorkflowWithOutput<TInput, TResult> workflow)
// => this.IsMatch(workflow as Workflow)
// && this.OutputType?.IsMatch(typeof(TResult)) is true
// && this.OutputCollectorId is not null && this.OutputCollectorId == workflow.OutputCollectorId;
}
@@ -6,7 +6,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// Represents a configuration for an object with a string identifier. For example, <see cref="IIdentified"/> object.
/// </summary>
/// <param name="id">A unique identifier for the configurable object.</param>
public class Config(string? id = null)
public class Config(string id)
{
/// <summary>
/// Gets a unique identifier for the configurable object.
@@ -14,16 +14,16 @@ public class Config(string? id = null)
/// <remarks>
/// If not provided, the configured object will generate its own identifier.
/// </remarks>
public string? Id => id;
public string Id => id;
}
/// <summary>
/// Represents a configuration for an object with a string identifier and options of type <typeparamref name="TOptions"/>.
/// </summary>
/// <typeparam name="TOptions">The type of options for the configurable object.</typeparam>
/// <param name="options">The options for the configurable object.</param>
/// <param name="id">A unique identifier for the configurable object.</param>
public class Config<TOptions>(TOptions? options = default, string? id = null) : Config(id)
/// <param name="options">The options for the configurable object.</param>
public class Config<TOptions>(string id, TOptions? options = default) : Config(id)
{
/// <summary>
/// Gets the options for the configured object.
@@ -16,7 +16,7 @@ public static class ConfigurationExtensions
/// <param name="configured">The existing configuration for the subject type to be upcast to its parent type. Cannot be null.</param>
/// <returns>A new <see cref="Configured{TParent}"/> instance that applies the original configuration logic to the parent type.</returns>
public static Configured<TParent> Super<TSubject, TParent>(this Configured<TSubject> configured) where TSubject : TParent
=> new(async config => await configured.FactoryAsync(config).ConfigureAwait(false), configured.Id, configured.Raw);
=> new(async (config, runId) => await configured.FactoryAsync(config, runId).ConfigureAwait(false), configured.Id, configured.Raw);
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
@@ -34,7 +34,7 @@ public static class Configured
throw new ArgumentException($"Provided ID '{id}' does not match subject's ID '{identified.Id}'.", nameof(id));
}
return new Configured<TSubject>(_ => new(subject), id: identified.Id, raw: raw ?? subject);
return new Configured<TSubject>((_, __) => new(subject), id: identified.Id, raw: raw ?? subject);
}
if (id is null)
@@ -42,7 +42,7 @@ public static class Configured
throw new ArgumentNullException(nameof(id), "ID must be provided when the subject does not implement IIdentified.");
}
return new Configured<TSubject>(_ => new(subject), id, raw: raw ?? subject);
return new Configured<TSubject>((_, __) => new(subject), id, raw: raw ?? subject);
}
}
@@ -53,7 +53,7 @@ public static class Configured
/// <param name="factoryAsync">A factory to intantiate the subject when desired.</param>
/// <param name="id">The unique identifier for the configured subject.</param>
/// <param name="raw"></param>
public class Configured<TSubject>(Func<Config, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
public class Configured<TSubject>(Func<Config, string, ValueTask<TSubject>> factoryAsync, string id, object? raw = null)
{
/// <summary>
/// Gets the raw representation of the configured object, if any.
@@ -68,7 +68,7 @@ public class Configured<TSubject>(Func<Config, ValueTask<TSubject>> factoryAsync
/// <summary>
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config"/>.
/// </summary>
public Func<Config, ValueTask<TSubject>> FactoryAsync => factoryAsync;
public Func<Config, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
/// <summary>
/// The configuration for this configured instance.
@@ -79,7 +79,7 @@ public class Configured<TSubject>(Func<Config, ValueTask<TSubject>> factoryAsync
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<ValueTask<TSubject>> BoundFactoryAsync => () => this.FactoryAsync(this.Configuration);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.FactoryAsync(this.Configuration, runId);
}
/// <summary>
@@ -91,7 +91,7 @@ public class Configured<TSubject>(Func<Config, ValueTask<TSubject>> factoryAsync
/// <param name="id">The unique identifier for the configured subject.</param>
/// <param name="options">Additional configuration options for the subject.</param>
/// <param name="raw"></param>
public class Configured<TSubject, TOptions>(Func<Config<TOptions>, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
public class Configured<TSubject, TOptions>(Func<Config<TOptions>, string, ValueTask<TSubject>> factoryAsync, string id, TOptions? options = default, object? raw = null)
{
/// <summary>
/// The raw representation of the configured object, if any.
@@ -111,31 +111,31 @@ public class Configured<TSubject, TOptions>(Func<Config<TOptions>, ValueTask<TSu
/// <summary>
/// Gets the factory function to create an instance of <typeparamref name="TSubject"/> given a <see cref="Config{TOptions}"/>.
/// </summary>
public Func<Config<TOptions>, ValueTask<TSubject>> FactoryAsync => factoryAsync;
public Func<Config<TOptions>, string, ValueTask<TSubject>> FactoryAsync => factoryAsync;
/// <summary>
/// The configuration for this configured instance.
/// </summary>
public Config<TOptions> Configuration => new(this.Options, this.Id);
public Config<TOptions> Configuration => new(this.Id, this.Options);
/// <summary>
/// Gets a "partially" applied factory function that only requires no parameters to create an instance of
/// <typeparamref name="TSubject"/> with the provided <see cref="Configuration"/> instance.
/// </summary>
internal Func<ValueTask<TSubject>> BoundFactoryAsync => () => this.CreateValidatingMemoizedFactory()(this.Configuration);
internal Func<string, ValueTask<TSubject>> BoundFactoryAsync => (runId) => this.CreateValidatingMemoizedFactory()(this.Configuration, runId);
private Func<Config, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
private Func<Config, string, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
async ValueTask<TSubject> FactoryAsync(Config configuration)
async ValueTask<TSubject> FactoryAsync(Config configuration, string runId)
{
if (this.Id != configuration.Id)
{
throw new InvalidOperationException($"Requested instance ID '{configuration.Id}' does not match configured ID '{this.Id}'.");
}
TSubject subject = await this.FactoryAsync(this.Configuration).ConfigureAwait(false);
TSubject subject = await this.FactoryAsync(this.Configuration, runId).ConfigureAwait(false);
if (this.Id is not null && subject is IIdentified identified && identified.Id != this.Id)
{
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -32,6 +33,7 @@ public enum EdgeKind
/// additional information relevant to the edge, and its concrete type depends on the value of <see
/// cref="Kind"/>, functioning as a tagged union.
/// </remarks>
[DebuggerDisplay("[{Data.Id}]: {Kind}Edge({Data.Connection})")]
public sealed class Edge
{
/// <summary>
@@ -56,4 +56,7 @@ public readonly struct EdgeId : IEquatable<EdgeId>
/// <inheritdoc />
public static bool operator !=(EdgeId left, EdgeId right) => !left.Equals(right);
/// <inheritdoc />
public override string ToString() => this.EdgeIndex.ToString();
}
@@ -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<bool> WaitForNextInputAsync(CancellationToken cancellation = default)
=> this._waitForResponseCoordinator.WaitForCoordinationAsync(cancellation);
public void ReleaseResponseWaiter() => this._waitForResponseCoordinator.MarkCoordinationPoint();
public string RunId => this._stepRunner.RunId;
public IReadOnlyList<CheckpointInfo> Checkpoints => this._checkpointingHandle.Checkpoints;
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellation = default)
=> this._eventStream.GetStatusAsync(cancellation);
public async IAsyncEnumerable<WorkflowEvent> 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<bool> IsValidInputTypeAsync<T>(CancellationToken cancellation = default)
=> this._stepRunner.IsValidInputTypeAsync<T>(cancellation);
public async ValueTask<bool> EnqueueMessageAsync<T>(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<bool> 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);
}
@@ -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<Checkpointed<TRunType>> WithCheckpointingAsync<TRunType>(this AsyncRunHandle runHandle, Func<ValueTask<TRunType>> prepareFunc)
{
TRunType run = await prepareFunc().ConfigureAwait(false);
return new Checkpointed<TRunType>(run, runHandle);
}
public static async ValueTask<StreamingRun> EnqueueAndStreamAsync<TInput>(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellation = default)
{
await runHandle.EnqueueMessageAsync(input, cancellation).ConfigureAwait(false);
return new(runHandle);
}
public static async ValueTask<StreamingRun> EnqueueUntypedAndStreamAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellation = default)
{
await runHandle.EnqueueMessageUntypedAsync(input, cancellation: cancellation).ConfigureAwait(false);
return new(runHandle);
}
public static async ValueTask<Run> EnqueueAndRunAsync<TInput>(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<Run> 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;
}
}
@@ -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<object?, WorkflowEvent, ValueTask>? EventRaised;
}
@@ -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<MessageEnvelope> messageQueue = nextStep.MessagesFor(target.Id);
foreach (MessageEnvelope envelope in this._envelopes)
{
messageQueue.Enqueue(envelope);
}
}
}
}
@@ -114,4 +114,10 @@ public sealed class EdgeConnection : IEquatable<EdgeConnection>
/// The unique identifiers of the sinks connected by this edge.
/// </summary>
public List<string> SinkIds { get; }
/// <inheritdoc />
public override string ToString()
{
return $"[{string.Join(",", this.SourceIds)}] => [{string.Join(",", this.SinkIds)}]";
}
}
@@ -12,9 +12,9 @@ internal sealed class EdgeMap
{
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
private readonly Dictionary<string, InputEdgeRunner> _portEdgeRunners;
private readonly Dictionary<string, ResponseEdgeRunner> _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<string, HashSet<Edge>> workflowEdges,
IEnumerable<InputPort> workflowPorts,
IEnumerable<RequestPort> 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<DeliveryMapping?> 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.");
}
@@ -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<bool> WaitForNextInputAsync(CancellationToken cancellation = default);
}
@@ -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<RunStatus> GetStatusAsync(CancellationToken cancellation = default);
IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(CancellationToken cancellation = default);
}
@@ -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);
@@ -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<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation = default);
ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation = default);
}
@@ -15,12 +15,16 @@ internal interface ISuperStepRunner
bool HasUnservicedRequests { get; }
bool HasUnprocessedMessages { get; }
ValueTask EnqueueResponseAsync(ExternalResponse response);
ValueTask<bool> EnqueueMessageAsync<T>(T message);
ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation = default);
event EventHandler<WorkflowEvent>? WorkflowEvent;
ValueTask<bool> IsValidInputTypeAsync<T>(CancellationToken cancellation = default);
ValueTask<bool> EnqueueMessageAsync<T>(T message, CancellationToken cancellation = default);
ValueTask<bool> EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default);
ConcurrentEventSink OutgoingEvents { get; }
ValueTask<bool> RunSuperStepAsync(CancellationToken cancellationToken);
// This cannot be cancelled
ValueTask RequestEndRunAsync();
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal class InitLocked<T>() where T : class
{
private int _writers;
private T? _value;
public T? Get()
{
return this._value;
}
public bool Init(Func<T> 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;
}
}
@@ -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<RunStatus> 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<WorkflowEvent> TakeEventStreamAsync([EnumeratorCancellation] CancellationToken cancellation = default)
{
ConcurrentQueue<WorkflowEvent> 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;
}
@@ -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<string>(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<DeliveryMapping?> 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);
@@ -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<string, List<MessageEnvelope>> QueuedMessages { get; } = [];
public ConcurrentDictionary<string, ConcurrentQueue<MessageEnvelope>> 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<MessageEnvelope> MessagesFor(string target)
public ConcurrentQueue<MessageEnvelope> MessagesFor(string target)
{
if (!this.QueuedMessages.TryGetValue(target, out var messages))
{
this.QueuedMessages[target] = messages = [];
}
return messages;
return this.QueuedMessages.GetOrAdd(target, _ => new ConcurrentQueue<MessageEnvelope>());
}
// 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();
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
internal enum ExecutionMode
{
OffThread,
Lockstep
}
@@ -24,8 +24,6 @@ public abstract class Executor : IIdentified
/// </summary>
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;
}
/// <summary>
/// Gets the configuration options for the executor.
/// </summary>
protected ExecutorOptions Options { get; }
/// <summary>
/// Override this method to register handlers for the executor.
/// </summary>
@@ -57,7 +60,7 @@ public abstract class Executor : IIdentified
/// <returns></returns>
protected virtual ISet<Type> 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);
}
@@ -29,7 +29,7 @@ public static class ExecutorIshConfigurationExtensions
/// <param name="id">An id for the executor to be instantiated.</param>
/// <param name="options">An optional parameter specifying the options.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
public static ExecutorIsh ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
@@ -48,6 +48,27 @@ public static class ExecutorIshConfigurationExtensions
typeof(FunctionExecutor<TInput, TOutput>),
ExecutorIsh.Type.Function);
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An ExecutorIsh instance representing the configured sub-workflow executor.</returns>
public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
{
object ownershipToken = new();
workflow.TakeOwnership(ownershipToken, subworkflow: true);
Configured<WorkflowHostExecutor, ExecutorOptions> configured = new(InitHostExecutorAsync, id, options, raw: workflow);
return new ExecutorIsh(configured.Super<WorkflowHostExecutor, Executor, ExecutorOptions>(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
ValueTask<WorkflowHostExecutor> InitHostExecutorAsync(Config<ExecutorOptions> config, string runId)
{
return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
}
}
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
@@ -114,13 +135,17 @@ public sealed class ExecutorIsh :
/// </summary>
Function,
/// <summary>
/// An <see cref="InputPort"/> for servicing external requests.
/// An <see cref="RequestPort"/> for servicing external requests.
/// </summary>
InputPort,
RequestPort,
/// <summary>
/// An <see cref="AIAgent"/> instance.
/// </summary>
Agent,
/// <summary>
/// A nested <see cref="Workflow"/> instance.
/// </summary>
Workflow,
}
/// <summary>
@@ -133,7 +158,7 @@ public sealed class ExecutorIsh :
private readonly Configured<Executor>? _configuredExecutor;
private readonly System.Type? _configuredExecutorType;
internal readonly InputPort? _inputPortValue;
internal readonly RequestPort? _requestPortValue;
private readonly AIAgent? _aiAgentValue;
/// <summary>
@@ -168,10 +193,10 @@ public sealed class ExecutorIsh :
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
/// </summary>
/// <param name="port">The input port to associate to be wrapped.</param>
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);
}
/// <summary>
@@ -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 <see cref="Func{Executor}"/> that can be used to obtain an <see cref="Executor"/> instance
/// corresponding to this <see cref="ExecutorIsh"/>.
/// </summary>
private Func<ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
private Func<string, ValueTask<Executor>> 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);
/// <summary>
/// Defines an implicit conversion from an <see cref="InputPort"/> to an <see cref="ExecutorIsh"/> instance.
/// Defines an implicit conversion from an <see cref="RequestPort"/> to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="inputPort">The <see cref="InputPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(InputPort inputPort) => new(inputPort);
/// <param name="inputPort">The <see cref="RequestPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
/// <summary>
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorIsh"/> instance.
@@ -294,9 +323,10 @@ public sealed class ExecutorIsh :
{
Type.Unbound => $"'{this.Id}':<unbound>",
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}':<unknown[{this.ExecutorType}]>"
};
}
@@ -4,7 +4,7 @@ using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryF = System.Func<System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
using ExecutorFactoryF = System.Func<string, System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
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<Executor> CreateInstanceAsync() => this.CheckId(await this.ProviderAsync().ConfigureAwait(false));
public async ValueTask<Executor> CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
}
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="PortInfo">The port to invoke.</param>
/// <param name="RequestId">A unique identifier for this request instance.</param>
/// <param name="Data">The data contained in the request.</param>
public record ExternalRequest(InputPortInfo PortInfo, string RequestId, PortableValue Data)
public record ExternalRequest(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
@@ -29,6 +29,13 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>() => this.Data.Is<TValue>();
/// <summary>
/// Determines whether the underlying data is of the specified type and outputs the value if it is.
/// </summary>
/// <typeparam name="TValue">The type to compare with the underlying data.</typeparam>
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
/// <summary>
/// Creates a new <see cref="ExternalRequest"/> for the specified input port and data payload.
/// </summary>
@@ -37,7 +44,7 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable
/// <param name="requestId">An optional unique identifier for this request instance. If <c>null</c>, a UUID will be generated.</param>
/// <returns>An <see cref="ExternalRequest"/> instance containing the specified port, data, and request identifier.</returns>
/// <exception cref="InvalidOperationException">Thrown when the input data object does not match the expected request type.</exception>
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
/// <param name="data">The data payload to include in the request. Must not be <c>null</c>.</param>
/// <param name="requestId">An optional identifier for the request. If <c>null</c>, a default identifier may be assigned.</param>
/// <returns>An <see cref="ExternalRequest"/> instance containing the specified port, data, and request identifier.</returns>
public static ExternalRequest Create<T>(InputPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId);
public static ExternalRequest Create<T>(RequestPort port, T data, string? requestId = null) => Create(port, (object)Throw.IfNull(data), requestId);
/// <summary>
/// Creates a new <see cref="ExternalResponse"/> corresponding to the request, with the speicified data payload.
@@ -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;
/// <param name="PortInfo">The port invoked.</param>
/// <param name="RequestId">The unique identifier of the corresponding request.</param>
/// <param name="Data">The data contained in the response.</param>
public record ExternalResponse(InputPortInfo PortInfo, string RequestId, PortableValue Data)
public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, PortableValue Data)
{
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
@@ -27,6 +28,15 @@ public record ExternalResponse(InputPortInfo PortInfo, string RequestId, Portabl
/// <returns>true if the underlying data is of type TValue; otherwise, false.</returns>
public bool DataIs<TValue>() => this.Data.Is<TValue>();
/// <summary>
/// Determines whether the underlying data can be retrieved as the specified type.
/// </summary>
/// <typeparam name="TValue">The type to which the underlying data is to be cast if available.</typeparam>
/// <param name="value">When this method returns, contains the value of type <typeparamref name="TValue"/> if the data is
/// available and compatible.</param>
/// <returns>true if the data is present and can be cast to <typeparamref name="TValue"/>; otherwise, false.</returns>
public bool DataIs<TValue>([NotNullWhen(true)] out TValue? value) => this.Data.Is(out value);
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
/// </summary>
@@ -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<string> Instantiated { get; } = [];
public HashSet<string> Activated { get; } = [];
public ConcurrentDictionary<string, string> Instantiated { get; } = new();
public ConcurrentDictionary<string, string> 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();
@@ -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;
/// <remarks><para> <see cref="InProcessRunner"/> 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. </para></remarks>
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<Type>? 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<Type> _knownValidInputTypes;
public async ValueTask<bool> IsValidInputTypeAsync(Type messageType)
public async ValueTask<bool> 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<bool> EnqueueMessageInternalAsync(object message, Type messageType)
public ValueTask<bool> IsValidInputTypeAsync<T>(CancellationToken cancellation = default)
=> this.IsValidInputTypeAsync(typeof(T), cancellation);
public async ValueTask<bool> 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<bool> EnqueueMessageAsync<T>(T message)
=> this.EnqueueMessageInternalAsync(Throw.IfNull(message), typeof(T));
public ValueTask<bool> EnqueueMessageAsync<T>(T message, CancellationToken cancellation = default)
=> this.EnqueueMessageUntypedAsync(Throw.IfNull(message), typeof(T), cancellation);
public ValueTask<bool> EnqueueMessageAsync(object message)
=> this.EnqueueMessageInternalAsync(Throw.IfNull(message), message.GetType());
public ValueTask<bool> 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<WorkflowEvent>? ISuperStepRunner.WorkflowEvent
{
add => this.WorkflowEvent += value;
remove => this.WorkflowEvent -= value;
}
public ConcurrentEventSink OutgoingEvents { get; } = new();
private event EventHandler<WorkflowEvent>? WorkflowEvent;
private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent)
=> this.OutgoingEvents.EnqueueAsync(workflowEvent);
private void RaiseWorkflowEvent(WorkflowEvent workflowEvent)
{
this.WorkflowEvent?.Invoke(this, workflowEvent);
}
public async ValueTask<StreamingRun> ResumeStreamAsync(CheckpointInfo checkpoint, CancellationToken cancellationToken = default)
public ValueTask<AsyncRunHandle> BeginStreamAsync(ExecutionMode mode, CancellationToken cancellation = default)
{
this.RunContext.CheckEnded();
Throw.IfNull(checkpoint);
return new(new AsyncRunHandle(this, this, mode));
}
public async ValueTask<AsyncRunHandle> 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<StreamingRun> StreamAsync(object input, CancellationToken cancellationToken = default)
{
this.RunContext.CheckEnded();
await this.EnqueueMessageAsync(input).ConfigureAwait(false);
return new StreamingRun(this);
}
public async ValueTask<StreamingRun> StreamAsync<TInput>(TInput input, CancellationToken cancellationToken = default)
{
this.RunContext.CheckEnded();
await this.EnqueueMessageAsync(input).ConfigureAwait(false);
return new StreamingRun(this);
}
internal async ValueTask<Run> 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<Run> 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<Run> RunAsync<TInput>(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<MessageEnvelope> envelopes)
private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue<MessageEnvelope> 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<Task> 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<Task> 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();
}
@@ -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<string, Task<Executor>> _executors = new();
private readonly ConcurrentQueue<Func<ValueTask>> _queuedExternalDeliveries = new();
private readonly ConcurrentQueue<ISuperStepRunner> _joinedSubworkflowRunners = new();
private readonly Dictionary<string, ExternalRequest> _externalRequests = [];
private readonly ConcurrentDictionary<string, ExternalRequest> _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<Executor> 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<IEnumerable<Type>> 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<StepContext> 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<WorkflowEvent> QueuedEvents = [];
private IEventSink OutgoingEvents { get; }
internal StateManager StateManager { get; } = new();
@@ -239,7 +272,9 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public IReadOnlyDictionary<string, string>? 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<Executor> 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<string, List<PortableMessageEnvelope>> 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<Executor>[] 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<ISuperStepRunner> 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<TMessage>(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation)
=> this.SendMessageAsync(senderId, Throw.IfNull(message));
}
@@ -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.
/// </summary>
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<TInput>(Workflow<TInput> checkedWorkflow, CheckpointManager? checkpointManager, string? runId)
where TInput : notnull
=> new(checkedWorkflow, checkpointManager, runId, [typeof(TInput)]);
private static ValueTask<StreamingRun> StreamAsync<TInput>(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
=> runner.StreamAsync(input, cancellationToken);
private static ValueTask<StreamingRun> StreamAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
=> runner.StreamAsync(input, cancellationToken);
private static ValueTask<Run> RunAsync<TInput>(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
=> runner.RunAsync(input, cancellationToken);
private static ValueTask<Run> RunAsync(InProcessRunner runner, object input, CancellationToken cancellationToken = default)
=> runner.RunAsync(input, cancellationToken);
private static async ValueTask<Checkpointed<StreamingRun>> StreamCheckpointedAsync<TInput>(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<Checkpointed<StreamingRun>> 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<AsyncRunHandle> BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable<Type> knownValidInputTypes, CancellationToken cancellationToken)
{
InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes);
return runner.BeginStreamAsync(this._executionMode, cancellationToken);
}
private static async ValueTask<Checkpointed<Run>> RunCheckpointedAsync<TInput>(InProcessRunner runner, TInput input, CancellationToken cancellationToken = default)
where TInput : notnull
internal ValueTask<AsyncRunHandle> ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, CheckpointInfo fromCheckpoint, IEnumerable<Type> 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<Checkpointed<Run>> 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<Checkpointed<StreamingRun>> 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<Checkpointed<Run>> 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);
}
/// <summary>
@@ -87,17 +48,19 @@ public static class InProcessExecution
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<StreamingRun> StreamAsync<TInput>(
public static async ValueTask<StreamingRun> StreamAsync<TInput>(
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);
}
/// <summary>
@@ -110,17 +73,19 @@ public static class InProcessExecution
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<StreamingRun> StreamAsync<TInput>(
public static async ValueTask<StreamingRun> StreamAsync<TInput>(
Workflow<TInput> 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);
}
/// <summary>
@@ -134,18 +99,21 @@ public static class InProcessExecution
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(
public static async ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(
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);
}
/// <summary>
@@ -159,18 +127,21 @@ public static class InProcessExecution
/// <param name="input">The input message to be processed as part of the streaming run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{StreamingRun}"/> that represents the asynchronous operation. The result contains a <see
/// cref="StreamingRun"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(
public static async ValueTask<Checkpointed<StreamingRun>> StreamAsync<TInput>(
Workflow<TInput> 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);
}
/// <summary>
@@ -182,17 +153,20 @@ public static class InProcessExecution
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="StreamingRun"/> that provides access to the results of the streaming run.</returns>
public static ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync(
public static async ValueTask<Checkpointed<StreamingRun>> 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<StreamingRun>(() => new(new StreamingRun(runHandle)))
.ConfigureAwait(false);
}
/// <summary>
@@ -205,17 +179,20 @@ public static class InProcessExecution
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="StreamingRun"/> that provides access to the results of the streaming run.</returns>
public static ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync<TInput>(
public static async ValueTask<Checkpointed<StreamingRun>> ResumeStreamAsync<TInput>(
Workflow<TInput> 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<StreamingRun>(() => new(new StreamingRun(runHandle)))
.ConfigureAwait(false);
}
/// <summary>
@@ -227,17 +204,19 @@ public static class InProcessExecution
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Run> RunAsync<TInput>(
public static async ValueTask<Run> RunAsync<TInput>(
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);
}
/// <summary>
@@ -249,17 +228,19 @@ public static class InProcessExecution
/// <param name="workflow">The workflow to be executed. Must not be <c>null</c>.</param>
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Run> RunAsync<TInput>(
public static async ValueTask<Run> RunAsync<TInput>(
Workflow<TInput> 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);
}
/// <summary>
@@ -272,18 +253,21 @@ public static class InProcessExecution
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<Run>> RunAsync<TInput>(
public static async ValueTask<Checkpointed<Run>> RunAsync<TInput>(
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);
}
/// <summary>
@@ -296,18 +280,21 @@ public static class InProcessExecution
/// <param name="input">The input message to be processed as part of the run.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<Run>> RunAsync<TInput>(
public static async ValueTask<Checkpointed<Run>> RunAsync<TInput>(
Workflow<TInput> 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);
}
/// <summary>
@@ -319,18 +306,21 @@ public static class InProcessExecution
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<Run>> ResumeAsync(
public static async ValueTask<Checkpointed<Run>> 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<Run>(() => new(new Run(runHandle)))
.ConfigureAwait(false);
}
/// <summary>
@@ -342,17 +332,20 @@ public static class InProcessExecution
/// <param name="fromCheckpoint">The <see cref="CheckpointInfo"/> corresponding to the checkpoint from which to resume.</param>
/// <param name="checkpointManager">The <see cref="CheckpointManager"/> to use with this run.</param>
/// <param name="runId">An optional unique identifier for the run. If not provided, a new identifier will be generated.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellationToken requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ValueTask{Run}"/> that represents the asynchronous operation. The result contains a <see
/// cref="Run"/> for managing and interacting with the streaming run.</returns>
public static ValueTask<Checkpointed<Run>> ResumeAsync<TInput>(
public static async ValueTask<Checkpointed<Run>> ResumeAsync<TInput>(
Workflow<TInput> 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<Run>(() => new(new Run(runHandle)))
.ConfigureAwait(false);
}
}
@@ -1,24 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// An external request port for a <see cref="Workflow"/> with the specified request and response types.
/// </summary>
/// <param name="Id"></param>
/// <param name="Request"></param>
/// <param name="Response"></param>
public sealed record InputPort(string Id, Type Request, Type Response)
{
/// <summary>
/// Creates a new <see cref="InputPort"/> instance configured for the specified request and response types.
/// </summary>
/// <typeparam name="TRequest">The type of the request messages that the input port will accept.</typeparam>
/// <typeparam name="TResponse">The type of the response messages that the input port will produce.</typeparam>
/// <param name="id">The unique identifier for the input port.</param>
/// <returns>An <see cref="InputPort"/> instance associated with the specified <paramref name="id"/>, configured to handle
/// requests of type <typeparamref name="TRequest"/> and responses of type <typeparamref name="TResponse"/>.</returns>
public static InputPort Create<TRequest, TResponse>(string id) => new(id, typeof(TRequest), typeof(TResponse));
};
@@ -150,7 +150,7 @@ public sealed class PortableValue
/// <param name="value">When this method returns, contains the value cast or deserialized to type TValue
/// if the conversion succeeded, or null if the conversion failed.</param>
/// <returns>true if the current instance can be assigned to targetType; otherwise, false.</returns>
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)
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// An external request port for a <see cref="Workflow"/> with the specified request and response types.
/// </summary>
/// <param name="Id"></param>
/// <param name="Request"></param>
/// <param name="Response"></param>
public record RequestPort(string Id, Type Request, Type Response)
{
/// <summary>
/// Creates a new <see cref="RequestPort"/> instance configured for the specified request and response types.
/// </summary>
/// <typeparam name="TRequest">The type of the request messages that the input port will accept.</typeparam>
/// <typeparam name="TResponse">The type of the response messages that the input port will produce.</typeparam>
/// <param name="id">The unique identifier for the input port.</param>
/// <returns>An <see cref="RequestPort"/> instance associated with the specified <paramref name="id"/>, configured to handle
/// requests of type <typeparamref name="TRequest"/> and responses of type <typeparamref name="TResponse"/>.</returns>
public static RequestPort<TRequest, TResponse> Create<TRequest, TResponse>(string id) => new(id, typeof(TRequest), typeof(TResponse));
};
/// <summary>
/// An external request port for a <see cref="Workflow"/> with the specified request and response types.
/// </summary>
/// <param name="Id"></param>
/// <param name="Request"></param>
/// <param name="Response"></param>
/// <param name="AllowWrapped"></param>
public sealed record RequestPort<TRequest, TResponse>(string Id, Type Request, Type Response, bool AllowWrapped = false) : RequestPort(Id, Request, Response);
@@ -73,7 +73,7 @@ public class RouteBuilder
return this;
}
internal RouteBuilder AddHandler(Type type, Func<object, IWorkflowContext, ValueTask> handler, bool overwrite = false)
internal RouteBuilder AddHandlerUntyped(Type type, Func<object, IWorkflowContext, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -86,7 +86,7 @@ public class RouteBuilder
}
}
internal RouteBuilder AddHandler<TResult>(Type type, Func<object, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
internal RouteBuilder AddHandlerUntyped<TResult>(Type type, Func<object, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
+36 -32
View File
@@ -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;
/// </summary>
public enum RunStatus
{
/// <summary>
/// The run has not yet started. This only occurs when running in "lockstep" mode.
/// </summary>
NotStarted,
/// <summary>
/// The run has halted, has no outstanding requets, but has not received a <see cref="RequestHaltEvent"/>.
/// </summary>
@@ -22,11 +28,12 @@ public enum RunStatus
/// </summary>
PendingRequests,
// TODO: Figure out if we want to have some way to have a true "converged" state
///// <summary>
///// The run has halted after converging.
///// </summary>
//Completed,
/// <summary>
/// The user has ended the run. No further events will be emitted, and no messages can be sent to it.
/// </summary>
/// <seealso cref="StreamingRun.EndRunAsync"/>
/// <seealso cref="Run.EndRunAsync"/>
Ended,
/// <summary>
/// The workflow is currently running, and may receive events or requests.
@@ -40,49 +47,35 @@ public enum RunStatus
/// </summary>
public sealed class Run
{
internal static async ValueTask<Run> CaptureStreamAsync(StreamingRun run, CancellationToken cancellationToken = default)
{
Run result = new(run);
await result.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false);
return result;
}
private readonly List<WorkflowEvent> _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<bool> 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;
}
/// <summary>
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
/// </summary>
public string RunId => this._streamingRun.RunId;
public string RunId => this._runHandle.RunId;
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
public RunStatus Status { get; private set; }
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellation = default)
=> this._runHandle.GetStatusAsync(cancellation);
/// <summary>
/// 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
/// <summary>
/// Resume execution of the workflow with the provided external responses.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <param name="messages">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 <see cref="ExternalResponse"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns><c>true</c> if the workflow had any output events, <c>false</c> otherwise.</returns>
public async ValueTask<bool> ResumeAsync<T>(IEnumerable<T> messages, CancellationToken cancellationToken = default)
public async ValueTask<bool> ResumeAsync<T>(CancellationToken cancellationToken = default, params IEnumerable<T> messages)
where T : notnull
{
if (messages is IEnumerable<ExternalResponse> 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);
}
/// <inheritdoc cref="StreamingRun.EndRunAsync"/>
public ValueTask EndRunAsync() => this._streamingRun.EndRunAsync();
public ValueTask EndRunAsync() => this._runHandle.RequestEndRunAsync();
}
@@ -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<string, ExternalRequest> _wrappedRequests = [];
private InputPort Port { get; }
private readonly Dictionary<string, ExternalRequest> _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<ExternalResponse, ExternalResponse>(this.HandleAsync);
.AddHandler<ExternalResponse, ExternalResponse?>(this.HandleAsync);
}
internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink);
public async ValueTask<ExternalRequest> HandleCatchAllAsync(PortableValue message, IWorkflowContext context)
public async ValueTask<ExternalRequest?> 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<ExternalRequest> 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<ExternalResponse> HandleAsync(ExternalResponse message, IWorkflowContext context)
public async ValueTask<ExternalResponse?> 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}.");
@@ -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<Type> 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<InProcessRunner> 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<StreamingRun> 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<InMemoryCheckpointManager>(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);
}
}
}
@@ -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;
/// </summary>
public sealed class StreamingRun
{
private TaskCompletionSource<object>? _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);
/// <summary>
/// Gets a value indicating whether there are any outstanding <see cref="ExternalRequest"/>s for which a
/// <see cref="ExternalResponse"/> has not been sent.
/// </summary>
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<bool> WaitOnInputAsync(CancellationToken cancellation = default)
=> this._runHandle.WaitForNextInputAsync(cancellation);
/// <summary>
/// A unique identifier for the run. Can be provided at the start of the run, or auto-generated.
/// </summary>
public string RunId => this._stepRunner.RunId;
public string RunId => this._runHandle.RunId;
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellation = default)
=> this._runHandle.GetStatusAsync(cancellation);
/// <summary>
/// Asynchronously sends the specified response to the external system and signals completion of the current
@@ -49,11 +46,7 @@ public sealed class StreamingRun
/// <param name="response">The <see cref="ExternalResponse"/> to send. Must not be <c>null</c>.</param>
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous send operation.</returns>
public ValueTask SendResponseAsync(ExternalResponse response)
{
this._waitForResponseSource?.TrySetResult(new());
return this._stepRunner.EnqueueResponseAsync(response);
}
=> this._runHandle.EnqueueResponseAsync(response);
/// <summary>
/// Attempts to send the specified message asynchronously and returns a value indicating whether the operation was
@@ -65,18 +58,11 @@ public sealed class StreamingRun
/// <returns>A <see cref="ValueTask{Boolean}"/> that represents the asynchronous send operation. It's
/// <see cref="ValueTask{Boolean}.Result"/> is <see langword="true"/> if the message was sent
/// successfully; otherwise, <see langword="false"/>.</returns>
public async ValueTask<bool> TrySendMessageAsync<TMessage>(TMessage message)
{
Throw.IfNull(message);
public ValueTask<bool> TrySendMessageAsync<TMessage>(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<bool> TrySendMessageUntypedAsync(object message, Type? declaredType = null)
=> this._runHandle.EnqueueMessageUntypedAsync(message, declaredType);
/// <summary>
/// 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<WorkflowEvent> 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);
}
/// <summary>
@@ -192,7 +119,10 @@ public sealed class StreamingRun
/// </summary>
/// <returns>A ValueTask that represents the asynchronous operation. The task is complete when the run has
/// ended and cleanup is finished.</returns>
public ValueTask EndRunAsync() => this._stepRunner.RequestEndRunAsync();
public async ValueTask EndRunAsync()
{
await this._runHandle.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Event triggered when a workflow encounters an error.
/// </summary>
/// <param name="subworkflowId">The ID of the subworkflow that encountered the error.</param>
/// <param name="e">Optionally, the <see cref="Exception"/> representing the error.</param>
public sealed class SubworkflowErrorEvent(string subworkflowId, Exception? e) : WorkflowErrorEvent(e)
{
/// <summary>
/// Gets the ID of the subworkflow that encountered the error.
/// </summary>
public string SubworkflowId { get; } = subworkflowId;
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Event triggered when a subworkflow encounters a warning-confition.
/// sub-workflow.
/// </summary>
/// <param name="message">The warning message.</param>
/// <param name="subWorkflowId">The unique identifier of the sub-workflow that triggered the warning. Cannot be null or empty.</param>
public sealed class SubworkflowWarningEvent(string message, string subWorkflowId) : WorkflowWarningEvent(message)
{
/// <summary>
/// The unique identifier of the sub-workflow that triggered the warning.
/// </summary>
public string SubWorkflowId { get; } = subWorkflowId;
}
@@ -8,17 +8,17 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Debug information about the SuperStep that finished running.
/// </summary>
public sealed class SuperStepCompletionInfo(HashSet<string> activatedExecutors, HashSet<string>? instantiatedExecutors = null)
public sealed class SuperStepCompletionInfo(IEnumerable<string> activatedExecutors, IEnumerable<string>? instantiatedExecutors = null)
{
/// <summary>
/// The unique identifiers of <see cref="Executor"/> instances that processed messages during this SuperStep
/// </summary>
public HashSet<string> ActivatedExecutors { get; } = Throw.IfNull(activatedExecutors);
public HashSet<string> ActivatedExecutors { get; } = [.. Throw.IfNull(activatedExecutors)];
/// <summary>
/// The unique identifiers of <see cref="Executor"/> instances newly created during this SuperStep
/// </summary>
public HashSet<string> InstantiatedExecutors { get; } = instantiatedExecutors ?? [];
public HashSet<string> InstantiatedExecutors { get; } = [.. instantiatedExecutors ?? []];
/// <summary>
/// A flag indicating whether the managed state was written to during this SuperStep. If the run was started
@@ -35,7 +35,7 @@ public class Workflow
);
}
internal Dictionary<string, InputPort> Ports { get; init; } = [];
internal Dictionary<string, RequestPort> Ports { get; init; } = [];
/// <summary>
/// Gets the collection of external request ports, keyed by their ID.
@@ -43,7 +43,7 @@ public class Workflow
/// <remarks>
/// Each port has a corresponding entry in the <see cref="Registrations"/> dictionary.
/// </remarks>
public Dictionary<string, InputPortInfo> ReflectPorts()
public Dictionary<string, RequestPortInfo> 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",
@@ -32,7 +32,7 @@ public class WorkflowBuilder
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, InputPort> _inputPorts = [];
private readonly Dictionary<string, RequestPort> _inputPorts = [];
private readonly HashSet<string> _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
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target)
=> this.AddEdge<object>(source, target, null);
public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? 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
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? condition = null)
public WorkflowBuilder AddEdge<T>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? 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.");
@@ -25,10 +25,24 @@ public static class WorkflowBuilderExtensions
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
/// <summary>
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
/// one or more target executors.
/// </summary>
/// <typeparam name="TMessage">The type of message to forward.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> to which the edges will be added.</param>
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="condition">An optional condition that messages must satisfy to be forwarded. If <see langword="null"/>,
/// all messages of type <typeparamref name="TMessage"/> will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorIsh> executors)
{
Throw.IfNull(executors);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(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<T> 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));
}
/// <summary>
@@ -84,10 +98,11 @@ public static class WorkflowBuilderExtensions
/// executor in the order provided.</remarks>
/// <param name="builder">The workflow builder to which the executor chain will be added. </param>
/// <param name="source">The initial executor in the chain. Cannot be null.</param>
/// <param name="allowRepetition">If set to <see langword="true"/>, the same executor can be added to the chain multiple times.</param>
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable<ExecutorIsh> 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);
}
@@ -10,4 +10,4 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="e">
/// Optionally, the <see cref="Exception"/> representing the error.
/// </param>
public sealed class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
@@ -15,6 +15,6 @@ internal class WorkflowJsonDefinitionData
{
public string StartExecutorId { get; set; } = string.Empty;
public IEnumerable<Edge> Edges { get; set; } = [];
public IEnumerable<InputPort> Ports { get; set; } = [];
public IEnumerable<RequestPort> Ports { get; set; } = [];
public IEnumerable<string> OutputExecutors { get; set; } = [];
}
@@ -6,4 +6,4 @@ namespace Microsoft.Agents.AI.Workflows;
/// Event triggered when a workflow encounters a warning-condition.
/// </summary>
/// <param name="message">The warning message.</param>
public sealed class WorkflowWarningEvent(string message) : WorkflowEvent(message);
public class WorkflowWarningEvent(string message) : WorkflowEvent(message);
@@ -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))]
@@ -102,7 +102,8 @@ public class InProcessStateTests
Run run = await InProcessExecution.RunAsync<TurnToken>(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<Run> checkpointed = await InProcessExecution.RunAsync<TurnToken>(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();
@@ -89,11 +89,11 @@ public class JsonSerializationTests
}
}
private static InputPort TestPort => InputPort.Create<string, int>("StringToInt");
private static InputPortInfo TestPortInfo => TestPort.ToPortInfo();
private static RequestPort TestPort => RequestPort.Create<string, int>("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<int, string>(IntToStringId).ToPortInfo();
private static InputPortInfo StringToInt => InputPort.Create<string, int>(StringToIntId).ToPortInfo();
private static RequestPortInfo IntToString => RequestPort.Create<int, string>(IntToStringId).ToPortInfo();
private static RequestPortInfo StringToInt => RequestPort.Create<string, int>(StringToIntId).ToPortInfo();
private static ValueTask<Workflow<string>> CreateTestWorkflowAsync()
{
ForwardMessageExecutor<string> forwardString = new(ForwardStringId);
ForwardMessageExecutor<int> forwardInt = new(ForwardIntId);
InputPort stringToInt = InputPort.Create<string, int>(StringToIntId);
InputPort intToString = InputPort.Create<int, string>(IntToStringId);
RequestPort stringToInt = RequestPort.Create<string, int>(StringToIntId);
RequestPort intToString = RequestPort.Create<int, string>(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<InputPortInfo> expected, HashSet<InputPortInfo> actual)
void ValidateRequestPorts(HashSet<RequestPortInfo> expected, HashSet<RequestPortInfo> actual)
=> actual.Should().HaveCount(expected.Count).And.IntersectWith(expected);
}
@@ -35,15 +35,15 @@ public class RepresentationTests
throw new NotImplementedException();
}
private static InputPort TestInputPort =>
InputPort.Create<FunctionCallContent, FunctionResultContent>("ExternalFunction");
private static RequestPort TestRequestPort =>
RequestPort.Create<FunctionCallContent, FunctionResultContent>("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<int, IWorkflowContext, CancellationToken, ValueTask> 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}";
@@ -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<ReverseTextExec
{
string result = string.Concat(message.Reverse());
await context.AddEventAsync(new RequestHaltEvent(result)).ConfigureAwait(false);
await context.YieldOutputAsync(result).ConfigureAwait(false);
return result;
}
}
@@ -13,7 +13,7 @@ internal static class Step1aEntryPoint
{
Run run = await InProcessExecution.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
Assert.Equal(RunStatus.Idle, run.Status);
Assert.Equal(RunStatus.Idle, await run.GetStatusAsync());
foreach (WorkflowEvent evt in run.NewEvents)
{
@@ -13,7 +13,7 @@ internal static class Step4EntryPoint
public static Workflow CreateWorkflowInstance(out JudgeExecutor judge)
{
InputPort guessNumber = InputPort.Create<NumberSignal, int>("GuessNumber");
RequestPort guessNumber = RequestPort.Create<NumberSignal, int>("GuessNumber");
judge = new(JudgeId, 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
@@ -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<ExternalRequest> 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)
@@ -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<TextProcessingResult> results) : WorkflowEvent(results);
internal static class Step8EntryPoint
{
public static List<string> 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<List<TextProcessingResult>> RunAsync(TextWriter writer, List<string> textsToProcess)
{
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> 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<TextProcessingResult> 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<TextProcessingResult> Results { get; } = new();
public HashSet<string> PendingTaskIds { get; } = new();
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<string>>(this.StartProcessingAsync)
.AddHandler<TextProcessingResult>(this.CollectResultAsync);
}
private async ValueTask StartProcessingAsync(List<string> 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;
}
}
}
@@ -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<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh filter, string? id = null)
{
id ??= typeof(TRequest).Name;
var requestPort = RequestPort.Create<TRequest, TResponse>(id);
return builder.ForwardMessage<ExternalRequest>(source, executors: [filter], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalRequest>(filter, executors: [requestPort], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalResponse>(requestPort, executors: [filter], condition: message => message.DataIs<TResponse>())
.ForwardMessage<ExternalResponse>(filter, executors: [source], condition: message => message.DataIs<TResponse>());
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string? id = null)
=> builder.AddExternalRequest(source, out RequestPort<TRequest, TResponse> _, id);
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
{
id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
inputPort = RequestPort.Create<TRequest, TResponse>(id);
return builder.AddExternalRequest(source, inputPort);
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, RequestPort<TRequest, TResponse> inputPort)
{
return builder.ForwardMessage<TRequest>(source, inputPort)
.ForwardMessage<ExternalRequest>(source, inputPort)
.ForwardMessage<TResponse>(inputPort, source)
.ForwardMessage<ExternalResponse>(inputPort, source);
}
public static Workflow CreateSubWorkflow()
{
ResourceRequestor requestor = new();
return new WorkflowBuilder(requestor)
.AddExternalRequest<ResourceRequest, ResourceResponse>(source: requestor)
.AddExternalRequest<PolicyCheckRequest, PolicyResponse>(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<ResourceRequest, ResourceResponse>(subworkflow, cache)
.AddPassthroughRequestHandler<PolicyCheckRequest, PolicyResponse>(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<string> PolicyMissIds = [PolicyMissRequest1.Id, PolicyMissRequest2.Id];
public static HashSet<string> ResourceMissIds = [ResourceMissRequest.Id];
public static Dictionary<string, RequestFinished> Part1FinishedResponses = new()
{
{ ResourceHitRequest1.Id, ResourceHitResponse1 },
{ ResourceHitRequest2.Id, ResourceHitResponse2 },
{ PolicyHitRequest1.Id, PolicyHitResponse1 },
{ PolicyHitRequest2.Id, PolicyHitResponse2 },
};
public static Dictionary<string, RequestFinished> 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<RequestFinished> ExpectedResponsesPart1 =>
[.. RequestsToProcess.Where(request => Part1FinishedResponses.ContainsKey(request.Id))
.Select(request => Part1FinishedResponses[request.Id])
.OrderBy(request => request.Id)];
public static RequestFinished[] ExpectedResponsesPart2 =>
[.. RequestsToProcess.Where(request => Part2FinishedResponses.ContainsKey(request.Id))
.Select(request => Part2FinishedResponses[request.Id])
.OrderBy(request => request.Id)];
public static async ValueTask<List<RequestFinished>> RunAsync(TextWriter writer)
{
RunStatus runStatus;
List<RequestFinished> results = [];
Run workflowRun = await InProcessExecution.RunAsync(WorkflowInstance, RequestsToProcess.ToList());
RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle;
runStatus = await workflowRun.GetStatusAsync();
runStatus.Should().Be(part1Status);
List<RequestFinished> finishedRequests = [];
List<ExternalRequest> resourceRequests = [];
List<ExternalRequest> policyRequests = [];
foreach (WorkflowEvent evt in workflowRun.NewEvents)
{
if (evt is WorkflowOutputEvent outputEvent && outputEvent.Data is RequestFinished finishedRequest)
{
finishedRequests.Add(finishedRequest);
}
else if (evt is RequestInfoEvent requestInfoEvent)
{
if (requestInfoEvent.Request.DataIs<ResourceRequest>())
{
resourceRequests.Add(requestInfoEvent.Request);
}
else if (requestInfoEvent.Request.DataIs<PolicyCheckRequest>())
{
policyRequests.Add(requestInfoEvent.Request);
}
}
}
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
finishedRequests.Should().HaveCount(ExpectedResponsesPart1.Count)
.And.ContainInOrder(ExpectedResponsesPart1);
int externalResourceRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.ResourceResponse != null);
int externalPolicyRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.PolicyResponse != null);
resourceRequests.Should().HaveCount(externalResourceRequests);
policyRequests.Should().HaveCount(externalPolicyRequests);
List<ExternalResponse> responses = [];
foreach (ExternalRequest request in resourceRequests)
{
ResourceRequest resourceRequest = request.DataAs<ResourceRequest>()!;
resourceRequest.Id.Should().BeOneOf(ResourceMissIds);
responses.Add(request.CreateResponse(Part2FinishedResponses[resourceRequest.Id].ResourceResponse!));
}
foreach (ExternalRequest request in policyRequests)
{
PolicyCheckRequest policyRequest = request.DataAs<PolicyCheckRequest>()!;
policyRequest.Id.Should().BeOneOf(PolicyMissIds);
responses.Add(request.CreateResponse(Part2FinishedResponses[policyRequest.Id].PolicyResponse!));
}
if (ExpectedResponsesPart2.Length == 0)
{
responses.Should().BeEmpty();
return results;
}
await workflowRun.ResumeAsync(responses: responses).ConfigureAwait(false);
runStatus = await workflowRun.GetStatusAsync();
runStatus.Should().Be(RunStatus.Idle);
results = finishedRequests;
finishedRequests = workflowRun.NewEvents.OfType<WorkflowOutputEvent>()
.Select(outputEvent => outputEvent.Data)
.Where(value => value is not null)
.OfType<RequestFinished>()
.ToList();
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
finishedRequests.Should().HaveCount(ExpectedResponsesPart2.Length)
.And.ContainInOrder(ExpectedResponsesPart2);
results.AddRange(finishedRequests);
return results;
}
}
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor))
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.RequestResourcesAsync)
.AddHandler<UserRequest>(InvokeResourceRequestAsync)
.AddHandler<ResourceResponse>(this.HandleResponseAsync)
.AddHandler<PolicyResponse>(this.HandleResponseAsync);
// For some reason, using a lambda here causes the analyzer to generate a spurious
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
// to a variable, or passing it to another method"
ValueTask InvokeResourceRequestAsync(UserRequest request, IWorkflowContext context)
=> this.RequestResourcesAsync([request], context);
}
private async ValueTask RequestResourcesAsync(List<UserRequest> requests, IWorkflowContext context)
{
foreach (UserRequest request in requests)
{
switch (request.RequestType)
{
case "resource":
await context.SendMessageAsync(new ResourceRequest(Id: request.Id, ResourceType: request.Type, Amount: request.Amount, Priority: request.Priority ?? "normal"))
.ConfigureAwait(false);
break;
case "policy":
await context.SendMessageAsync(new PolicyCheckRequest(Id: request.Id, PolicyType: request.PolicyType ?? "quota", ResourceType: request.Type, Amount: request.Amount))
.ConfigureAwait(false);
break;
}
}
}
private async ValueTask HandleResponseAsync(ResourceResponse response, IWorkflowContext context)
{
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "resource", ResourceResponse: response));
}
private async ValueTask HandleResponseAsync(PolicyResponse response, IWorkflowContext context)
{
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response));
}
}
internal sealed class ResourceCache() : Executor(nameof(ResourceCache))
{
private readonly Dictionary<string, int> _availableResources = new()
{
["cpu"] = 10,
["memory"] = 50,
["disk"] = 100,
};
internal List<ResourceResponse> Responses { get; } = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
// Note the disbalance here - we could also handle ExternalResponse here instead, but we would have
// to do the exact same type check on it, so we might as well handle
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectResultAsync);
}
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context)
{
if (request.DataIs(out ResourceRequest? resourceRequest))
{
ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context)
.ConfigureAwait(false);
if (response != null)
{
await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false);
}
else
{
// Cache does not have enough resources, forward the request to the external system
await context.SendMessageAsync(request).ConfigureAwait(false);
}
}
}
private async ValueTask<ResourceResponse?> TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context)
{
if (this._availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount)
{
// Cache has enough resources, allocate from cache
this._availableResources[request.ResourceType] -= request.Amount;
ResourceResponse resourceResponse = new(request.Id, request.ResourceType, request.Amount, Source: "cache");
this.Responses.Add(resourceResponse);
return resourceResponse;
}
return null;
}
private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs(out ResourceResponse? resourceResponse))
{
// Normally we'd update the cache according to whatever logic we want here.
this.Responses.Add(resourceResponse);
return context.SendMessageAsync(response);
}
return default;
}
}
internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine))
{
private readonly Dictionary<string, int> _quotas = new()
{
["cpu"] = 5,
["memory"] = 20,
["disk"] = 1000,
};
internal List<PolicyResponse> Responses { get; } = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectAndForwardAsync);
}
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context)
{
if (request.DataIs(out PolicyCheckRequest? policyRquest))
{
PolicyResponse? response = await this.TryHandlePolicyCheckRequestAsync(policyRquest, context)
.ConfigureAwait(false);
if (response != null)
{
await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false);
}
else
{
// QuotaPolicyEngine cannot approve the request, forward to external system
await context.SendMessageAsync(request).ConfigureAwait(false);
}
}
}
private async ValueTask<PolicyResponse?> TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context)
{
if (request.PolicyType == "quota" &&
this._quotas.TryGetValue(request.ResourceType, out int quota) &&
request.Amount <= quota)
{
PolicyResponse policyResponse = new(request.Id, Approved: true, Reason: $"Within quota ({quota})");
this.Responses.Add(policyResponse);
return policyResponse;
}
return null;
}
private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs(out PolicyResponse? policyResponse))
{
this.Responses.Add(policyResponse);
return context.SendMessageAsync(response);
}
return default;
}
}
internal sealed class Coordinator() : Executor(nameof(Coordinator))
{
private int _inflightRequests;
internal List<RequestFinished> Results { get; } = [];
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.StartAsync)
.AddHandler<UserRequest>(InvokeStartAsync)
.AddHandler<RequestFinished>(this.HandleFinishedRequestAsync);
// For some reason, using a lambda here causes the analyzer to generate a spurious
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
// to a variable, or passing it to another method"
ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context)
=> this.StartAsync([request], context);
}
private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context)
{
this.Results.Add(finished);
Interlocked.Decrement(ref this._inflightRequests);
return context.YieldOutputAsync(finished);
}
private async ValueTask StartAsync(List<UserRequest> request, IWorkflowContext context)
{
Interlocked.Add(ref this._inflightRequests, request.Count);
foreach (UserRequest req in request)
{
await context.SendMessageAsync(req).ConfigureAwait(false);
}
}
}
@@ -1,10 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Sample;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
@@ -177,6 +179,49 @@ public class SampleSmokeTest
line => Assert.Contains($"{EchoAgent.DefaultId}: {EchoAgent.Prefix}{HelloAgent.Greeting}", line)
);
}
[Fact]
public async Task Test_RunSample_Step8Async()
{
List<string> 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 ",
];
using StringWriter writer = new();
List<TextProcessingResult> results = await Step8EntryPoint.RunAsync(writer, textsToProcess);
Assert.Equal(textsToProcess.Count, results.Count);
Assert.Collection(results,
textsToProcess.Select(CreateValidator).ToArray());
Action<TextProcessingResult> CreateValidator(string textToProcess, int index)
{
return result =>
{
TextProcessingResult expected = new(
TaskId: $"Task{index}",
Text: textToProcess,
WordCount: textToProcess.Split([' '], StringSplitOptions.RemoveEmptyEntries).Length,
ChatCount: textToProcess.Length
);
result.Should().Be(expected);
};
}
}
[Fact]
public async Task Test_RunSample_Step9Async()
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(writer);
}
}
internal sealed class VerifyingPlaybackResponder<TInput, TResponse>
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
@@ -74,8 +75,29 @@ public class TestRunContext : IRunnerContext
ValueTask<StepContext> IRunnerContext.AdvanceAsync() =>
throw new NotImplementedException();
public Dictionary<string, Executor> Executors { get; } = [];
public Dictionary<string, Executor> Executors { get; set; } = [];
public string StartingExecutorId { get; set; } = string.Empty;
public bool WithCheckpointing => throw new NotSupportedException();
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer) =>
new(this.Executors[executorId]);
public ValueTask<IEnumerable<Type>> GetStartingExecutorInputTypesAsync(CancellationToken cancellation = default)
{
if (this.Executors.TryGetValue(this.StartingExecutorId, out Executor? executor))
{
return new(executor.InputTypes);
}
throw new InvalidOperationException($"No executor with ID '{this.StartingExecutorId}' is registered in this context.");
}
public ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation = default)
=> this.AddEventAsync(workflowEvent);
public ValueTask SendMessageAsync<TMessage>(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellation = default)
=> this.SendMessageAsync(senderId, message, cancellation);
ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation) => default;
}
@@ -35,7 +35,7 @@ internal static partial class ValidationExtensions
actual.ExecutorType.Equals(prototype.ExecutorType);
}
public static Expression<Func<InputPortInfo, bool>> CreatePortInfoValidator(this InputPort prototype)
public static Expression<Func<RequestPortInfo, bool>> CreatePortInfoValidator(this RequestPort prototype)
{
return actual => actual.PortId == prototype.Id &&
// Rely on the TypeId test to probe TypeId serialization - just validate that we got a functional TypeId