Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs
Jacob Alber 76fe7319e0 .NET: feat: Refactor Handoff Orchestration and add HITL support (#5174)
* feat: Refactor Handoff Orchestration and add HITL support

* Change HandoffAgentExecutor to use factory-based instantiation
* Extract shared request collection logic in AIAgentUnservicedRequestsCollector
* Refactor HandoffAgentExecutor to use the "ContinueTurn" pattern as in AIAgentHostExecutor

* fix: Remove '$' from exception strings
2026-04-13 14:59:17 +00:00

226 lines
11 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a base class for executors that maintain and manage state across multiple message handling operations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
public abstract class StatefulExecutor<TState> : Executor
{
private readonly Func<TState> _initialStateFactory;
private TState? _stateCache;
/// <summary>
/// Initializes the executor with a unique id and an initial value for the state.
/// </summary>
/// <param name="id">The unique identifier for this executor instance. Cannot be null or empty.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
/// <param name="declareCrossRunShareable">true to declare that the executor's state can be shared across multiple runs; otherwise, false.</param>
protected StatefulExecutor(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
bool declareCrossRunShareable = false)
: base(id, options ?? new StatefulExecutorOptions(), declareCrossRunShareable)
{
this.Options = (StatefulExecutorOptions)base.Options;
this._initialStateFactory = Throw.IfNull(initialStateFactory);
}
/// <inheritdoc/>
protected new StatefulExecutorOptions Options { get; }
private string DefaultStateKey => $"{this.GetType().Name}.State";
/// <summary>
/// Gets the key used to identify the executor's state.
/// </summary>
protected string StateKey => this.Options.StateKey ?? this.DefaultStateKey;
/// <summary>
/// Reads the state associated with this executor. If it is not initialized, it will be set to the initial state.
/// </summary>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="skipCache">Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable
/// mode.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns></returns>
protected async ValueTask<TState> ReadStateAsync(IWorkflowContext context, bool skipCache = false, CancellationToken cancellationToken = default)
{
if (!skipCache && this._stateCache is not null)
{
return this._stateCache;
}
TState? state = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken)
.ConfigureAwait(false);
if (!context.ConcurrentRunsEnabled)
{
this._stateCache = state;
}
return state;
}
/// <summary>
/// Queues up an update to the executor's state.
/// </summary>
/// <param name="state">The new value of state.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns></returns>
protected ValueTask QueueStateUpdateAsync(TState state, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!context.ConcurrentRunsEnabled)
{
this._stateCache = state;
}
return context.QueueStateUpdateAsync(this.StateKey, state, this.Options.ScopeName, cancellationToken);
}
/// <summary>
/// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified
/// key.
/// </summary>
/// <param name="invocation">A delegate that receives the current state, workflow context, and cancellation token,
/// and returns the updated state asynchronously.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="skipCache">Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable
/// mode.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A ValueTask that represents the asynchronous operation.</returns>
protected async ValueTask InvokeWithStateAsync(
Func<TState, IWorkflowContext, CancellationToken, ValueTask<TState?>> invocation,
IWorkflowContext context,
bool skipCache = false,
CancellationToken cancellationToken = default)
{
if (!skipCache && !context.ConcurrentRunsEnabled)
{
if (this._stateCache is null)
{
this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken)
.ConfigureAwait(false);
}
TState newState = await invocation(this._stateCache ?? this._initialStateFactory(),
context,
cancellationToken).ConfigureAwait(false)
?? this._initialStateFactory();
await context.QueueStateUpdateAsync(this.StateKey,
newState,
this.Options.ScopeName,
cancellationToken).ConfigureAwait(false);
this._stateCache = newState;
}
else
{
await context.InvokeWithStateAsync(invocation,
this.StateKey,
this._initialStateFactory,
this.Options.ScopeName,
cancellationToken)
.ConfigureAwait(false);
}
}
/// <inheritdoc cref="IResettableExecutor.ResetAsync"/>
protected virtual ValueTask ResetAsync()
{
this._stateCache = this._initialStateFactory();
return default;
}
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages,
/// and maintain state across invocations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages,
/// and maintain state across invocations.
/// </summary>
/// <typeparam name="TState">The type of state associated with this Executor.</typeparam>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput, TOutput>
where TOutput : notnull
{
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
}