Skip to content

Commit eb1117f

Browse files
joslatCopilotlokitothcrickman
authored
.NET: adds support for labels in edges, fixes rendering of labels in dot a… (#1507)
* adds support for labels in edges, fixes rendering of labels in dot and mermaid, adds rendering of labels in edges * Update dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 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. * Unify label in EdgeData * Edge API adjustments, removed useless "sanitizer" * fixed test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jacob Alber <jaalber@microsoft.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
1 parent 16230d3 commit eb1117f

11 files changed

Lines changed: 238 additions & 30 deletions

File tree

dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
1111
/// </summary>
1212
public sealed class DirectEdgeData : EdgeData
1313
{
14-
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id)
14+
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label)
1515
{
1616
this.SourceId = sourceId;
1717
this.SinkId = sinkId;

dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ public abstract class EdgeData
1414
/// </summary>
1515
internal abstract EdgeConnection Connection { get; }
1616

17-
internal EdgeData(EdgeId id)
17+
internal EdgeData(EdgeId id, string? label = null)
1818
{
1919
this.Id = id;
20+
this.Label = label;
2021
}
2122

2223
internal EdgeId Id { get; }
24+
25+
/// <summary>
26+
/// An optional label for the edge, allowing for arbitrary metadata to be associated with it.
27+
/// </summary>
28+
public string? Label { get; }
2329
}

dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows;
1010
/// </summary>
1111
internal sealed class FanInEdgeData : EdgeData
1212
{
13-
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id) : base(id)
13+
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id, string? label) : base(id, label)
1414
{
1515
this.SourceIds = sourceIds;
1616
this.SinkId = sinkId;

dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
1313
/// </summary>
1414
internal sealed class FanOutEdgeData : EdgeData
1515
{
16-
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId)
16+
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label)
1717
{
1818
this.SourceId = sourceId;
1919
this.SinkIds = sinkIds;

dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,30 @@ private static void EmitWorkflowDigraph(Workflow workflow, List<string> lines, s
9999
}
100100

101101
// Emit normal edges
102-
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
102+
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
103103
{
104-
var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : "";
105-
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};");
104+
// Build edge attributes
105+
var attributes = new List<string>();
106+
107+
// Add style for conditional edges
108+
if (isConditional)
109+
{
110+
attributes.Add("style=dashed");
111+
}
112+
113+
// Add label (custom label or default "conditional" for conditional edges)
114+
if (label != null)
115+
{
116+
attributes.Add($"label=\"{EscapeDotLabel(label)}\"");
117+
}
118+
else if (isConditional)
119+
{
120+
attributes.Add("label=\"conditional\"");
121+
}
122+
123+
// Combine attributes
124+
var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : "";
125+
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};");
106126
}
107127
}
108128

@@ -133,12 +153,7 @@ private static void EmitSubWorkflowsDigraph(Workflow workflow, List<string> line
133153

134154
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
135155
{
136-
string sanitize(string input)
137-
{
138-
return input;
139-
}
140-
141-
string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id;
156+
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
142157

143158
// Add start node
144159
var startExecutorId = workflow.StartExecutorId;
@@ -175,14 +190,23 @@ string sanitize(string input)
175190
}
176191

177192
// Emit normal edges
178-
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
193+
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
179194
{
180195
if (isConditional)
181196
{
182-
lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};");
197+
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
198+
199+
// Conditional edge, with user label or default
200+
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
201+
}
202+
else if (label != null)
203+
{
204+
// Regular edge with label
205+
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
183206
}
184207
else
185208
{
209+
// Regular edge without label
186210
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
187211
}
188212
}
@@ -214,9 +238,9 @@ string sanitize(string input)
214238
return result;
215239
}
216240

217-
private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow)
241+
private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow)
218242
{
219-
var edges = new List<(string, string, bool)>();
243+
var edges = new List<(string, string, bool, string?)>();
220244
foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x))
221245
{
222246
if (edgeGroup.Kind == EdgeKind.FanIn)
@@ -229,14 +253,15 @@ string sanitize(string input)
229253
case EdgeKind.Direct when edgeGroup.DirectEdgeData != null:
230254
var directData = edgeGroup.DirectEdgeData;
231255
var isConditional = directData.Condition != null;
232-
edges.Add((directData.SourceId, directData.SinkId, isConditional));
256+
var label = directData.Label;
257+
edges.Add((directData.SourceId, directData.SinkId, isConditional, label));
233258
break;
234259

235260
case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null:
236261
var fanOutData = edgeGroup.FanOutEdgeData;
237262
foreach (var sinkId in fanOutData.SinkIds)
238263
{
239-
edges.Add((fanOutData.SourceId, sinkId, false));
264+
edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label));
240265
}
241266
break;
242267
}
@@ -276,5 +301,24 @@ private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(t
276301
return false;
277302
}
278303

304+
// Helper method to escape special characters in DOT labels
305+
private static string EscapeDotLabel(string label)
306+
{
307+
return label.Replace("\"", "\\\"").Replace("\n", "\\n");
308+
}
309+
310+
// Helper method to escape special characters in Mermaid labels
311+
private static string EscapeMermaidLabel(string label)
312+
{
313+
return label
314+
.Replace("&", "&amp;") // Must be first to avoid double-escaping
315+
.Replace("|", "&#124;") // Pipe breaks Mermaid delimiter syntax
316+
.Replace("\"", "&quot;") // Quote character
317+
.Replace("<", "&lt;") // Less than
318+
.Replace(">", "&gt;") // Greater than
319+
.Replace("\n", "<br/>") // Newline to HTML break
320+
.Replace("\r", ""); // Remove carriage return
321+
}
322+
279323
#endregion
280324
}

dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,18 @@ private HashSet<Edge> EnsureEdgesFor(string sourceId)
168168
return edges;
169169
}
170170

171+
/// <summary>
172+
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
173+
/// condition.
174+
/// </summary>
175+
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
176+
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
177+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
178+
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
179+
/// executors already exists.</exception>
180+
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target)
181+
=> this.AddEdge<object>(source, target, null, false);
182+
171183
/// <summary>
172184
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
173185
/// condition.
@@ -182,6 +194,20 @@ private HashSet<Edge> EnsureEdgesFor(string sourceId)
182194
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
183195
=> this.AddEdge<object>(source, target, null, idempotent);
184196

197+
/// <summary>
198+
/// Adds a directed edge from the specified source executor to the target executor.
199+
/// </summary>
200+
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
201+
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
202+
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
203+
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
204+
/// rather than an error.</param>
205+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
206+
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
207+
/// executors already exists.</exception>
208+
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, string? label = null, bool idempotent = false)
209+
=> this.AddEdge<object>(source, target, null, label, idempotent);
210+
185211
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
186212
{
187213
if (condition is null)
@@ -222,6 +248,20 @@ public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, b
222248

223249
private EdgeId TakeEdgeId() => new(Interlocked.Increment(ref this._edgeCount));
224250

251+
/// <summary>
252+
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
253+
/// condition.
254+
/// </summary>
255+
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
256+
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
257+
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
258+
/// If null, the edge is always activated when the source sends a message.</param>
259+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
260+
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
261+
/// executors already exists.</exception>
262+
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null)
263+
=> this.AddEdge(source, target, condition, label: null, false);
264+
225265
/// <summary>
226266
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
227267
/// condition.
@@ -236,6 +276,23 @@ public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, b
236276
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
237277
/// executors already exists.</exception>
238278
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
279+
=> this.AddEdge(source, target, condition, label: null, idempotent);
280+
281+
/// <summary>
282+
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
283+
/// condition.
284+
/// </summary>
285+
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
286+
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
287+
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
288+
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
289+
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
290+
/// rather than an error.</param>
291+
/// If null, the edge is always activated when the source sends a message.</param>
292+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
293+
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
294+
/// executors already exists.</exception>
295+
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, string? label = null, bool idempotent = false)
239296
{
240297
// Add an edge from source to target with an optional condition.
241298
// This is a low-level builder method that does not enforce any specific executor type.
@@ -256,7 +313,7 @@ public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target
256313
"You cannot add another edge without a condition for the same source and target.");
257314
}
258315

259-
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition));
316+
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label);
260317

261318
this.EnsureEdgesFor(source.Id).Add(new(directEdge));
262319

@@ -275,6 +332,19 @@ public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target
275332
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
276333
=> this.AddFanOutEdge<object>(source, targets, null);
277334

335+
/// <summary>
336+
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
337+
/// custom partitioning function.
338+
/// </summary>
339+
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
340+
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
341+
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
342+
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
343+
/// <param name="label">A label for the edge. Will be used in visualization.</param>
344+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
345+
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, string label)
346+
=> this.AddFanOutEdge<object>(source, targets, null, label);
347+
278348
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
279349
{
280350
if (targetAssigner is null)
@@ -305,6 +375,21 @@ public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<Executo
305375
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
306376
/// If null, messages will route to all targets.</param>
307377
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
378+
=> this.AddFanOutEdge(source, targets, targetSelector, label: null);
379+
380+
/// <summary>
381+
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
382+
/// custom partitioning function.
383+
/// </summary>
384+
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
385+
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
386+
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
387+
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
388+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
389+
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
390+
/// If null, messages will route to all targets.</param>
391+
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
392+
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null, string? label = null)
308393
{
309394
Throw.IfNull(source);
310395
Throw.IfNull(targets);
@@ -321,7 +406,8 @@ public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<Exec
321406
this.Track(source).Id,
322407
sinkIds,
323408
this.TakeEdgeId(),
324-
CreateTargetAssignerFunc(targetSelector));
409+
CreateTargetAssignerFunc(targetSelector),
410+
label);
325411

326412
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
327413

@@ -339,6 +425,20 @@ public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<Exec
339425
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
340426
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
341427
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
428+
=> this.AddFanInEdge(sources, target, label: null);
429+
430+
/// <summary>
431+
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
432+
/// optional trigger condition.
433+
/// </summary>
434+
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
435+
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
436+
/// behavior.</remarks>
437+
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
438+
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
439+
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
440+
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
441+
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
342442
{
343443
Throw.IfNull(target);
344444
Throw.IfNull(sources);
@@ -354,7 +454,8 @@ public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, Execut
354454
FanInEdgeData edgeData = new(
355455
sourceIds,
356456
this.Track(target).Id,
357-
this.TakeEdgeId());
457+
this.TakeEdgeId(),
458+
label);
358459

359460
foreach (string sourceId in edgeData.SourceIds)
360461
{

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeMapSmokeTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
2121

2222
Dictionary<string, HashSet<Edge>> workflowEdges = [];
2323

24-
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
24+
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
2525
Edge fanInEdge = new(edgeData);
2626

2727
workflowEdges["executor1"] = [fanInEdge];

dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ public async Task Test_FanInEdgeRunnerAsync()
155155
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
156156
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
157157

158-
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
158+
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
159159
FanInEdgeRunner runner = new(runContext, edgeData);
160160

161161
// Step 1: Send message from executor1, should not forward yet.

0 commit comments

Comments
 (0)