diff --git a/dotnet/src/Microsoft.Agents.Workflows/Config.cs b/dotnet/src/Microsoft.Agents.Workflows/Config.cs new file mode 100644 index 0000000000..cdd2ce1534 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Config.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// Configuration for an object with a string identifier. For example, object. +/// +/// A unique identifier for the configurable object. +public class Config(string? id = null) +{ + /// + /// A unique identifier for the configurable object. + /// + /// + /// If not provided, the configured object will generate its own identifier. + /// + public string? Id => id; +} + +/// +/// Configuration for an object with a string identifier and options of type . +/// +/// The type of options for the configurable object. +/// The options for the configurable object. +/// A unique identifier for the configurable object. +public class Config(TOptions? options = default, string? id = null) : Config(id) +{ + /// + /// Options for the configured object. + /// + public TOptions? Options => options; +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/ConfigurationExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/ConfigurationExtensions.cs new file mode 100644 index 0000000000..c3309116c6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/ConfigurationExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// Extensions methods for creating Configured objects +/// +public static class ConfigurationExtensions +{ + /// + /// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at + /// the parent type level. + /// + /// The type of the original subject being configured. Must inherit from or implement TParent. + /// The base type or interface to which the configuration will be upcast. + /// The existing configuration for the subject type to be upcast to its parent type. Cannot be null. + /// A new instance that applies the original configuration logic to the parent type. + public static Configured Super(this Configured configured) where TSubject : TParent + => new(async config => await configured.FactoryAsync(config).ConfigureAwait(false), configured.Id, configured.Raw); + + /// + /// Creates a new configuration that treats the subject as its base type, allowing configuration to be applied at + /// the parent type level. + /// + /// The type of the original subject being configured. Must inherit from or implement TParent. + /// The base type or interface to which the configuration will be upcast. + /// The type of configuration options for the original subject being configured. + /// The existing configuration for the subject type to be upcast to its parent type. Cannot be null. + /// A new instance that applies the original configuration logic to the parent type. + public static Configured Super(this Configured configured) where TSubject : TParent + => configured.Memoize().Super(); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Configured.cs b/dotnet/src/Microsoft.Agents.Workflows/Configured.cs new file mode 100644 index 0000000000..d88da9277f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Configured.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows; + +/// +/// Helper methods for creating instances. +/// +public static class Configured +{ + /// + /// Creates a instance from an existing subject instance. + /// + /// + /// The subject instance. If the subject implements , its ID will be used + /// and checked against the provided ID (if any). + /// + /// + /// A unique identifier for the configured subject. This is required if the subject does not implement + /// + /// + /// + /// The raw representation of the subject instance. + /// + /// + public static Configured FromInstance(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((_) => 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((_) => new(subject), id, raw: raw ?? subject); + } +} + +/// +/// A representation of a preconfigured, lazy-instantiatable instance of . +/// +/// The type of the preconfigured subject. +/// A factory to intantiate the subject when desired. +/// The unique identifier for the configured subject. +/// +public class Configured(Func> factoryAsync, string id, object? raw = null) +{ + /// + /// The raw representation of the configured object, if any. + /// + public object? Raw => raw; + + /// + /// Gets the configured identifier for the subject. + /// + public string Id => id; + + /// + /// Gets the factory function to create an instance of given a . + /// + public Func> FactoryAsync => factoryAsync; + + /// + /// The configuration for this configured instance. + /// + public Config Configuration => new(this.Id); + + /// + /// Gets a "partially" applied factory function that only requires no parameters to create an instance of + /// with the provided instance. + /// + internal Func> BoundFactoryAsync => () => this.FactoryAsync(this.Configuration); +} + +/// +/// A representation of a preconfigured, lazy-instantiatable instance of . +/// +/// The type of the preconfigured subject. +/// The type of configuration options for the preconfigured subject. +/// A factory to intantiate the subject when desired. +/// The unique identifier for the configured subject. +/// Additional configuration options for the subject. +/// +public class Configured(Func, ValueTask> factoryAsync, string id, TOptions? options = default, object? raw = null) +{ + /// + /// The raw representation of the configured object, if any. + /// + public object? Raw => raw; + + /// + /// Gets the configured identifier for the subject. + /// + public string Id => id; + + /// + /// Gets the options associated with this instance. + /// + public TOptions? Options => options; + + /// + /// Gets the factory function to create an instance of given a . + /// + public Func, ValueTask> FactoryAsync => factoryAsync; + + /// + /// The configuration for this configured instance. + /// + public Config Configuration => new(this.Options, this.Id); + + /// + /// Gets a "partially" applied factory function that only requires no parameters to create an instance of + /// with the provided instance. + /// + internal Func> BoundFactoryAsync => () => this.CreateValidatingMemoizedFactory()(this.Configuration); + + private Func> CreateValidatingMemoizedFactory() + { + return FactoryAsync; + + async ValueTask 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; + } + } + + /// + /// Memoizes and erases the typed configuration options for the subject. + /// + public Configured Memoize() => new(this.CreateValidatingMemoizedFactory(), this.Id); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs index 97519ec0ad..c3e704ed69 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs @@ -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 /// public string Id { get; } + private readonly ExecutorOptions _options; + /// /// Initialize the executor with a unique identifier /// - /// A optional unique identifier for the executor. If null, a type-tagged - /// UUID will be generated. - protected Executor(string? id = null) : this(ExecutorOptions.Default, id) - { - } - - private readonly ExecutorOptions _options; - internal Executor(ExecutorOptions options, string? id = null) + /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// Configuration options for the executor. If null, default options will be used. + 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; } /// @@ -144,3 +142,43 @@ public abstract class Executor : IIdentified /// public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); } + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages. +/// +/// The type of input message. +/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// Configuration options for the executor. If null, default options will be used. +public abstract class Executor(string? id = null, ExecutorOptions? options = null) + : Executor(id, options), IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.AddHandler(this.HandleAsync); + } + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context); +} + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages. +/// +/// The type of input message. +/// The type of output message. +/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// Configuration options for the executor. If null, default options will be used. +public abstract class Executor(string? id = null, ExecutorOptions? options = null) + : Executor(id, options ?? ExecutorOptions.Default), + IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder.AddHandler(this.HandleAsync); + } + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context); +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs index dd026f541d..53a2fb201f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs @@ -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; +/// +/// Extension methods for configuring executors and functions as instances. +/// +public static class ExecutorIshConfigurationExtensions +{ + /// + /// Configures a factory method for creating an of type , with + /// the specified id and options. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, if this is used as a start node of a typed via , + /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the + /// demanded TInput. + /// + /// The type of the resulting executor + /// The type of options object to be passed to the factory method. + /// The factory method. + /// An id for the executor to be instantiated. + /// An optional parameter specifying the options. + /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorIsh ConfigureFactory(this Func, ValueTask> factoryAsync, string id, TOptions? options = null) + where TExecutor : Executor + where TOptions : ExecutorOptions + { + Configured configured = new(factoryAsync, id, options); + + return new ExecutorIsh(configured.Super(), typeof(TExecutor), ExecutorIsh.Type.Executor); + } + + private static ExecutorIsh ToExecutorIsh(this FunctionExecutor executor, Delegate raw) + { + return new ExecutorIsh(Configured.FromInstance(executor, raw: raw) + .Super, Executor>(), + typeof(FunctionExecutor), + ExecutorIsh.Type.Function); + } + + private static ExecutorIsh ToExecutorIsh(this FunctionExecutor executor, Delegate raw) + { + return new ExecutorIsh(Configured.FromInstance(executor, raw: raw) + .Super, Executor>(), + typeof(FunctionExecutor), + ExecutorIsh.Type.Function); + } + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// Configuration options for the executor. If null, default options will be used. + /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null) + => new FunctionExecutor(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync); + + /// + /// Configures a function-based asynchronous message handler as an executor with the specified identifier and + /// options. + /// + /// The type of input message. + /// The type of output message. + /// A delegate that defines the asynchronous function to execute for each input message. + /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// Configuration options for the executor. If null, default options will be used. + /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null) + => new FunctionExecutor(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync); +} + /// /// A tagged union representing an object that can function like an in a , /// or a reference to one by ID. @@ -31,6 +105,10 @@ public sealed class ExecutorIsh : /// Executor, /// + /// A function delegate to be wrapped as an executor. + /// + Function, + /// /// An for servicing external requests. /// 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? _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 configured, System.Type configuredExecutorType, ExecutorIsh.Type type) + { + this.ExecutorType = type; + this._configuredExecutor = configured; + this._configuredExecutorType = configuredExecutorType; + } + /// /// Initializes a new instance of the ExecutorIsh class using the specified executor. /// @@ -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(); } /// @@ -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 that can be used to obtain an instance /// corresponding to this . /// - private Func ExecutorProvider => this.ExecutorType switch + private Func> 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}':", - 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}':" }; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs index a19630bf31..02cadf601f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.Workflows; /// /// Configuration options for Executor behavior. /// -public sealed class ExecutorOptions +public class ExecutorOptions { /// /// The default runner configuration. diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorRegistration.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorRegistration.cs index bb1e060107..76dbba8ed3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorRegistration.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorRegistration.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using ExecutorFactoryF = System.Func; +using ExecutorFactoryF = System.Func>; 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 CreateInstanceAsync() => this.CheckId(await this.ProviderAsync().ConfigureAwait(false)); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs new file mode 100644 index 0000000000..502e58008b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows; + +/// +/// Executes a user-provided asynchronous function in response to workflow messages of the specified input type. +/// +/// The type of input message. +/// A delegate that defines the asynchronous function to execute for each input message. +/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// Configuration options for the executor. If null, default options will be used. +public class FunctionExecutor(Func handlerAsync, + string? id = null, + ExecutorOptions? options = null) : Executor(id, options) +{ + internal static Func WrapAction(Action handlerSync) + { + return RunActionAsync; + + ValueTask RunActionAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellation) + { + handlerSync(input, workflowContext, cancellation); + return default; + } + } + + /// + public override ValueTask HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default); + + /// + /// Creates a new instance of the class. + /// + /// A synchronous function to execute for each input message and workflow context. + public FunctionExecutor(Action handlerSync) : this(WrapAction(handlerSync)) + { } +} + +/// +/// Executes a user-provided asynchronous function in response to workflow messages of the specified input type, +/// +/// The type of input message. +/// The type of output message. +/// A delegate that defines the asynchronous function to execute for each input message. +/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// Configuration options for the executor. If null, default options will be used. +public class FunctionExecutor(Func> handlerAsync, + string? id = null, + ExecutorOptions? options = null) : Executor(id, options) +{ + internal static Func> WrapFunc(Func handlerSync) + { + return RunFuncAsync; + + ValueTask RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellation) + { + TOutput result = handlerSync(input, workflowContext, cancellation); + return new ValueTask(result); + } + } + /// + public override ValueTask HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default); + /// + /// Creates a new instance of the class. + /// + /// A synchronous function to execute for each input message and workflow context. + public FunctionExecutor(Func handlerSync) : this(WrapFunc(handlerSync)) + { } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index 15c4b9c8b2..3a57dd9302 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -35,7 +35,7 @@ internal class InProcessRunnerContext : 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) diff --git a/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs index 9b0ca4a74e..f5854bf29d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs @@ -16,8 +16,8 @@ public class ReflectingExecutor< ] TExecutor > : Executor where TExecutor : ReflectingExecutor { - /// - protected ReflectingExecutor(string? id = null) : base(id) + /// + protected ReflectingExecutor(string? id = null, ExecutorOptions? options = null) : base(id, options) { } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs index 4cb151666f..9b12ff1515 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs @@ -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; diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index 1a37415d19..49ad08c33b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -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(Func> 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 PropagateCultureAndInvoke() + { + // Set the culture and UI culture to the captured values + System.Globalization.CultureInfo.CurrentCulture = culture; + System.Globalization.CultureInfo.CurrentUICulture = uiCulture; + return funcAsync().AsTask(); + } + } + /// /// Builds and returns a workflow instance configured to process messages of the specified input type. /// @@ -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 diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs index 1809d0d908..294b58a4de 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs @@ -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 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> outputCollector = new(StreamingAggregators.Union()); - RunExecutorishInfoMatchTest(outputCollector); + await RunExecutorishInfoMatchTestAsync(outputCollector); } private static string Source(string id) => $"Source/{id}";