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