.NET: Improve DevEx for simple Executors (#626)

* feat: Improve DevEx for simple Executors

* Add abstract types for executors that will only handle one type of message
* Add FunctionExecutor and configuration capability on delegates
* Add support for late-instantiated Executors

* refactor: Remove open-typed extension method

* refactor: Switch to TaskFactory pattern for async--from-sync

* docs: Update XML docs for publics and fix formatting

* refactor: Better naming for ExecutorIsh configuration methods

* docs: Fix typo in ExecutorIshConfigurationExtensions.ConfigureFactory
This commit is contained in:
Jacob Alber
2025-09-08 15:11:53 -04:00
committed by GitHub
Unverified
parent 518fd447fd
commit 0cae1e0adf
13 changed files with 498 additions and 42 deletions
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows;
/// <summary>
/// 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)
{
/// <summary>
/// A unique identifier for the configurable object.
/// </summary>
/// <remarks>
/// If not provided, the configured object will generate its own identifier.
/// </remarks>
public string? Id => id;
}
/// <summary>
/// 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)
{
/// <summary>
/// Options for the configured object.
/// </summary>
public TOptions? Options => options;
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Workflows;
/// <summary>
/// Extensions methods for creating Configured objects
/// </summary>
public static class ConfigurationExtensions
{
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
/// the parent type level.
/// </summary>
/// <typeparam name="TSubject">The type of the original subject being configured. Must inherit from or implement TParent.</typeparam>
/// <typeparam name="TParent">The base type or interface to which the configuration will be upcast.</typeparam>
/// <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);
/// <summary>
/// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at
/// the parent type level.
/// </summary>
/// <typeparam name="TSubject">The type of the original subject being configured. Must inherit from or implement TParent.</typeparam>
/// <typeparam name="TParent">The base type or interface to which the configuration will be upcast.</typeparam>
/// <typeparam name="TSubjectOptions">The type of configuration options for the original subject being configured.</typeparam>
/// <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, TSubjectOptions>(this Configured<TSubject, TSubjectOptions> configured) where TSubject : TParent
=> configured.Memoize().Super<TSubject, TParent>();
}
@@ -0,0 +1,153 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows;
/// <summary>
/// Helper methods for creating <see cref="Configured{TSubject}"/> instances.
/// </summary>
public static class Configured
{
/// <summary>
/// Creates a <see cref="Configured{TSubject}"/> instance from an existing subject instance.
/// </summary>
/// <param name="subject">
/// The subject instance. If the subject implements <see cref="IIdentified"/>, its ID will be used
/// and checked against the provided ID (if any).
/// </param>
/// <param name="id">
/// A unique identifier for the configured subject. This is required if the subject does not implement
/// <see cref="IIdentified"/>
/// </param>
/// <param name="raw">
/// The raw representation of the subject instance.
/// </param>
/// <returns></returns>
public static Configured<TSubject> FromInstance<TSubject>(TSubject subject, string? id = null, object? raw = null)
{
if (subject is IIdentified identified)
{
if (id != null && identified.Id != id)
{
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);
}
if (id == null)
{
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);
}
}
/// <summary>
/// A representation of a preconfigured, lazy-instantiatable instance of <typeparamref name="TSubject"/>.
/// </summary>
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
/// <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)
{
/// <summary>
/// The raw representation of the configured object, if any.
/// </summary>
public object? Raw => raw;
/// <summary>
/// Gets the configured identifier for the subject.
/// </summary>
public string Id => id;
/// <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;
/// <summary>
/// The configuration for this configured instance.
/// </summary>
public Config Configuration => new(this.Id);
/// <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.FactoryAsync(this.Configuration);
}
/// <summary>
/// A representation of a preconfigured, lazy-instantiatable instance of <typeparamref name="TSubject"/>.
/// </summary>
/// <typeparam name="TSubject">The type of the preconfigured subject.</typeparam>
/// <typeparam name="TOptions">The type of configuration options for the preconfigured subject.</typeparam>
/// <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="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)
{
/// <summary>
/// The raw representation of the configured object, if any.
/// </summary>
public object? Raw => raw;
/// <summary>
/// Gets the configured identifier for the subject.
/// </summary>
public string Id => id;
/// <summary>
/// Gets the options associated with this instance.
/// </summary>
public TOptions? Options => options;
/// <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;
/// <summary>
/// The configuration for this configured instance.
/// </summary>
public Config<TOptions> Configuration => new(this.Options, this.Id);
/// <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);
private Func<Config, ValueTask<TSubject>> CreateValidatingMemoizedFactory()
{
return FactoryAsync;
async ValueTask<TSubject> FactoryAsync(Config configuration)
{
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);
if (this.Id != null && subject is IIdentified identified && identified.Id != this.Id)
{
throw new InvalidOperationException($"Created instance ID '{identified.Id}' does not match configured ID '{this.Id}'.");
}
return subject;
}
}
/// <summary>
/// Memoizes and erases the typed configuration options for the subject.
/// </summary>
public Configured<TSubject> Memoize() => new(this.CreateValidatingMemoizedFactory(), this.Id);
}
@@ -7,6 +7,7 @@ using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Execution;
using Microsoft.Agents.Workflows.Reflection;
namespace Microsoft.Agents.Workflows;
@@ -21,20 +22,17 @@ public abstract class Executor : IIdentified
/// </summary>
public string Id { get; }
private readonly ExecutorOptions _options;
/// <summary>
/// Initialize the executor with a unique identifier
/// </summary>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged
/// UUID will be generated.</param>
protected Executor(string? id = null) : this(ExecutorOptions.Default, id)
{
}
private readonly ExecutorOptions _options;
internal Executor(ExecutorOptions options, string? id = null)
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
protected Executor(string? id = null, ExecutorOptions? options = null)
{
this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}";
this._options = options;
this._options = options ?? ExecutorOptions.Default;
}
/// <summary>
@@ -144,3 +142,43 @@ public abstract class Executor : IIdentified
/// <returns></returns>
public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType);
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public abstract class Executor<TInput>(string? id = null, ExecutorOptions? options = null)
: Executor(id, options), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<TInput>(this.HandleAsync);
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context);
}
/// <summary>
/// Provides a simple executor implementation that uses a single message handler function to process incoming messages.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public abstract class Executor<TInput, TOutput>(string? id = null, ExecutorOptions? options = null)
: Executor(id, options ?? ExecutorOptions.Default),
IMessageHandler<TInput, TOutput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context);
}
@@ -1,12 +1,86 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows.Specialized;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows;
/// <summary>
/// Extension methods for configuring executors and functions as <see cref="ExecutorIsh"/> instances.
/// </summary>
public static class ExecutorIshConfigurationExtensions
{
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id and options.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, if this is used as a start node of a typed <see cref="Workflow{TInput}"/> via <see cref="WorkflowBuilder.Build{T}"/>,
/// it will be instantiated as part of the workflow's construction, to validate that its input type matches the
/// demanded <c>TInput</c>.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <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)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
return new ExecutorIsh(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor), ExecutorIsh.Type.Executor);
}
private static ExecutorIsh ToExecutorIsh<TInput>(this FunctionExecutor<TInput> executor, Delegate raw)
{
return new ExecutorIsh(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput>, Executor>(),
typeof(FunctionExecutor<TInput>),
ExecutorIsh.Type.Function);
}
private static ExecutorIsh ToExecutorIsh<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw)
{
return new ExecutorIsh(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
typeof(FunctionExecutor<TInput, TOutput>),
ExecutorIsh.Type.Function);
}
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null)
=> new FunctionExecutor<TInput>(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null)
=> new FunctionExecutor<TInput, TOutput>(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync);
}
/// <summary>
/// A tagged union representing an object that can function like an <see cref="Executor"/> in a <see cref="Workflow"/>,
/// or a reference to one by ID.
@@ -31,6 +105,10 @@ public sealed class ExecutorIsh :
/// </summary>
Executor,
/// <summary>
/// A function delegate to be wrapped as an executor.
/// </summary>
Function,
/// <summary>
/// An <see cref="InputPort"/> for servicing external requests.
/// </summary>
InputPort,
@@ -46,7 +124,10 @@ public sealed class ExecutorIsh :
public Type ExecutorType { get; init; }
private readonly string? _idValue;
private readonly Executor? _executorValue;
private readonly Configured<Executor>? _configuredExecutor;
private readonly System.Type? _configuredExecutorType;
internal readonly InputPort? _inputPortValue;
private readonly AIAgent? _aiAgentValue;
@@ -60,6 +141,13 @@ public sealed class ExecutorIsh :
this._idValue = Throw.IfNull(id);
}
internal ExecutorIsh(Configured<Executor> configured, System.Type configuredExecutorType, ExecutorIsh.Type type)
{
this.ExecutorType = type;
this._configuredExecutor = configured;
this._configuredExecutorType = configuredExecutorType;
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified executor.
/// </summary>
@@ -67,7 +155,8 @@ public sealed class ExecutorIsh :
public ExecutorIsh(Executor executor)
{
this.ExecutorType = Type.Executor;
this._executorValue = Throw.IfNull(executor);
this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor));
this._configuredExecutorType = executor.GetType();
}
/// <summary>
@@ -96,18 +185,20 @@ public sealed class ExecutorIsh :
public string Id => this.ExecutorType switch
{
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
Type.Executor => this._executorValue!.Id,
Type.Executor => this._configuredExecutor!.Id,
Type.InputPort => this._inputPortValue!.Id,
Type.Agent => this._aiAgentValue!.Id,
Type.Function => this._configuredExecutor!.Id,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
internal object? RawData => this.ExecutorType switch
{
Type.Unbound => this._idValue,
Type.Executor => this._executorValue,
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
Type.InputPort => this._inputPortValue,
Type.Agent => this._aiAgentValue,
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -122,9 +213,10 @@ public sealed class ExecutorIsh :
private System.Type RuntimeType => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
Type.Executor => this._executorValue!.GetType(),
Type.Executor => this._configuredExecutorType!,
Type.InputPort => typeof(RequestInfoExecutor),
Type.Agent => typeof(AIAgentHostExecutor),
Type.Function => this._configuredExecutorType!,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -132,12 +224,13 @@ 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<Executor> ExecutorProvider => this.ExecutorType switch
private Func<ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
Type.Executor => () => this._executorValue!,
Type.InputPort => () => new RequestInfoExecutor(this._inputPortValue!),
Type.Agent => () => new AIAgentHostExecutor(this._aiAgentValue!),
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
Type.InputPort => () => new(new RequestInfoExecutor(this._inputPortValue!)),
Type.Agent => () => new(new AIAgentHostExecutor(this._aiAgentValue!)),
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
@@ -225,9 +318,10 @@ public sealed class ExecutorIsh :
return this.ExecutorType switch
{
Type.Unbound => $"'{this.Id}':<unbound>",
Type.Executor => $"'{this.Id}':{this._executorValue!.GetType().Name}",
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
Type.InputPort => $"'{this.Id}':Input({this._inputPortValue!.Request.Name}->{this._inputPortValue!.Response.Name})",
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
_ => $"'{this.Id}':<unknown[{this.ExecutorType}]>"
};
}
@@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows;
/// <summary>
/// Configuration options for Executor behavior.
/// </summary>
public sealed class ExecutorOptions
public class ExecutorOptions
{
/// <summary>
/// The default runner configuration.
@@ -1,9 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryF = System.Func<Microsoft.Agents.Workflows.Executor>;
using ExecutorFactoryF = System.Func<System.Threading.Tasks.ValueTask<Microsoft.Agents.Workflows.Executor>>;
namespace Microsoft.Agents.Workflows;
@@ -11,7 +12,7 @@ internal class ExecutorRegistration(string id, Type executorType, ExecutorFactor
{
public string Id { get; } = Throw.IfNullOrEmpty(id);
public Type ExecutorType { get; } = Throw.IfNull(executorType);
public ExecutorFactoryF Provider { get; } = Throw.IfNull(provider);
public ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
internal object? RawExecutorishData { get; } = rawData;
@@ -28,5 +29,5 @@ internal class ExecutorRegistration(string id, Type executorType, ExecutorFactor
return executor;
}
public Executor CreateInstance() => this.CheckId(this.Provider());
public async ValueTask<Executor> CreateInstanceAsync() => this.CheckId(await this.ProviderAsync().ConfigureAwait(false));
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.Workflows;
/// <summary>
/// Executes a user-provided asynchronous function in response to workflow messages of the specified input type.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public class FunctionExecutor<TInput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerAsync,
string? id = null,
ExecutorOptions? options = null) : Executor<TInput>(id, options)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync)
{
return RunActionAsync;
ValueTask RunActionAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellation)
{
handlerSync(input, workflowContext, cancellation);
return default;
}
}
/// <inheritdoc/>
public override ValueTask HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default);
/// <summary>
/// Creates a new instance of the <see cref="FunctionExecutor{TInput}"/> class.
/// </summary>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(Action<TInput, IWorkflowContext, CancellationToken> handlerSync) : this(WrapAction(handlerSync))
{ }
}
/// <summary>
/// Executes a user-provided asynchronous function in response to workflow messages of the specified input type,
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, a type-tagged UUID will be generated.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
public class FunctionExecutor<TInput, TOutput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerAsync,
string? id = null,
ExecutorOptions? options = null) : Executor<TInput, TOutput>(id, options)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
{
return RunFuncAsync;
ValueTask<TOutput> RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellation)
{
TOutput result = handlerSync(input, workflowContext, cancellation);
return new ValueTask<TOutput>(result);
}
}
/// <inheritdoc/>
public override ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default);
/// <summary>
/// Creates a new instance of the <see cref="FunctionExecutor{TInput,TOutput}"/> class.
/// </summary>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync) : this(WrapFunc(handlerSync))
{ }
}
@@ -35,7 +35,7 @@ internal class InProcessRunnerContext<TExternalInput> : IRunnerContext
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
this._executors[executorId] = executor = registration.Provider();
this._executors[executorId] = executor = await registration.ProviderAsync().ConfigureAwait(false);
tracer?.TraceActivated(executorId);
if (executor is RequestInfoExecutor requestInputExecutor)
@@ -16,8 +16,8 @@ public class ReflectingExecutor<
] TExecutor
> : Executor where TExecutor : ReflectingExecutor<TExecutor>
{
/// <inheritdoc cref="Executor.Executor(string?)"/>
protected ReflectingExecutor(string? id = null) : base(id)
/// <inheritdoc cref="Executor.Executor(string?, ExecutorOptions?)"/>
protected ReflectingExecutor(string? id = null, ExecutorOptions? options = null) : base(id, options)
{ }
/// <inheritdoc />
@@ -20,7 +20,7 @@ internal class RequestInfoExecutor : Executor
};
private readonly bool _allowWrapped;
public RequestInfoExecutor(InputPort port, bool allowWrapped = true) : base(RequestInfoExecutor.DefaultOptions, port.Id)
public RequestInfoExecutor(InputPort port, bool allowWrapped = true) : base(port.Id, RequestInfoExecutor.DefaultOptions)
{
this.Port = port;
@@ -2,7 +2,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.Workflows;
@@ -205,6 +208,32 @@ public class WorkflowBuilder
return this;
}
[SuppressMessage("Reliability", "CA2008:Do not create tasks without passing a TaskScheduler",
Justification = "We explicitly set the TaskScheduler when we create the TaskFactory")]
[SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits",
Justification = "This runs the thread on the thread pool")]
private static TResult RunSync<TResult>(Func<ValueTask<TResult>> funcAsync)
{
TaskFactory factory = new(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default);
// See ASP.Net.Identity's implementation of AsyncHelper
// https://github.com/aspnet/AspNetIdentity/blob/main/src/Microsoft.AspNet.Identity.Core/AsyncHelper.cs
// Capture the current culture and UI culture
var culture = System.Globalization.CultureInfo.CurrentCulture;
var uiCulture = System.Globalization.CultureInfo.CurrentUICulture;
return factory.StartNew(PropagateCultureAndInvoke).Unwrap().GetAwaiter().GetResult();
Task<TResult> PropagateCultureAndInvoke()
{
// Set the culture and UI culture to the captured values
System.Globalization.CultureInfo.CurrentCulture = culture;
System.Globalization.CultureInfo.CurrentUICulture = uiCulture;
return funcAsync().AsTask();
}
}
/// <summary>
/// Builds and returns a workflow instance configured to process messages of the specified input type.
/// </summary>
@@ -228,9 +257,7 @@ public class WorkflowBuilder
throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound.");
}
// TODO: Delay-instantiate the start executor, and ensure it take input of type T
Executor startExecutor = startRegistration.Provider();
Executor startExecutor = RunSync(startRegistration.CreateInstanceAsync);
if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T))))
{
// We have no handlers for the input type T, which means the built workflow will not be able to
@@ -47,42 +47,49 @@ public class RepresentationTests
return current;
}
private static void RunExecutorishInfoMatchTest(ExecutorIsh target)
private static async ValueTask RunExecutorishInfoMatchTestAsync(ExecutorIsh target)
{
ExecutorRegistration registration = target.Registration;
ExecutorInfo info = registration.ToExecutorInfo();
info.IsMatch(registration.Provider()).Should().BeTrue();
info.IsMatch(await registration.ProviderAsync()).Should().BeTrue();
}
[Fact]
public void Test_Executorish_Infos()
public async Task Test_Executorish_InfosAsync()
{
int testsRun = 0;
RunExecutorishTest(new TestExecutor());
RunExecutorishTest(TestInputPort);
RunExecutorishTest(new TestAgent());
await RunExecutorishTest(new TestExecutor());
await RunExecutorishTest(TestInputPort);
await RunExecutorishTest(new TestAgent());
Func<int, IWorkflowContext, CancellationToken, ValueTask> function = MessageHandlerAsync;
await RunExecutorishTest(function.AsExecutor("FunctionExecutor"));
if (Enum.GetValues(typeof(ExecutorIsh.Type)).Length > testsRun + 1)
{
Assert.Fail("Not all ExecutorIsh types were tested.");
}
void RunExecutorishTest(ExecutorIsh executorish)
async ValueTask RunExecutorishTest(ExecutorIsh executorish)
{
RunExecutorishInfoMatchTest(executorish);
await RunExecutorishInfoMatchTestAsync(executorish);
testsRun++;
}
async ValueTask MessageHandlerAsync(int message, IWorkflowContext workflowContext, CancellationToken cancellation = default)
{
}
}
[Fact]
public void Test_SpecializedExecutor_Infos()
public async Task Test_SpecializedExecutor_InfosAsync()
{
RunExecutorishInfoMatchTest(new AIAgentHostExecutor(new TestAgent()));
RunExecutorishInfoMatchTest(new RequestInfoExecutor(TestInputPort));
await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestInputPort));
OutputCollectorExecutor<ChatMessage, IEnumerable<ChatMessage>> outputCollector = new(StreamingAggregators.Union<ChatMessage>());
RunExecutorishInfoMatchTest(outputCollector);
await RunExecutorishInfoMatchTestAsync(outputCollector);
}
private static string Source(string id) => $"Source/{id}";