.NET: [BREAKING] refactor: Normalize WorkflowBuilder APIs (#1935)

* [BREAKING] refactor: Normalize WorkflowBuilder APIs

* "partitioner" => "assigner"
* normalize ordering so sources always to the left of targets for edges
* normalize parameter ordering so sources and targets are always first arguments
* remove `params` (users should use collection expressions instead)

* refactor: Align name with Python
This commit is contained in:
Jacob Alber
2025-11-05 19:50:01 +00:00
committed by GitHub
parent 33f84f9ed2
commit 94a5ba3448
12 changed files with 96 additions and 67 deletions
@@ -127,7 +127,7 @@ public static partial class AgentWorkflowBuilder
// provenance tracking exposed in the workflow context passed to a handler.
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);
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
@@ -143,7 +143,7 @@ public static partial class AgentWorkflowBuilder
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInEdge(end, sources: accumulators);
builder.AddFanInEdge(accumulators, end);
builder = builder.WithOutputFrom(end);
if (workflowName is not null)
@@ -84,7 +84,7 @@ public sealed class SwitchBuilder
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
HashSet<int> defaultIndicies = this._defaultIndicies;
return builder.AddFanOutEdge<object>(source, CasePartitioner, [.. this._executors]);
return builder.AddFanOutEdge<object>(source, this._executors, CasePartitioner);
IEnumerable<int> CasePartitioner(object? input, int targetCount)
{
@@ -272,12 +272,12 @@ 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(ExecutorBinding source, params IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, null, targets);
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, targets, null);
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
{
if (partitioner is null)
if (targetAssigner is null)
{
return null;
}
@@ -289,7 +289,7 @@ public class WorkflowBuilder
maybeObj = portableValue.AsType(typeof(T));
}
return partitioner(maybeObj is T typed ? typed : default, count);
return targetAssigner(maybeObj is T typed ? typed : default, count);
};
}
@@ -300,11 +300,11 @@ public class WorkflowBuilder
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="partitioner">An optional function that determines how input is partitioned among the target executors.
/// 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>(ExecutorBinding source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorBinding> targets)
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
{
Throw.IfNull(source);
Throw.IfNull(targets);
@@ -321,7 +321,7 @@ public class WorkflowBuilder
this.Track(source).Id,
sinkIds,
this.TakeEdgeId(),
CreateEdgeAssignerFunc(partitioner));
CreateTargetAssignerFunc(targetSelector));
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
@@ -335,10 +335,10 @@ public class WorkflowBuilder
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
/// behavior.</remarks>
/// <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>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -364,8 +364,14 @@ public class WorkflowBuilder
return this;
}
/// <inheritdoc cref="AddFanInEdge(IEnumerable{ExecutorBinding}, ExecutorBinding)"/>
[Obsolete("Use AddFanInEdge(IEnumerable<ExecutorBinding>, ExecutorBinding) instead.")]
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
=> this.AddFanInEdge(sources, target);
private void Validate()
{
// Validate that there are no unbound executors
if (this._unboundExecutors.Count > 0)
{
throw new InvalidOperationException(
@@ -22,10 +22,10 @@ public static class WorkflowBuilderExtensions
/// <typeparam name="TMessage">The type of message to forward.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> to which the edges will be added.</param>
/// <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>
/// <param name="target">The target executor to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardMessage<TMessage>(source, [target], condition: null);
/// <summary>
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
@@ -34,26 +34,38 @@ public static class WorkflowBuilderExtensions
/// <typeparam name="TMessage">The type of message to forward.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> to which the edges will be added.</param>
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="targets">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, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
=> builder.ForwardMessage<TMessage>(source, targets, condition: null);
/// <summary>
/// Adds edges to the workflow that forward messages of the specified type from the source executor to
/// one or more target executors.
/// </summary>
/// <typeparam name="TMessage">The type of message to forward.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> to which the edges will be added.</param>
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="targets">The target executors to which messages will be forwarded.</param>
/// <param name="condition">An optional condition that messages must satisfy to be forwarded. If <see langword="null"/>,
/// 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, ExecutorBinding source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorBinding> executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<TMessage, bool>? condition = null)
{
Throw.IfNull(executors);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>(IsAllowedTypeAndMatchingCondition)!;
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
if (targets.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorBinding> { Count: 1 })
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
return builder.AddEdge(source, targets.First(), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
// The reason we can check for "not null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
@@ -66,24 +78,35 @@ public static class WorkflowBuilderExtensions
/// <typeparam name="TMessage">The type of messages to exclude from being forwarded to the executors.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> instance to which the edges will be added.</param>
/// <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>
/// <param name="target">The target executor 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, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target)
=> builder.ForwardExcept<TMessage>(source, [target]);
/// <summary>
/// Adds edges from the specified source to the provided executors, excluding messages of a specified type.
/// </summary>
/// <typeparam name="TMessage">The type of messages to exclude from being forwarded to the executors.</typeparam>
/// <param name="builder">The <see cref="WorkflowBuilder"/> instance to which the edges will be added.</param>
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="targets">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, ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
{
Throw.IfNull(executors);
Throw.IfNull(targets);
Func<object?, bool> predicate = WorkflowBuilder.CreateConditionFunc<TMessage>((Func<object?, bool>)IsAllowedType)!;
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
if (targets.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorBinding> { Count: 1 })
if (targets is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
return builder.AddEdge(source, targets.First(), predicate);
}
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, executors));
return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets));
// The reason we can check for "null" here is that CreateConditionFunc<T> will do the correct unwrapping
// logic for PortableValues.
@@ -98,11 +121,11 @@ public static class WorkflowBuilderExtensions
/// executor in the order provided.</remarks>
/// <param name="builder">The workflow builder to which the executor chain will be added. </param>
/// <param name="source">The initial executor in the chain. Cannot be null.</param>
/// <param name="allowRepetition">If set to <see langword="true"/>, the same executor can be added to the chain multiple times.</param>
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
/// <param name="executors">An ordered sequence 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>
/// <param name="allowRepetition">If set to <see langword="true"/>, the same executor can be added to the chain multiple times.</param>
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable<ExecutorBinding> executors)
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, IList<ExecutorBinding> executors, bool allowRepetition = false)
{
Throw.IfNull(builder);
Throw.IfNull(source);