From a75c93cedc2fa1b9b703d4e56303d384591e68b7 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 02:16:33 +0200 Subject: [PATCH 1/9] adds support for labels in edges, fixes rendering of labels in dot and mermaid, adds rendering of labels in edges --- .../WorkflowAsAnAgent/WorkflowHelper.cs | 4 +- .../Concurrent/Concurrent/Program.cs | 4 +- .../Workflows/Concurrent/MapReduce/Program.cs | 8 +-- .../Workflows/SharedStates/Program.cs | 4 +- .../01_ExecutorsAndEdges/Program.cs | 2 +- .../AgentWorkflowBuilder.cs | 2 +- .../DirectEdgeData.cs | 8 ++- .../FanInEdgeData.cs | 8 ++- .../FanOutEdgeData.cs | 8 ++- .../SwitchBuilder.cs | 2 +- .../Visualization/WorkflowVisualizer.cs | 50 +++++++++++++++---- .../WorkflowBuilder.cs | 27 ++++++---- .../EdgeMapSmokeTests.cs | 2 +- .../EdgeRunnerTests.cs | 2 +- .../InProcessStateTests.cs | 2 +- .../JsonSerializationTests.cs | 2 +- .../RepresentationTests.cs | 10 ++-- .../WorkflowVisualizerTests.cs | 18 +++---- 18 files changed, 109 insertions(+), 54 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs index 82ebffa050..0caeaeb537 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs @@ -23,8 +23,8 @@ internal static ValueTask>> GetWorkflowAsync(IChatCli // 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, null, targets: [frenchAgent, englishAgent]) + .AddFanInEdge(aggregationExecutor, null, sources: [frenchAgent, englishAgent]) .WithOutputFrom(aggregationExecutor) .BuildAsync>(); } diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index 30b2372006..c5133a5c29 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -52,8 +52,8 @@ private static async Task Main() // 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, "fan-out", targets: [physicist, chemist]) + .AddFanInEdge(aggregationExecutor, "fan-in", sources: [physicist, chemist]) .WithOutputFrom(aggregationExecutor) .Build(); diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs index db10f0e8af..529e4a6b25 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs @@ -62,10 +62,10 @@ public static Workflow BuildWorkflow() // 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, "Splitting", targets: [.. mappers]) // Split -> many mappers + .AddFanInEdge(shuffler, "fan-in", sources: [.. mappers]) // All mappers -> shuffle + .AddFanOutEdge(shuffler, "Shuffling", targets: [.. reducers]) // Shuffle -> many reducers + .AddFanInEdge(completion, "fan-in", sources: [.. reducers]) // All reducers -> completion .WithOutputFrom(completion) .Build(); } diff --git a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs index 6f4cfdf38b..99c14d57c2 100644 --- a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs @@ -26,8 +26,8 @@ private static async Task Main() // Build the workflow by connecting executors sequentially var workflow = new WorkflowBuilder(fileRead) - .AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount]) - .AddFanInEdge(aggregate, sources: [wordCount, paragraphCount]) + .AddFanOutEdge(fileRead, null, targets: [wordCount, paragraphCount]) + .AddFanInEdge(aggregate, null, sources: [wordCount, paragraphCount]) .WithOutputFrom(aggregate) .Build(); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index 68e2effd04..3c2a603d0a 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -25,7 +25,7 @@ private static async Task Main() // Build the workflow by connecting executors sequentially WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + builder.AddEdge(uppercase, reverse, false, "custom label").WithOutputFrom(reverse); var workflow = builder.Build(); // Execute the workflow with input data diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 907d10fe60..cd96c06567 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -126,7 +126,7 @@ private static Workflow BuildConcurrentCore( // 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}")]; - builder.AddFanOutEdge(start, targets: agentExecutors); + builder.AddFanOutEdge(start, null, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { builder.AddEdge(agentExecutors[i], accumulators[i]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs index 2119bd775b..6be4adcffb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs @@ -11,11 +11,12 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class DirectEdgeData : EdgeData { - internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id) + internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id) { this.SourceId = sourceId; this.SinkId = sinkId; this.Condition = condition; + this.Label = label; this.Connection = new([sourceId], [sinkId]); } @@ -35,6 +36,11 @@ internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? c /// public PredicateT? Condition { get; } + /// + /// An optional label for the edge, allowing for arbitrary metadata to be associated with it. + /// + public string? Label { get; } + /// internal override EdgeConnection Connection { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs index 0cb2b38378..42d7e0d8e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs @@ -10,11 +10,12 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanInEdgeData : EdgeData { - internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id) : base(id) + internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id, string? label) : base(id) { this.SourceIds = sourceIds; this.SinkId = sinkId; this.Connection = new(sourceIds, [sinkId]); + this.Label = label; } /// @@ -27,6 +28,11 @@ internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id) : base( /// public string SinkId { get; } + /// + /// Optional label for the edge. + /// + public string? Label { get; init; } + /// internal override EdgeConnection Connection { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs index 9d9ddf4cea..04a6a853f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs @@ -13,12 +13,13 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanOutEdgeData : EdgeData { - internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId) + internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId) { this.SourceId = sourceId; this.SinkIds = sinkIds; this.EdgeAssigner = assigner; this.Connection = new([sourceId], sinkIds); + this.Label = label; } /// @@ -37,6 +38,11 @@ internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, As /// public AssignerF? EdgeAssigner { get; } + /// + /// An optional label for the edge. + /// + public string? Label { get; } + /// internal override EdgeConnection Connection { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 306acd4b4e..e121b769c5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -84,7 +84,7 @@ internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh sou List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; HashSet defaultIndicies = this._defaultIndicies; - return builder.AddFanOutEdge(source, CasePartitioner, [.. this._executors]); + return builder.AddFanOutEdge(source, CasePartitioner, null, [.. this._executors]); IEnumerable CasePartitioner(object? input, int targetCount) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index bd28801352..7cf243c3ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -99,10 +99,26 @@ private static void EmitWorkflowDigraph(Workflow workflow, List lines, s } // Emit normal edges - foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow)) + foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow)) { - var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : ""; - lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};"); + // Build edge attributes + var attributes = new List(); + + // Add style for conditional edges + if (isConditional) + { + attributes.Add("style=dashed"); + } + + // Add label (custom label or default "conditional" for conditional edges) + if (label != null) + { + attributes.Add($"label=\"{EscapeDotLabel(label)}\""); + } + + // Combine attributes + var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : ""; + lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};"); } } @@ -175,14 +191,21 @@ string sanitize(string input) } // Emit normal edges - foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow)) + foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow)) { - if (isConditional) + if (label != null) + { + // Regular edge with label + lines.Add($"{indent}{MapId(src)} -->|{label}| {MapId(target)};"); + } + else if (isConditional) { - lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};"); + // Conditional edge with default label + lines.Add($"{indent}{MapId(src)} -->|conditional| {MapId(target)};"); } else { + // Regular edge without label lines.Add($"{indent}{MapId(src)} --> {MapId(target)};"); } } @@ -214,9 +237,9 @@ string sanitize(string input) return result; } - private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow) + private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow) { - var edges = new List<(string, string, bool)>(); + var edges = new List<(string, string, bool, string?)>(); foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x)) { if (edgeGroup.Kind == EdgeKind.FanIn) @@ -229,14 +252,15 @@ string sanitize(string input) case EdgeKind.Direct when edgeGroup.DirectEdgeData != null: var directData = edgeGroup.DirectEdgeData; var isConditional = directData.Condition != null; - edges.Add((directData.SourceId, directData.SinkId, isConditional)); + var label = directData.Label ?? (isConditional ? "conditional" : null); + edges.Add((directData.SourceId, directData.SinkId, isConditional, label)); break; case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null: var fanOutData = edgeGroup.FanOutEdgeData; foreach (var sinkId in fanOutData.SinkIds) { - edges.Add((fanOutData.SourceId, sinkId, false)); + edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label)); } break; } @@ -276,5 +300,11 @@ private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [Not return false; } + // Helper method to escape special characters in DOT labels + private static string EscapeDotLabel(string label) + { + return label.Replace("\"", "\\\"").Replace("\n", "\\n"); + } + #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 94e4f20594..ee66945efa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -178,11 +178,12 @@ private HashSet EnsureEdgesFor(string sourceId) /// The executor that acts as the target node of the edge. Cannot be null. /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. + /// /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false) - => this.AddEdge(source, target, null, idempotent); + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false, string? label = null) + => this.AddEdge(source, target, null, idempotent, label); internal static Func? CreateConditionFunc(Func? condition) { @@ -234,10 +235,11 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idem /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. /// If null, the edge is always activated when the source sends a message. + /// /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null, bool idempotent = false) + public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func? condition = null, bool idempotent = false, string? label = null) { // 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. @@ -258,7 +260,7 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func(ExecutorIsh source, ExecutorIsh target, FuncIf 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. /// The source executor from which the fan-out edge originates. Cannot be null. + /// /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) - => this.AddFanOutEdge(source, null, targets); + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params IEnumerable targets) + => this.AddFanOutEdge(source, null, label, targets); internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) { @@ -304,9 +307,10 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerableThe source executor from which the fan-out edge originates. Cannot be null. /// An optional function that determines how input is partitioned among the target executors. /// If null, messages will route to all targets. + /// /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, params IEnumerable targets) + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, string? label = null, params IEnumerable targets) { Throw.IfNull(source); Throw.IfNull(targets); @@ -323,7 +327,8 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func(ExecutorIsh source, Func /// The target executor that receives input from the specified source executors. Cannot be null. + /// /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable sources) + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, string? label = null, params IEnumerable sources) { Throw.IfNull(target); Throw.IfNull(sources); @@ -356,7 +362,8 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable> workflowEdges = []; - FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0)); + FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null); Edge fanInEdge = new(edgeData); workflowEdges["executor1"] = [fanInEdge]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs index 4239b6ef6d..e080086818 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs @@ -155,7 +155,7 @@ public async Task Test_FanInEdgeRunnerAsync() runContext.Executors["executor2"] = new ForwardMessageExecutor("executor2"); runContext.Executors["executor3"] = new ForwardMessageExecutor("executor3"); - FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0)); + FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), "custom label"); FanInEdgeRunner runner = new(runContext, edgeData); // Step 1: Send message from executor1, should not forward yet. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs index 014c51b3c0..8353086dea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs @@ -160,7 +160,7 @@ public async Task InProcessRun_StateShouldError_TwoExecutorsAsync() Workflow workflow = new WorkflowBuilder(forward) - .AddFanOutEdge(forward, targets: [testExecutor, testExecutor2]) + .AddFanOutEdge(forward, null, targets: [testExecutor, testExecutor2]) .Build(); Run runWithFailure = await InProcessExecution.RunAsync(workflow, new TurnToken()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index cd0f910ddb..ce1f166247 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -118,7 +118,7 @@ public void Test_FanOutEdgeInfo_JsonRoundtrip() RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator()); } - private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId()); + private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null); private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData); [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs index 85f5baee6a..48033bd447 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs @@ -129,17 +129,17 @@ public void Test_EdgeInfos() RunEdgeInfoMatchTest(fanOutEdgeWithAssigner); // FanIn Edges - Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId())); + Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge); - Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId())); + Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge2); - Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId())); + Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge3, expect: false); // Order matters (though for FanIn maybe it shouldn't?) - Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId())); - Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId())); + Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId(), null)); + Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge4, expect: false); // Identity matters RunEdgeInfoMatchTest(fanInEdge, fanInEdge5, expect: false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs index 441dd28439..3de68d21e7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs @@ -111,8 +111,8 @@ public void Test_WorkflowViz_FanIn_EdgeGroup() // 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, null, s1, s2) + .AddFanInEdge(t, null, s1, s2) // AddFanInEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); @@ -174,7 +174,7 @@ public void Test_WorkflowViz_FanOut_Edges() var target3 = new MockExecutor("target3"); var workflow = new WorkflowBuilder("start") - .AddFanOutEdge(start, target1, target2, target3) + .AddFanOutEdge(start, null, target1, target2, target3) .Build(); var dotContent = workflow.ToDotString(); @@ -199,8 +199,8 @@ public void Test_WorkflowViz_Mixed_EdgeTypes() var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge - .AddFanOutEdge(a, b, c) // Fan-out - .AddFanInEdge(end, b, c) // Fan-in - AddFanInEdge(target, sources) + .AddFanOutEdge(a, null, b, c) // Fan-out + .AddFanInEdge(end, null, b, c) // Fan-in - AddFanInEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); @@ -307,8 +307,8 @@ public void Test_WorkflowViz_Mermaid_FanIn_EdgeGroup() var t = new ListStrTargetExecutor("t"); var workflow = new WorkflowBuilder("start") - .AddFanOutEdge(start, s1, s2) - .AddFanInEdge(t, s1, s2) + .AddFanOutEdge(start, null, s1, s2) + .AddFanInEdge(t, null, s1, s2) .Build(); var mermaidContent = workflow.ToMermaidString(); @@ -378,8 +378,8 @@ public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes() var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge - .AddFanOutEdge(a, b, c) // Fan-out - .AddFanInEdge(end, b, c) // Fan-in + .AddFanOutEdge(a, null, b, c) // Fan-out + .AddFanInEdge(end, null, b, c) // Fan-in .Build(); var mermaidContent = workflow.ToMermaidString(); From e6c26a08a259fe978446a47b3ea54582ccc40dca Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 19:16:07 +0200 Subject: [PATCH 2/9] Update dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index ee66945efa..4afad99acb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -277,6 +277,8 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) + => this.AddFanOutEdge(source, null, targets); public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params IEnumerable targets) => this.AddFanOutEdge(source, null, label, targets); From eac37f91b6bc007009d07d872f0a9b622b507aa2 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 19:16:32 +0200 Subject: [PATCH 3/9] Update dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 4afad99acb..8edb5f4d64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -348,6 +348,11 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . + // Overload for backward compatibility: original signature without label + public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable sources) + { + return AddFanInEdge(target, null, sources); + } public WorkflowBuilder AddFanInEdge(ExecutorIsh target, string? label = null, params IEnumerable sources) { Throw.IfNull(target); From 22c754d6df31b4043643aec6e7d04196529a8ab0 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 19:18:02 +0200 Subject: [PATCH 4/9] Update dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Visualization/WorkflowVisualizer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index 7cf243c3ed..f8900eb59f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -252,7 +252,7 @@ string sanitize(string input) case EdgeKind.Direct when edgeGroup.DirectEdgeData != null: var directData = edgeGroup.DirectEdgeData; var isConditional = directData.Condition != null; - var label = directData.Label ?? (isConditional ? "conditional" : null); + var label = directData.Label; edges.Add((directData.SourceId, directData.SinkId, isConditional, label)); break; From 2b5ddf1eda3a85451030f9605cb6156b6bb5bea0 Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 19:46:03 +0200 Subject: [PATCH 5/9] escaping edge labels, adding tests for labels containing strange characters that would break the diagram and enabling the previous signature so the API has backwards compatibility. --- .../Visualization/WorkflowVisualizer.cs | 17 +++++- .../WorkflowBuilder.cs | 28 +++++++++- .../WorkflowVisualizerTests.cs | 56 +++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index f8900eb59f..f507aa64ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -196,11 +196,11 @@ string sanitize(string input) if (label != null) { // Regular edge with label - lines.Add($"{indent}{MapId(src)} -->|{label}| {MapId(target)};"); + lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};"); } else if (isConditional) { - // Conditional edge with default label + // Conditional edge with default label (no escaping needed for literal) lines.Add($"{indent}{MapId(src)} -->|conditional| {MapId(target)};"); } else @@ -306,5 +306,18 @@ private static string EscapeDotLabel(string label) return label.Replace("\"", "\\\"").Replace("\n", "\\n"); } + // Helper method to escape special characters in Mermaid labels + private static string EscapeMermaidLabel(string label) + { + return label + .Replace("&", "&") // Must be first to avoid double-escaping + .Replace("|", "|") // Pipe breaks Mermaid delimiter syntax + .Replace("\"", """) // Quote character + .Replace("<", "<") // Less than + .Replace(">", ">") // Greater than + .Replace("\n", "
") // Newline to HTML break + .Replace("\r", ""); // Remove carriage return + } + #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 8edb5f4d64..f90af2fcbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -274,11 +274,21 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, FuncIf 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. /// The source executor from which the fan-out edge originates. Cannot be null. - /// /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) => this.AddFanOutEdge(source, null, targets); + + /// + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. + /// + /// 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. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params IEnumerable targets) => this.AddFanOutEdge(source, null, label, targets); @@ -345,14 +355,26 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func /// The target executor that receives input from the specified source executors. Cannot be null. - /// /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . // Overload for backward compatibility: original signature without label public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable sources) { - return AddFanInEdge(target, null, sources); + return this.AddFanInEdge(target, null, sources); } + + /// + /// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an + /// optional trigger condition. + /// + /// 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. + /// The target executor that receives input from the specified source executors. Cannot be null. + /// + /// One or more source executors that provide input to the target. Cannot be null or empty. + /// The current instance of . + // Overload for backward compatibility: original signature without label public WorkflowBuilder AddFanInEdge(ExecutorIsh target, string? label = null, params IEnumerable sources) { Throw.IfNull(target); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs index 3de68d21e7..2a39b16376 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs @@ -394,4 +394,60 @@ public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes() // Check fan-in (should have intermediate node) mermaidContent.Should().Contain("((fan-in))"); } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe() + { + // Test that pipe characters in labels are properly escaped + var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "High | Low Priority") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should escape pipe character + mermaidContent.Should().Contain("start -->|High | Low Priority| end"); + // Should not contain unescaped pipe that would break syntax + mermaidContent.Should().NotContain("-->|High | Low"); + } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars() + { + // Test that special characters are properly escaped + var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "Score >= 90 & < 100") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should escape special characters + mermaidContent.Should().Contain("&"); + mermaidContent.Should().Contain(">"); + mermaidContent.Should().Contain("<"); + } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline() + { + // Test that newlines are converted to
+ var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "Line 1\nLine 2") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should convert newline to
+ mermaidContent.Should().Contain("Line 1
Line 2"); + mermaidContent.Should().NotContain("\n"); + } } From f499fb3201f90e9bfec0b80d48ae2c6a533337bc Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Thu, 16 Oct 2025 19:55:04 +0200 Subject: [PATCH 6/9] XML documentation minor fix --- .../WorkflowBuilder.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index f90af2fcbd..959afb69b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -178,7 +178,8 @@ private HashSet EnsureEdgesFor(string sourceId) /// The executor that acts as the target node of the edge. Cannot be null. /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. - /// + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. @@ -232,10 +233,11 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idem /// The executor that acts as the source node of the edge. Cannot be null. /// The executor that acts as the target node of the edge. Cannot be null. /// An optional predicate that determines whether the edge should be followed based on the input. + /// If null, the edge is always activated when the source sends a message. /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. - /// If null, the edge is always activated when the source sends a message. - /// + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. @@ -286,7 +288,8 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerableIf 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. /// The source executor from which the fan-out edge originates. Cannot be null. - /// + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params IEnumerable targets) @@ -319,7 +322,8 @@ public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params I /// The source executor from which the fan-out edge originates. Cannot be null. /// An optional function that determines how input is partitioned among the target executors. /// If null, messages will route to all targets. - /// + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, Func>? partitioner = null, string? label = null, params IEnumerable targets) @@ -371,7 +375,8 @@ public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable /// The target executor that receives input from the specified source executors. Cannot be null. - /// + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . // Overload for backward compatibility: original signature without label From 1767091babf8032e6fec06a6e862245b5a1fa00d Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Mon, 20 Oct 2025 20:34:39 +0200 Subject: [PATCH 7/9] minor improvements --- .../Agents/WorkflowAsAnAgent/WorkflowHelper.cs | 4 ++-- .../WorkflowBuilder.cs | 16 +--------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs index 0caeaeb537..82ebffa050 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs @@ -23,8 +23,8 @@ internal static ValueTask>> GetWorkflowAsync(IChatCli // Build the workflow by adding executors and connecting them return new WorkflowBuilder(startExecutor) - .AddFanOutEdge(startExecutor, null, targets: [frenchAgent, englishAgent]) - .AddFanInEdge(aggregationExecutor, null, sources: [frenchAgent, englishAgent]) + .AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent]) + .AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent]) .WithOutputFrom(aggregationExecutor) .BuildAsync>(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 959afb69b7..54e951ec2c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -279,21 +279,7 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, FuncOne or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable targets) - => this.AddFanOutEdge(source, null, targets); - - /// - /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a - /// custom partitioning function. - /// - /// 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. - /// The source executor from which the fan-out edge originates. Cannot be null. - /// An optional text label to describe the edge. This label will appear in workflow visualizations - /// (DOT and Mermaid formats) to document the purpose or condition of the edge. - /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. - /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label, params IEnumerable targets) - => this.AddFanOutEdge(source, null, label, targets); + => this.AddFanOutEdge(source, null, null, targets); internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) { From 4824d58f19f5f3e0859fbef95b7609523f7b512c Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Sun, 2 Nov 2025 14:34:12 +0100 Subject: [PATCH 8/9] Unify label in EdgeData --- .../src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs | 8 +------- dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs | 8 +++++++- dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs | 8 +------- .../src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs | 8 +------- 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs index 6be4adcffb..7d61c939cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs @@ -11,12 +11,11 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class DirectEdgeData : EdgeData { - internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id) + internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label) { this.SourceId = sourceId; this.SinkId = sinkId; this.Condition = condition; - this.Label = label; this.Connection = new([sourceId], [sinkId]); } @@ -36,11 +35,6 @@ internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? c /// public PredicateT? Condition { get; } - /// - /// An optional label for the edge, allowing for arbitrary metadata to be associated with it. - /// - public string? Label { get; } - /// internal override EdgeConnection Connection { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs index 7771b3966e..570bc79bc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs @@ -14,10 +14,16 @@ public abstract class EdgeData /// internal abstract EdgeConnection Connection { get; } - internal EdgeData(EdgeId id) + internal EdgeData(EdgeId id, string? label = null) { this.Id = id; + this.Label = label; } internal EdgeId Id { get; } + + /// + /// An optional label for the edge, allowing for arbitrary metadata to be associated with it. + /// + public string? Label { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs index 42d7e0d8e8..1132fca334 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs @@ -10,12 +10,11 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanInEdgeData : EdgeData { - internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id, string? label) : base(id) + internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id, string? label) : base(id, label) { this.SourceIds = sourceIds; this.SinkId = sinkId; this.Connection = new(sourceIds, [sinkId]); - this.Label = label; } /// @@ -28,11 +27,6 @@ internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id, string? /// public string SinkId { get; } - /// - /// Optional label for the edge. - /// - public string? Label { get; init; } - /// internal override EdgeConnection Connection { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs index 04a6a853f9..86a940c1b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs @@ -13,13 +13,12 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanOutEdgeData : EdgeData { - internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId) + internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label) { this.SourceId = sourceId; this.SinkIds = sinkIds; this.EdgeAssigner = assigner; this.Connection = new([sourceId], sinkIds); - this.Label = label; } /// @@ -38,11 +37,6 @@ internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, As /// public AssignerF? EdgeAssigner { get; } - /// - /// An optional label for the edge. - /// - public string? Label { get; } - /// internal override EdgeConnection Connection { get; } } From a3c406f066c8556e210b221eff88274cd20e567c Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Sun, 2 Nov 2025 18:12:47 +0100 Subject: [PATCH 9/9] Edge API adjustments, removed useless "sanitizer" --- .../AgentWorkflowBuilder.cs | 2 +- .../Visualization/WorkflowVisualizer.cs | 7 +------ .../WorkflowBuilder.cs | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index 3d995702e6..8b527d5c44 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -127,7 +127,7 @@ private static Workflow BuildConcurrentCore( // 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}")]; - builder.AddFanOutEdge(start, null, targets: agentExecutors); + builder.AddFanOutEdge(start, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { builder.AddEdge(agentExecutors[i], accumulators[i]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index 39db8fbf35..44e081f29c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -149,12 +149,7 @@ private static void EmitSubWorkflowsDigraph(Workflow workflow, List line private static void EmitWorkflowMermaid(Workflow workflow, List lines, string indent, string? ns = null) { - string sanitize(string input) - { - return input; - } - - string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id; + string MapId(string id) => ns != null ? $"{ns}/{id}" : id; // Add start node var startExecutorId = workflow.StartExecutorId; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 63de0f8000..c207e08820 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -280,6 +280,20 @@ public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, Func targets) => this.AddFanOutEdge(source, null, null, targets); + /// + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. + /// + /// 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. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . + public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, string? label = null, params IEnumerable targets) + => this.AddFanOutEdge(source, null, label, targets); + internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) { if (partitioner is null)