.NET: [BREAKING] Unify ExecutorIsh and ExecutorRegistration, unify/simplify APIs (#1637)

* refactor: Unify ExecutorIsh and ExecutorRegistration => ExecutorBinding

* Switch to more modern Record type-tree for Sum Types
* Unify APIs for getting ExecutorBinding
* Fix an issue where workflows consisting entirely of cross-run shareable executors which are not instance-resettable do not properly clear state when running non-concurrently.

* feat: Simplify function-to-executor pattern

* refactor: Normalize API naming
This commit is contained in:
Jacob Alber
2025-11-03 13:20:35 -05:00
committed by GitHub
Unverified
parent 91c66f8a2c
commit b25b0af49b
35 changed files with 939 additions and 595 deletions
@@ -20,7 +20,9 @@ public static class Program
private static async Task Main()
{
// Create the executors
UppercaseExecutor uppercase = new();
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
ReverseTextExecutor reverse = new();
// Build the workflow by connecting executors sequentially
@@ -40,23 +42,6 @@ public static class Program
}
}
/// <summary>
/// First executor: converts input text to uppercase.
/// </summary>
internal sealed class UppercaseExecutor() : Executor<string, string>("UppercaseExecutor")
{
/// <summary>
/// Processes the input message by converting it to uppercase.
/// </summary>
/// <param name="message">The input text to convert</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The input text converted to uppercase</returns>
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors
}
/// <summary>
/// Second executor: reverses the input text and completes the workflow.
/// </summary>
@@ -40,7 +40,7 @@ public static class Program
.Build();
// Step 2: Configure the sub-workflow as an executor for use in the parent workflow
ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow");
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("TextProcessingSubWorkflow");
// Step 3: Build a main workflow that uses the sub-workflow as an executor
Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n");
@@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content.
## What You'll Learn
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally
2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions
3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses
4. **Building complex pipelines** - Chaining multiple heterogeneous components together
@@ -19,12 +19,12 @@ internal sealed class WorkflowModelBuilder : IModelBuilder<Func<object?, bool>>
Debug.WriteLine($"> CONNECT: {source.Id} => {target.Id}{(condition is null ? string.Empty : " (?)")}");
this.WorkflowBuilder.AddEdge(
GetExecutorIsh(source),
GetExecutorIsh(target),
GetExecutorBinding(source),
GetExecutorBinding(target),
condition);
}
private static ExecutorIsh GetExecutorIsh(IModeledAction action) =>
private static ExecutorBinding GetExecutorBinding(IModeledAction action) =>
action switch
{
RequestPortAction port => port.RequestPort,
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
/// </summary>
/// <param name="Agent">The AI agent.</param>
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
: ExecutorBinding(Throw.IfNull(Agent).Name ?? Throw.IfNull(Agent.Id),
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
typeof(AIAgentHostExecutor),
Agent)
{
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -40,7 +40,7 @@ public static partial class AgentWorkflowBuilder
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
WorkflowBuilder? builder = null;
ExecutorIsh? previous = null;
ExecutorBinding? previous = null;
foreach (var agent in agents)
{
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
@@ -125,8 +125,8 @@ public static partial class AgentWorkflowBuilder
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, targets: agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
@@ -141,7 +141,7 @@ public static partial class AgentWorkflowBuilder
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorIsh end = endFactory.ConfigureFactory(ConcurrentEndExecutor.ExecutorId);
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInEdge(end, sources: accumulators);
@@ -12,7 +12,7 @@ internal sealed record class ExecutorInfo(TypeId ExecutorType, string ExecutorId
this.ExecutorType.IsMatch(executor.GetType())
&& this.ExecutorId == executor.Id;
public bool IsMatch(ExecutorRegistration registration) =>
this.ExecutorType.IsMatch(registration.ExecutorType)
&& this.ExecutorId == registration.Id;
public bool IsMatch(ExecutorBinding binding) =>
this.ExecutorType.IsMatch(binding.ExecutorType)
&& this.ExecutorId == binding.Id;
}
@@ -9,10 +9,10 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal static class RepresentationExtensions
{
public static ExecutorInfo ToExecutorInfo(this ExecutorRegistration registration)
public static ExecutorInfo ToExecutorInfo(this ExecutorBinding binding)
{
Throw.IfNull(registration);
return new ExecutorInfo(new TypeId(registration.ExecutorType), registration.Id);
Throw.IfNull(binding);
return new ExecutorInfo(new TypeId(binding.ExecutorType), binding.Id);
}
public static EdgeInfo ToEdgeInfo(this Edge edge)
@@ -38,8 +38,8 @@ internal static class RepresentationExtensions
Throw.IfNull(workflow);
Dictionary<string, ExecutorInfo> executors =
workflow.Registrations.Values.ToDictionary(
keySelector: registration => registration.Id,
workflow.ExecutorBindings.Values.ToDictionary(
keySelector: binding => binding.Id,
elementSelector: ToExecutorInfo);
Dictionary<string, List<EdgeInfo>> edges = workflow.Edges.Keys.ToDictionary(
@@ -47,10 +47,10 @@ internal sealed class WorkflowInfo
}
// Validate the executors
if (workflow.Registrations.Count != this.Executors.Count ||
if (workflow.ExecutorBindings.Count != this.Executors.Count ||
this.Executors.Keys.Any(
executorId => workflow.Registrations.TryGetValue(executorId, out ExecutorRegistration? registration)
&& !this.Executors[executorId].IsMatch(registration)))
executorId => workflow.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding)
&& !this.Executors[executorId].IsMatch(binding)))
{
return false;
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
// TODO: Unwrap the Configured object, just like for SubworkflowBinding
internal record ConfiguredExecutorBinding(Configured<Executor> ConfiguredExecutor, Type ExecutorType)
: ExecutorBinding(Throw.IfNull(ConfiguredExecutor).Id,
ConfiguredExecutor.BoundFactoryAsync,
ExecutorType,
ConfiguredExecutor.Raw)
{
/// <inheritdoc/>
public override bool IsSharedInstance { get; } = ConfiguredExecutor.Raw is Executor;
protected override async ValueTask<bool> ResetCoreAsync()
{
if (this.ConfiguredExecutor.Raw is IResettableExecutor resettable)
{
await resettable.ResetAsync().ConfigureAwait(false);
}
return false;
}
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -27,6 +27,8 @@ public abstract class Executor : IIdentified
private static readonly string s_namespace = typeof(Executor).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
// TODO: Add overloads for binding with a configuration/options object once the Configured<T> hierarchy goes away.
/// <summary>
/// Initialize the executor with a unique identifier
/// </summary>
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the binding information for a workflow executor, including its identifier, factory method, type, and
/// optional raw value.
/// </summary>
/// <param name="Id">The unique identifier for the executor in the workflow.</param>
/// <param name="FactoryAsync">A factory function that creates an instance of the executor. The function accepts two string parameters and returns
/// a ValueTask containing the created Executor instance.</param>
/// <param name="ExecutorType">The type of the executor. Must be a type derived from Executor.</param>
/// <param name="RawValue">An optional raw value associated with the binding.</param>
public abstract record class ExecutorBinding(string Id, Func<string, ValueTask<Executor>>? FactoryAsync, Type ExecutorType, object? RawValue = null)
: IIdentified,
IEquatable<IIdentified>,
IEquatable<string>
{
/// <summary>
/// Gets a value indicating whether the binding is a placeholder (i.e., does not have a factory method defined).
/// </summary>
[MemberNotNullWhen(false, nameof(FactoryAsync))]
public bool IsPlaceholder => this.FactoryAsync == null;
/// <summary>
/// Gets a value whether the executor created from this binding is a shared instance across all runs.
/// </summary>
public abstract bool IsSharedInstance { get; }
/// <summary>
/// Gets a value whether instances of the executor created from this binding can be used in concurrent runs
/// from the same <see cref="Workflow"/> instance.
/// </summary>
public abstract bool SupportsConcurrentSharedExecution { get; }
/// <summary>
/// Gets a value whether instances of the executor created from this binding can be reset between subsequent
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
/// cref="SupportsConcurrentSharedExecution"/>.
/// </summary>
public abstract bool SupportsResetting { get; }
/// <inheritdoc/>
public override string ToString() => $"{this.Id}:{(this.IsPlaceholder ? ":<unbound>" : this.ExecutorType.Name)}";
private Executor CheckId(Executor executor)
{
if (executor.Id != this.Id)
{
throw new InvalidOperationException(
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
}
return executor;
}
internal async ValueTask<Executor> CreateInstanceAsync(string runId)
=> !this.IsPlaceholder
? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false))
: throw new InvalidOperationException(
$"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder.");
/// <inheritdoc/>
public virtual bool Equals(ExecutorBinding? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(IIdentified? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(string? other) =>
other is not null && other == this.Id;
internal ValueTask<bool> TryResetAsync()
{
// Non-shared instances do not need resetting
if (!this.IsSharedInstance)
{
return new(true);
}
// If the executor supports concurrent use, then resetting is a no-op.
if (!this.SupportsResetting)
{
return new(false);
}
return this.ResetCoreAsync();
}
/// <summary>
/// Resets the executor's shared resources to their initial state. Must be overridden by bindings that support
/// resetting.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
protected virtual ValueTask<bool> ResetCoreAsync() => throw new InvalidOperationException("ExecutorBindings that support resetting must override ResetCoreAsync()");
/// <inheritdoc/>
public override int GetHashCode() => this.Id.GetHashCode();
/// <summary>
/// Defines an implicit conversion from an Executor to a <see cref="ExecutorBinding"/>.
/// </summary>
/// <param name="executor">The Executor instance to convert.</param>
public static implicit operator ExecutorBinding(Executor executor) => executor.BindExecutor();
/// <summary>
/// Defines an implicit conversion from a string identifier to an <see cref="ExecutorPlaceholder"/>.
/// </summary>
/// <param name="id">The string identifier to convert to a placeholder.</param>
public static implicit operator ExecutorBinding(string id) => new ExecutorPlaceholder(id);
/// <summary>
/// Defines an implicit conversion from a <see cref="RequestPort "/>to an <see cref="ExecutorBinding"/>.
/// </summary>
/// <param name="port">The RequestPort instance to convert.</param>
public static implicit operator ExecutorBinding(RequestPort port) => port.BindAsExecutor();
/// <summary>
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorBinding"/> instance.
/// </summary>
/// <param name="agent"></param>
public static implicit operator ExecutorBinding(AIAgent agent) => agent.BindAsExecutor();
}
@@ -0,0 +1,434 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension methods for configuring executors and functions as <see cref="ExecutorBinding"/> instances.
/// </summary>
public static class ExecutorBindingExtensions
{
/// <summary>
/// Configures an <see cref="Executor"/> instance for use in a workflow.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
/// </remarks>
/// <param name="executor">The executor instance.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance wrapping the specified <see cref="Executor"/>.</returns>
public static ExecutorBinding BindExecutor(this Executor executor)
=> new ExecutorInstanceBinding(executor);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> factoryAsync.BindExecutor();
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> factoryAsync.BindExecutor(id);
/// <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, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </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 <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
return new ConfiguredExecutorBinding(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor));
}
/// <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, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </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 <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
=> factoryAsync.BindExecutor(id, options);
private static ConfiguredExecutorBinding ToBinding<TInput>(this FunctionExecutor<TInput> executor, Delegate raw)
=> new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput>, Executor>(),
typeof(FunctionExecutor<TInput>));
private static ConfiguredExecutorBinding ToBinding<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw)
=> new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
typeof(FunctionExecutor<TInput, TOutput>));
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An ExecutorRegistration instance representing the configured sub-workflow executor.</returns>
[Obsolete("Use BindAsExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
=> workflow.BindAsExecutor(id, options);
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance representing the configured sub-workflow executor.</returns>
public static ExecutorBinding BindAsExecutor(this Workflow workflow, string id, ExecutorOptions? options = null)
=> new SubworkflowBinding(workflow, id, options);
/// <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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(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>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, __) => messageHandlerAsync(input)))
.BindAsExecutor(id, options, threadsafe);
/// <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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, ct) => messageHandlerAsync(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, __) => messageHandler(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, ctx, __) => messageHandler(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, ct) => messageHandler(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <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 unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, __) => messageHandlerAsync(input)))
.BindAsExecutor(id, options, threadsafe);
/// <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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <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">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, ct) => messageHandlerAsync(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based 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="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
/// <summary>
/// Configures a function-based 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="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, __) => messageHandler(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based 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="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, ctx, __) => messageHandler(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based 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="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, ct) => messageHandler(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based aggregating executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
/// <summary>
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
/// </summary>
/// <param name="agent">The agent instance.</param>
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
=> new AIAgentBinding(agent, emitEvents);
/// <summary>
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
/// </summary>
/// <param name="port">The port configuration.</param>
/// <param name="allowWrappedRequests">Specifies whether the port should accept requests already wrapped in
/// <see cref="ExternalRequest"/>.</param>
/// <returns>A <see cref="RequestPortBinding"/> instance that wraps the provided port.</returns>
public static ExecutorBinding BindAsExecutor(this RequestPort port, bool allowWrappedRequests = true)
=> new RequestPortBinding(port, allowWrappedRequests);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for a shared executor instance, including configuration options
/// for event emission.
/// </summary>
/// <param name="ExecutorInstance">The executor instance to bind. Cannot be null.</param>
public record ExecutorInstanceBinding(Executor ExecutorInstance)
: ExecutorBinding(Throw.IfNull(ExecutorInstance).Id,
(_) => new(ExecutorInstance),
ExecutorInstance.GetType(),
ExecutorInstance)
{
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => this.ExecutorInstance.IsCrossRunShareable;
/// <inheritdoc/>
public override bool SupportsResetting => this.ExecutorInstance is IResettableExecutor;
/// <inheritdoc/>
public override bool IsSharedInstance => true;
/// <inheritdoc/>
protected override async ValueTask<bool> ResetCoreAsync()
{
if (this.ExecutorInstance is IResettableExecutor resettable)
{
await resettable.ResetAsync().ConfigureAwait(false);
return true;
}
return false;
}
}
@@ -1,369 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.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"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</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>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</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>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
/// <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, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </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>, string, 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) => new(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) => new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
typeof(FunctionExecutor<TInput, TOutput>),
ExecutorIsh.Type.Function);
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An ExecutorIsh instance representing the configured sub-workflow executor.</returns>
public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
{
object ownershipToken = new();
workflow.TakeOwnership(ownershipToken, subworkflow: true);
Configured<WorkflowHostExecutor, ExecutorOptions> configured = new(InitHostExecutorAsync, id, options, raw: workflow);
return new ExecutorIsh(configured.Super<WorkflowHostExecutor, Executor, ExecutorOptions>(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
ValueTask<WorkflowHostExecutor> InitHostExecutorAsync(Config<ExecutorOptions> config, string runId)
{
return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
}
}
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </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>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</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, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).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 unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</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, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based aggregating executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
}
/// <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.
/// </summary>
public sealed class ExecutorIsh :
IIdentified,
IEquatable<ExecutorIsh>,
IEquatable<IIdentified>,
IEquatable<string>
{
/// <summary>
/// The type of the <see cref="ExecutorIsh"/>.
/// </summary>
public enum Type
{
/// <summary>
/// An unbound executor reference, identified only by ID.
/// </summary>
Unbound,
/// <summary>
/// An actual <see cref="Executor"/> instance.
/// </summary>
Executor,
/// <summary>
/// A function delegate to be wrapped as an executor.
/// </summary>
Function,
/// <summary>
/// An <see cref="RequestPort"/> for servicing external requests.
/// </summary>
RequestPort,
/// <summary>
/// An <see cref="AIAgent"/> instance.
/// </summary>
Agent,
/// <summary>
/// A nested <see cref="Workflow"/> instance.
/// </summary>
Workflow,
}
/// <summary>
/// Gets the type of data contained in this <see cref="ExecutorIsh" /> instance.
/// </summary>
public Type ExecutorType { get; init; }
private readonly string? _idValue;
private readonly Configured<Executor>? _configuredExecutor;
private readonly System.Type? _configuredExecutorType;
internal readonly RequestPort? _requestPortValue;
private readonly AIAgent? _aiAgentValue;
/// <summary>
/// Initializes a new instance of the <see cref="ExecutorIsh"/> class as an unbound reference by ID.
/// </summary>
/// <param name="id">A unique identifier for an <see cref="Executor"/> in the <see cref="Workflow"/></param>
public ExecutorIsh(string id)
{
this.ExecutorType = Type.Unbound;
this._idValue = Throw.IfNull(id);
}
internal ExecutorIsh(Configured<Executor> configured, System.Type configuredExecutorType, 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>
/// <param name="executor">The executor instance to be wrapped.</param>
public ExecutorIsh(Executor executor)
{
this.ExecutorType = Type.Executor;
this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor));
this._configuredExecutorType = executor.GetType();
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
/// </summary>
/// <param name="port">The input port to associate to be wrapped.</param>
public ExecutorIsh(RequestPort port)
{
this.ExecutorType = Type.RequestPort;
this._requestPortValue = Throw.IfNull(port);
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified AI agent.
/// </summary>
/// <param name="aiAgent"></param>
public ExecutorIsh(AIAgent aiAgent)
{
this.ExecutorType = Type.Agent;
this._aiAgentValue = Throw.IfNull(aiAgent);
}
internal bool IsUnbound => this.ExecutorType == Type.Unbound;
/// <inheritdoc/>
public string Id => this.ExecutorType switch
{
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
Type.Executor => this._configuredExecutor!.Id,
Type.RequestPort => this._requestPortValue!.Id,
Type.Agent => this._aiAgentValue!.Id,
Type.Function => this._configuredExecutor!.Id,
Type.Workflow => this._configuredExecutor!.Id,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
internal object? RawData => this.ExecutorType switch
{
Type.Unbound => this._idValue,
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
Type.RequestPort => this._requestPortValue,
Type.Agent => this._aiAgentValue,
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// Gets the registration details for the current executor.
/// </summary>
/// <remarks>The returned registration depends on the type of the executor. If the executor is unbound, an
/// <see cref="InvalidOperationException"/> is thrown. For other executor types, the registration includes the
/// appropriate ID, type, and provider based on the executor's configuration.</remarks>
internal ExecutorRegistration Registration => new(this.Id, this.RuntimeType, this.ExecutorProvider, this.RawData);
private System.Type RuntimeType => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutorType!,
Type.RequestPort => typeof(RequestInfoExecutor),
Type.Agent => typeof(AIAgentHostExecutor),
Type.Function => this._configuredExecutorType!,
Type.Workflow => this._configuredExecutorType!,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// 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<string, ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)),
Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)),
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
Type.Workflow => this._configuredExecutor!.BoundFactoryAsync,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// Defines an implicit conversion from an <see cref="Executor"/> instance to an <see cref="ExecutorIsh"/> object.
/// </summary>
/// <param name="executor">The <see cref="Executor"/> instance to convert to <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(Executor executor) => new(executor);
/// <summary>
/// Defines an implicit conversion from an <see cref="RequestPort"/> to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="inputPort">The <see cref="RequestPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
/// <summary>
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="aiAgent">The <see cref="AIAgent"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent);
/// <summary>
/// Defines an implicit conversion from a string to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="id">The string ID to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(string id) => new(id);
/// <inheritdoc/>
public bool Equals(ExecutorIsh? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(IIdentified? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(string? other) =>
other is not null && other == this.Id;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj switch
{
null => false,
ExecutorIsh ish => this.Equals(ish),
IIdentified identified => this.Equals(identified),
string str => this.Equals(str),
_ => false
};
/// <inheritdoc/>
public override int GetHashCode() => this.Id.GetHashCode();
/// <inheritdoc/>
public override string ToString() => this.ExecutorType switch
{
Type.Unbound => $"'{this.Id}':<unbound>",
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})",
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}",
_ => $"'{this.Id}':<unknown[{this.ExecutorType}]>"
};
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a placeholder entry for an <see cref="ExecutorBinding"/>, identified by a unique ID.
/// </summary>
/// <param name="Id">The unique identifier for the placeholder registration.</param>
public record ExecutorPlaceholder(string Id)
: ExecutorBinding(Id,
null,
typeof(Executor),
Id)
{
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => false;
/// <inheritdoc/>
public override bool SupportsResetting => false;
/// <inheritdoc/>
public override bool IsSharedInstance => false;
}
@@ -1,72 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryF = System.Func<string, System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class ExecutorRegistration(string id, Type executorType, ExecutorFactoryF provider, object? rawData)
{
public string Id { get; } = Throw.IfNullOrEmpty(id);
public Type ExecutorType { get; } = Throw.IfNull(executorType);
private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
public bool IsSharedInstance { get; } = rawData is Executor;
/// <summary>
/// Gets a value whether instances of the executor created from this registration can be reset between subsequent
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
/// cref="SupportsConcurrent"/>.
/// </summary>
public bool SupportsResetting { get; } = rawData is Executor &&
// Cross-Run Shareable executors are "trivially" resettable, since they
// have no on-object state.
rawData is IResettableExecutor;
/// <summary>
/// Gets a value whether instances of the executor created from this registration can be used in concurrent runs
/// from the same <see cref="Workflow"/> instance.
/// </summary>
public bool SupportsConcurrent { get; } = rawData is not Executor executor || executor.IsCrossRunShareable;
internal async ValueTask<bool> TryResetAsync()
{
// Non-shared instances do not need resetting
if (!this.IsSharedInstance)
{
return true;
}
// Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable
// then we would have returned in the first condition, and if rawData is not an Executor, we would have
// returned in the second condition. That only leaves the possibility of rawData is Executor and also
// IResettableExecutor.
if (this.RawExecutorishData is IResettableExecutor resettableExecutor)
{
await resettableExecutor.ResetAsync().ConfigureAwait(false);
return true;
}
return false;
}
internal object? RawExecutorishData { get; } = rawData;
public override string ToString() => $"{this.ExecutorType.Name}({this.Id})";
private Executor CheckId(Executor executor)
{
if (executor.Id != this.Id)
{
throw new InvalidOperationException(
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
}
return executor;
}
public async ValueTask<Executor> CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
}
@@ -38,7 +38,9 @@ public class FunctionExecutor<TInput>(string id,
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync) : this(id, WrapAction(handlerSync))
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
{
}
}
@@ -76,7 +78,9 @@ public class FunctionExecutor<TInput, TOutput>(string id,
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync) : this(id, WrapFunc(handlerSync))
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
{
}
}
@@ -50,12 +50,12 @@ public sealed class GroupChatWorkflowBuilder
public Workflow Build()
{
AIAgent[] agents = this._participants.ToArray();
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
(string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost));
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
foreach (var participant in agentMap.Values)
@@ -73,7 +73,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task<Executor> CreateExecutorAsync(string id)
{
if (!this._workflow.Registrations.TryGetValue(executorId, out var registration))
if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration))
{
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the registration details for a request port, including configuration for allowing wrapped requests.
/// </summary>
/// <param name="Port">The request port.</param>
/// <param name="AllowWrapped">true to allow wrapped requests to be handled by the port; otherwise, false.
/// The default is true.</param>
public record RequestPortBinding(RequestPort Port, bool AllowWrapped = true)
: ExecutorBinding(Throw.IfNull(Port).Id,
(_) => new ValueTask<Executor>(new RequestInfoExecutor(Port, AllowWrapped)),
typeof(RequestInfoExecutor),
Port)
{
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -10,11 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class GroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorIsh> agentMap,
Dictionary<AIAgent, ExecutorBinding> agentMap,
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
{
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private readonly List<ChatMessage> _pendingMessages = [];
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for a subworkflow, including its instance, identifier, and optional
/// executor options.
/// </summary>
/// <param name="WorkflowInstance"></param>
/// <param name="Id"></param>
/// <param name="ExecutorOptions"></param>
public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorOptions? ExecutorOptions = null)
: ExecutorBinding(Throw.IfNull(Id),
CreateWorkflowExecutorFactory(WorkflowInstance, Id, ExecutorOptions),
typeof(WorkflowHostExecutor),
WorkflowInstance)
{
private static Func<string, ValueTask<Executor>> CreateWorkflowExecutorFactory(Workflow workflow, string id, ExecutorOptions? options)
{
object ownershipToken = new();
workflow.TakeOwnership(ownershipToken, subworkflow: true);
return InitHostExecutorAsync;
ValueTask<Executor> InitHostExecutorAsync(string runId)
{
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
}
}
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public sealed class SwitchBuilder
{
private readonly List<ExecutorIsh> _executors = [];
private readonly List<ExecutorBinding> _executors = [];
private readonly Dictionary<string, int> _executorIndicies = [];
private readonly List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> _caseMap = [];
private readonly HashSet<int> _defaultIndicies = [];
@@ -30,14 +30,14 @@ public sealed class SwitchBuilder
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
/// Cannot be null.</param>
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(predicate);
Throw.IfNull(executors);
HashSet<int> indicies = [];
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
@@ -60,11 +60,11 @@ public sealed class SwitchBuilder
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
public SwitchBuilder WithDefault(params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
@@ -79,7 +79,7 @@ public sealed class SwitchBuilder
return this;
}
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source)
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding source)
{
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
HashSet<int> defaultIndicies = this._defaultIndicies;
@@ -69,7 +69,7 @@ public static class WorkflowVisualizer
lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\\n(Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.Registrations.Keys)
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
@@ -108,7 +108,7 @@ public static class WorkflowVisualizer
private static void EmitSubWorkflowsDigraph(Workflow workflow, List<string> lines, string indent)
{
foreach (var kvp in workflow.Registrations)
foreach (var kvp in workflow.ExecutorBindings)
{
var execId = kvp.Key;
var registration = kvp.Value;
@@ -145,7 +145,7 @@ public static class WorkflowVisualizer
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.Registrations.Keys)
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
@@ -264,9 +264,9 @@ public static class WorkflowVisualizer
#endif
}
private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [NotNullWhen(true)] out Workflow? workflow)
private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(true)] out Workflow? workflow)
{
if (registration.RawExecutorishData is Workflow subWorkflow)
if (binding.RawValue is Workflow subWorkflow)
{
workflow = subWorkflow;
return true;
@@ -19,7 +19,7 @@ public class Workflow
/// <summary>
/// A dictionary of executor providers, keyed by executor ID.
/// </summary>
internal Dictionary<string, ExecutorRegistration> Registrations { get; init; } = [];
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
internal HashSet<string> OutputExecutors { get; init; } = [];
@@ -41,7 +41,7 @@ public class Workflow
/// Gets the collection of external request ports, keyed by their ID.
/// </summary>
/// <remarks>
/// Each port has a corresponding entry in the <see cref="Registrations"/> dictionary.
/// Each port has a corresponding entry in the <see cref="ExecutorBindings"/> dictionary.
/// </remarks>
public Dictionary<string, RequestPortInfo> ReflectPorts()
{
@@ -66,10 +66,10 @@ public class Workflow
/// </summary>
public string? Description { get; internal init; }
internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent);
internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution);
internal IEnumerable<string> NonConcurrentExecutorIds =>
this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id);
this.ExecutorBindings.Values.Where(r => !r.SupportsConcurrentSharedExecution).Select(r => r.Id);
/// <summary>
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
@@ -86,12 +86,14 @@ public class Workflow
}
private bool _needsReset;
private bool HasResettable => this.Registrations.Values.Any(registration => registration.SupportsResetting);
private bool HasResettableExecutors =>
this.ExecutorBindings.Values.Any(registration => registration.SupportsResetting);
private async ValueTask<bool> TryResetExecutorRegistrationsAsync()
{
if (this.HasResettable)
if (this.HasResettableExecutors)
{
foreach (ExecutorRegistration registration in this.Registrations.Values)
foreach (ExecutorBinding registration in this.ExecutorBindings.Values)
{
// TryResetAsync returns true if the executor does not need resetting
if (!await registration.TryResetAsync().ConfigureAwait(false))
@@ -158,7 +160,7 @@ public class Workflow
});
}
this._needsReset = this.HasResettable;
this._needsReset = this.HasResettableExecutors;
this._ownedAsSubworkflow = subworkflow;
}
@@ -188,7 +190,7 @@ public class Workflow
/// a <see cref="ProtocolDescriptor"/> the protocol this <see cref="Workflow"/> follows.</returns>
public async ValueTask<ProtocolDescriptor> DescribeProtocolAsync(CancellationToken cancellationToken = default)
{
ExecutorRegistration startExecutorRegistration = this.Registrations[this.StartExecutorId];
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
return startExecutor.DescribeProtocol();
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <remarks>Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out
/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow.
/// All executors must be bound by calling into <see cref="BindExecutor"/> if they were intially specified as
/// <see cref="ExecutorIsh.Type.Unbound"/>.</remarks>
/// <see cref="ExecutorBinding.IsPlaceholder"/>.</remarks>
public class WorkflowBuilder
{
private readonly record struct EdgeConnection(string SourceId, string TargetId)
@@ -28,11 +28,11 @@ public class WorkflowBuilder
}
private int _edgeCount;
private readonly Dictionary<string, ExecutorRegistration> _executors = [];
private readonly Dictionary<string, ExecutorBinding> _executors = [];
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, RequestPort> _inputPorts = [];
private readonly Dictionary<string, RequestPort> _requestPorts = [];
private readonly HashSet<string> _outputExecutors = [];
private readonly string _startExecutorId;
@@ -46,57 +46,56 @@ public class WorkflowBuilder
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
/// </summary>
/// <param name="start">The executor that defines the starting point of the workflow. Cannot be null.</param>
public WorkflowBuilder(ExecutorIsh start)
public WorkflowBuilder(ExecutorBinding start)
{
this._startExecutorId = this.Track(start).Id;
}
private ExecutorIsh Track(ExecutorIsh executorish)
private ExecutorBinding Track(ExecutorBinding registration)
{
// If the executor is unbound, create an entry for it, unless it already exists.
// Otherwise, update the entry for it, and remove the unbound tag
if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id))
if (registration.IsPlaceholder && !this._executors.ContainsKey(registration.Id))
{
// If this is an unbound executor, we need to track it separately
this._unboundExecutors.Add(executorish.Id);
this._unboundExecutors.Add(registration.Id);
}
else if (!executorish.IsUnbound)
else if (!registration.IsPlaceholder)
{
ExecutorRegistration incoming = executorish.Registration;
// If there is already a bound executor with this ID, we need to validate (to best efforts)
// that the two are matching (at least based on type)
if (this._executors.TryGetValue(executorish.Id, out ExecutorRegistration? existing))
if (this._executors.TryGetValue(registration.Id, out ExecutorBinding? existing))
{
if (existing.ExecutorType != incoming.ExecutorType)
if (existing.ExecutorType != registration.ExecutorType)
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {incoming.ExecutorType.Name}) is already bound.");
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {registration.ExecutorType.Name}) is already bound.");
}
if (existing.RawExecutorishData is not null &&
!ReferenceEquals(existing.RawExecutorishData, incoming.RawExecutorishData))
if (existing.RawValue is not null &&
!ReferenceEquals(existing.RawValue, registration.RawValue))
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but different instance is already bound.");
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but different instance is already bound.");
}
}
else
{
this._executors[executorish.Id] = executorish.Registration;
if (this._unboundExecutors.Contains(executorish.Id))
this._executors[registration.Id] = registration;
if (this._unboundExecutors.Contains(registration.Id))
{
this._unboundExecutors.Remove(executorish.Id);
this._unboundExecutors.Remove(registration.Id);
}
}
}
if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort)
if (registration is RequestPortBinding portRegistration)
{
RequestPort port = executorish._requestPortValue!;
this._inputPorts[port.Id] = port;
RequestPort port = portRegistration.Port;
this._requestPorts[port.Id] = port;
}
return executorish;
return registration;
}
/// <summary>
@@ -106,9 +105,9 @@ public class WorkflowBuilder
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors)
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
{
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
this._outputExecutors.Add(this.Track(executor).Id);
}
@@ -139,21 +138,21 @@ public class WorkflowBuilder
}
/// <summary>
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
/// </summary>
/// <param name="executor">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
/// <param name="registration">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if the specified executor is already bound or does not exist in the workflow.</exception>
public WorkflowBuilder BindExecutor(Executor executor)
public WorkflowBuilder BindExecutor(ExecutorBinding registration)
{
if (!this._unboundExecutors.Contains(executor.Id))
if (Throw.IfNull(registration) is ExecutorPlaceholder)
{
throw new InvalidOperationException(
$"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow.");
$"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " +
"You must provide a concrete executor instance or registration.");
}
this._executors[executor.Id] = new ExecutorIsh(executor).Registration;
this._unboundExecutors.Remove(executor.Id);
this.Track(registration);
return this;
}
@@ -180,7 +179,7 @@ public class WorkflowBuilder
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
@@ -236,7 +235,7 @@ public class WorkflowBuilder
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? condition = null, bool idempotent = false)
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
{
// Add an edge from source to target with an optional condition.
// This is a low-level builder method that does not enforce any specific executor type.
@@ -273,7 +272,7 @@ public class WorkflowBuilder
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, null, targets);
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
@@ -305,7 +304,7 @@ public class WorkflowBuilder
/// If null, messages will route to all targets.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorBinding> targets)
{
Throw.IfNull(source);
Throw.IfNull(targets);
@@ -339,7 +338,7 @@ public class WorkflowBuilder
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -398,9 +397,9 @@ public class WorkflowBuilder
var workflow = new Workflow(this._startExecutorId, this._name, this._description)
{
Registrations = this._executors,
ExecutorBindings = this._executors,
Edges = this._edges,
Ports = this._inputPorts,
Ports = this._requestPorts,
OutputExecutors = this._outputExecutors
};
@@ -24,7 +24,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
/// <summary>
@@ -38,7 +38,7 @@ public static class WorkflowBuilderExtensions
/// all messages of type <typeparamref name="TMessage"/> will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
@@ -47,7 +47,7 @@ public static class WorkflowBuilderExtensions
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { Count: 1 })
if (executors is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
@@ -68,7 +68,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
@@ -77,7 +77,7 @@ public static class WorkflowBuilderExtensions
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { Count: 1 })
if (executors is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
@@ -102,7 +102,7 @@ public static class WorkflowBuilderExtensions
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -139,7 +139,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor representing the external system or process to connect. Cannot be null.</param>
/// <param name="portId">The unique identifier for the input port that will handle the external call. Cannot be null.</param>
/// <returns>The original workflow builder instance with the external call added.</returns>
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string portId)
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string portId)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -160,7 +160,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor that determines the branching condition for the switch. Cannot be null.</param>
/// <param name="configureSwitch">An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null.</param>
/// <returns>The workflow builder instance with the configured switch step added.</returns>
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action<SwitchBuilder> configureSwitch)
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorBinding source, Action<SwitchBuilder> configureSwitch)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -167,7 +167,7 @@ public class JsonSerializationTests
builder.AddEdge(forwardString, stringToInt)
.AddEdge(stringToInt, forwardInt)
.AddEdge(forwardInt, intToString)
.AddEdge(intToString, StreamingAggregators.Last<int>().AsExecutor("Aggregate"));
.AddEdge(intToString, StreamingAggregators.Last<int>().BindAsExecutor("Aggregate"));
return builder.Build();
}
@@ -2,6 +2,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -38,34 +40,40 @@ public class RepresentationTests
private static RequestPort TestRequestPort =>
RequestPort.Create<FunctionCallContent, FunctionResultContent>("ExternalFunction");
private static async ValueTask RunExecutorishInfoMatchTestAsync(ExecutorIsh target)
private static async ValueTask RunExecutorBindingInfoMatchTestAsync(ExecutorBinding binding)
{
ExecutorRegistration registration = target.Registration;
ExecutorInfo info = registration.ToExecutorInfo();
ExecutorInfo info = binding.ToExecutorInfo();
info.IsMatch(await registration.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
info.IsMatch(await binding.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
}
[Fact]
public async Task Test_Executorish_InfosAsync()
public async Task Test_ExecutorBinding_InfosAsync()
{
int testsRun = 0;
await RunExecutorishTestAsync(new TestExecutor());
await RunExecutorishTestAsync(TestRequestPort);
await RunExecutorishTestAsync(new TestAgent());
await RunExecutorishTestAsync(Step1EntryPoint.WorkflowInstance.ConfigureSubWorkflow(nameof(Step1EntryPoint)));
await RunExecutorBindingTestAsync(new TestExecutor());
await RunExecutorBindingTestAsync(TestRequestPort);
await RunExecutorBindingTestAsync(new TestAgent());
await RunExecutorBindingTestAsync(Step1EntryPoint.WorkflowInstance.BindAsExecutor(nameof(Step1EntryPoint)));
Func<int, IWorkflowContext, CancellationToken, ValueTask> function = MessageHandlerAsync;
await RunExecutorishTestAsync(function.AsExecutor("FunctionExecutor"));
await RunExecutorBindingTestAsync(function.BindAsExecutor("FunctionExecutor"));
if (Enum.GetValues(typeof(ExecutorIsh.Type)).Length > testsRun + 1)
Type bindingBaseType = typeof(ExecutorBinding);
Assembly workflowAssembly = bindingBaseType.Assembly;
int expectedTests = workflowAssembly.GetTypes()
.Count(type => type != bindingBaseType
&& bindingBaseType.IsAssignableFrom(type));
expectedTests.Should().BePositive();
if (expectedTests > testsRun + 1)
{
Assert.Fail("Not all ExecutorIsh types were tested.");
Assert.Fail("Not all ExecutorBinding types were tested.");
}
async ValueTask RunExecutorishTestAsync(ExecutorIsh executorish)
async ValueTask RunExecutorBindingTestAsync(ExecutorBinding binding)
{
await RunExecutorishInfoMatchTestAsync(executorish);
await RunExecutorBindingInfoMatchTestAsync(binding);
testsRun++;
}
@@ -77,8 +85,8 @@ public class RepresentationTests
[Fact]
public async Task Test_SpecializedExecutor_InfosAsync()
{
await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
}
private static string Source(int id) => $"Source/{id}";
@@ -11,21 +11,23 @@ internal static class Step7EntryPoint
public static string EchoAgentId => Step6EntryPoint.EchoAgentId;
public static string EchoPrefix => Step6EntryPoint.EchoPrefix;
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2)
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2, int numIterations = 2)
{
Workflow workflow = Step6EntryPoint.CreateWorkflow(maxSteps);
AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent");
AgentThread thread = agent.GetNewThread();
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false))
for (int i = 0; i < numIterations; i++)
{
string updateText = $"{update.AuthorName
?? update.AgentId
?? update.Role.ToString()
?? ChatRole.Assistant.ToString()}: {update.Text}";
writer.WriteLine(updateText);
AgentThread thread = agent.GetNewThread();
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false))
{
string updateText = $"{update.AuthorName
?? update.AgentId
?? update.Role.ToString()
?? ChatRole.Assistant.ToString()}: {update.Text}";
writer.WriteLine(updateText);
}
}
}
}
@@ -29,13 +29,13 @@ internal static class Step8EntryPoint
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List<string> textsToProcess)
{
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> processTextAsyncFunc = ProcessTextAsync;
ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true);
ExecutorBinding processText = processTextAsyncFunc.BindAsExecutor("TextProcessor", threadsafe: true);
Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor");
ExecutorBinding textProcessor = subWorkflow.BindAsExecutor("TextProcessor");
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id));
var orchestrator = createOrchestrator.ConfigureFactory();
var orchestrator = createOrchestrator.BindExecutor();
Workflow workflow = new WorkflowBuilder(orchestrator)
.AddEdge(orchestrator, textProcessor)
@@ -62,7 +62,7 @@ internal sealed record class RequestFinished(string Id, string RequestType, Reso
internal static class Step9EntryPoint
{
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh filter, string? id = null)
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding filter, string? id = null)
{
id ??= typeof(TRequest).Name;
@@ -74,10 +74,10 @@ internal static class Step9EntryPoint
.ForwardMessage<ExternalResponse>(filter, executors: [source], condition: message => message.DataIs<TResponse>());
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string? id = null)
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string? id = null)
=> builder.AddExternalRequest(source, out RequestPort<TRequest, TResponse> _, id);
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
{
id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
@@ -86,7 +86,7 @@ internal static class Step9EntryPoint
return builder.AddExternalRequest(source, inputPort);
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, RequestPort<TRequest, TResponse> inputPort)
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, RequestPort<TRequest, TResponse> inputPort)
{
return builder.ForwardMessage<TRequest>(source, inputPort)
.ForwardMessage<ExternalRequest>(source, inputPort)
@@ -110,7 +110,7 @@ internal static class Step9EntryPoint
Coordinator coordinator = new();
ResourceCache cache = new();
QuotaPolicyEngine policyEngine = new();
ExecutorIsh subworkflow = CreateSubWorkflow().ConfigureSubWorkflow("ResourceWorkflow");
ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow");
return new WorkflowBuilder(coordinator)
.AddChain(coordinator, allowRepetition: true, subworkflow, coordinator)
@@ -522,4 +522,26 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCross
return state + requests.Count;
}
}
internal async ValueTask RunWorkflowHandleEventsAsync<TInput>(Workflow workflow, TInput input) where TInput : notnull
{
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case ExecutorInvokedEvent invoked:
Console.WriteLine($"Executor invoked: {invoked.ExecutorId}");
break;
case ExecutorCompletedEvent completed:
Console.WriteLine($"Executor completed: {completed.ExecutorId}");
break;
// Other event types can be handled here as needed
default:
break;
}
}
}
}
@@ -212,6 +212,8 @@ public class SampleSmokeTest
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
Assert.Collection(lines,
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line),
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line)
);
@@ -30,9 +30,9 @@ public partial class WorkflowBuilderSmokeTests
workflow.StartExecutorId.Should().Be("start");
workflow.Registrations.Should().HaveCount(1);
workflow.Registrations.Should().ContainKey("start");
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
@@ -45,9 +45,9 @@ public partial class WorkflowBuilderSmokeTests
workflow.StartExecutorId.Should().Be("start");
workflow.Registrations.Should().HaveCount(1);
workflow.Registrations.Should().ContainKey("start");
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
@@ -77,9 +77,9 @@ public partial class WorkflowBuilderSmokeTests
workflow.StartExecutorId.Should().Be("start");
workflow.Registrations.Should().HaveCount(1);
workflow.Registrations.Should().ContainKey("start");
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]