.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 14:50:01 -05:00
committed by GitHub
Unverified
parent 33f84f9ed2
commit 94a5ba3448
12 changed files with 96 additions and 67 deletions
@@ -23,8 +23,8 @@ internal static class WorkflowFactory
// Build the workflow by adding executors and connecting them
return new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
.WithOutputFrom(aggregationExecutor)
.Build();
}
@@ -52,8 +52,8 @@ public static class Program
// Build the workflow by adding executors and connecting them
var workflow = new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [physicist, chemist])
.AddFanInEdge(aggregationExecutor, sources: [physicist, chemist])
.AddFanOutEdge(startExecutor, [physicist, chemist])
.AddFanInEdge([physicist, chemist], aggregationExecutor)
.WithOutputFrom(aggregationExecutor)
.Build();
@@ -62,10 +62,10 @@ public static class Program
// Step 4: Build the concurrent workflow with fan-out/fan-in pattern
return new WorkflowBuilder(splitter)
.AddFanOutEdge(splitter, targets: [.. mappers]) // Split -> many mappers
.AddFanInEdge(shuffler, sources: [.. mappers]) // All mappers -> shuffle
.AddFanOutEdge(shuffler, targets: [.. reducers]) // Shuffle -> many reducers
.AddFanInEdge(completion, sources: [.. reducers]) // All reducers -> completion
.AddFanOutEdge(splitter, [.. mappers]) // Split -> many mappers
.AddFanInEdge([.. mappers], shuffler) // All mappers -> shuffle
.AddFanOutEdge(shuffler, [.. reducers]) // Shuffle -> many reducers
.AddFanInEdge([.. reducers], completion) // All reducers -> completion
.WithOutputFrom(completion)
.Build();
}
@@ -60,13 +60,13 @@ public static class Program
WorkflowBuilder builder = new(emailAnalysisExecutor);
builder.AddFanOutEdge(
emailAnalysisExecutor,
targets: [
[
handleSpamExecutor,
emailAssistantExecutor,
emailSummaryExecutor,
handleUncertainExecutor,
],
partitioner: GetPartitioner()
GetTargetAssigner()
)
// After the email assistant writes a response, it will be sent to the send email executor
.AddEdge(emailAssistantExecutor, sendEmailExecutor)
@@ -105,7 +105,7 @@ public static class Program
/// Creates a partitioner for routing messages based on the analysis result.
/// </summary>
/// <returns>A function that takes an analysis result and returns the target partitions.</returns>
private static Func<AnalysisResult?, int, IEnumerable<int>> GetPartitioner()
private static Func<AnalysisResult?, int, IEnumerable<int>> GetTargetAssigner()
{
return (analysisResult, targetCount) =>
{
@@ -24,8 +24,8 @@ internal static class WorkflowHelper
// Build the workflow by adding executors and connecting them
return new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
.AddFanOutEdge(startExecutor, [frenchAgent, englishAgent])
.AddFanInEdge([frenchAgent, englishAgent], aggregationExecutor)
.WithOutputFrom(aggregationExecutor)
.Build();
}
@@ -26,8 +26,8 @@ public static class Program
// Build the workflow by connecting executors sequentially
var workflow = new WorkflowBuilder(fileRead)
.AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount])
.AddFanInEdge(aggregate, sources: [wordCount, paragraphCount])
.AddFanOutEdge(fileRead, [wordCount, paragraphCount])
.AddFanInEdge([wordCount, paragraphCount], aggregate)
.WithOutputFrom(aggregate)
.Build();
@@ -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);
@@ -68,10 +68,10 @@ internal static class Step9EntryPoint
var requestPort = RequestPort.Create<TRequest, TResponse>(id);
return builder.ForwardMessage<ExternalRequest>(source, executors: [filter], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalRequest>(filter, executors: [requestPort], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalResponse>(requestPort, executors: [filter], condition: message => message.DataIs<TResponse>())
.ForwardMessage<ExternalResponse>(filter, executors: [source], condition: message => message.DataIs<TResponse>());
return builder.ForwardMessage<ExternalRequest>(source, targets: [filter], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalRequest>(filter, targets: [requestPort], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalResponse>(requestPort, targets: [filter], condition: message => message.DataIs<TResponse>())
.ForwardMessage<ExternalResponse>(filter, targets: [source], condition: message => message.DataIs<TResponse>());
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string? id = null)
@@ -88,10 +88,10 @@ internal static class Step9EntryPoint
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)
.ForwardMessage<TResponse>(inputPort, source)
.ForwardMessage<ExternalResponse>(inputPort, source);
return builder.ForwardMessage<TRequest>(source, [inputPort])
.ForwardMessage<ExternalRequest>(source, [inputPort])
.ForwardMessage<TResponse>(inputPort, [source])
.ForwardMessage<ExternalResponse>(inputPort, [source]);
}
public static Workflow CreateSubWorkflow()
@@ -113,7 +113,7 @@ internal static class Step9EntryPoint
ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow");
return new WorkflowBuilder(coordinator)
.AddChain(coordinator, allowRepetition: true, subworkflow, coordinator)
.AddChain(coordinator, [subworkflow, coordinator], allowRepetition: true)
.AddPassthroughRequestHandler<ResourceRequest, ResourceResponse>(subworkflow, cache)
.AddPassthroughRequestHandler<PolicyCheckRequest, PolicyResponse>(subworkflow, policyEngine)
.WithOutputFrom(coordinator)
@@ -111,8 +111,8 @@ public class WorkflowVisualizerTests
// Build a connected workflow: start fans out to s1 and s2, which then fan-in to t
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, s1, s2)
.AddFanInEdge(t, s1, s2) // AddFanInEdge(target, sources)
.AddFanOutEdge(start, [s1, s2])
.AddFanInEdge([s1, s2], t) // AddFanInEdge(target, sources)
.Build();
var dotContent = workflow.ToDotString();
@@ -174,7 +174,7 @@ public class WorkflowVisualizerTests
var target3 = new MockExecutor("target3");
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, target1, target2, target3)
.AddFanOutEdge(start, [target1, target2, target3])
.Build();
var dotContent = workflow.ToDotString();
@@ -199,8 +199,8 @@ public class WorkflowVisualizerTests
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, a, Condition) // Conditional edge
.AddFanOutEdge(a, b, c) // Fan-out
.AddFanInEdge(end, b, c) // Fan-in - AddFanInEdge(target, sources)
.AddFanOutEdge(a, [b, c]) // Fan-out
.AddFanInEdge([b, c], end) // Fan-in - AddFanInEdge(target, sources)
.Build();
var dotContent = workflow.ToDotString();
@@ -307,8 +307,8 @@ public class WorkflowVisualizerTests
var t = new ListStrTargetExecutor("t");
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, s1, s2)
.AddFanInEdge(t, s1, s2)
.AddFanOutEdge(start, [s1, s2])
.AddFanInEdge([s1, s2], t)
.Build();
var mermaidContent = workflow.ToMermaidString();
@@ -378,8 +378,8 @@ public class WorkflowVisualizerTests
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, a, Condition) // Conditional edge
.AddFanOutEdge(a, b, c) // Fan-out
.AddFanInEdge(end, b, c) // Fan-in
.AddFanOutEdge(a, [b, c]) // Fan-out
.AddFanInEdge([b, c], end) // Fan-in
.Build();
var mermaidContent = workflow.ToMermaidString();