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:
+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))]
|
||||
|
||||
Reference in New Issue
Block a user