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