mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
518fd447fd
commit
0cae1e0adf
@@ -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}";
|
||||
|
||||
Reference in New Issue
Block a user