Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions workflow/visualization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright (c) Microsoft. All rights reserved.

package workflow

import (
"fmt"
"sort"
"strconv"
"strings"
)

// ToMermaidString renders wf as a Mermaid flowchart definition.
//
// It mirrors .NET's WorkflowVisualizer.ToMermaidString: nodes are emitted for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap: include_internal_executors option missing

The Python WorkflowViz.to_digraph() and to_mermaid() both accept include_internal_executors: bool = False (see _viz.py). This parameter controls whether internal (framework-managed) executors are included in the rendered graph. Its default is False, meaning internal executors are hidden by default.

The Go ToMermaidString and ToDotString functions have no equivalent option. Callers cannot opt in to showing internal executors, and there is no parity with the Python default of excluding them if Go's reflection helpers surface internal executors unconditionally.

Suggestion: introduce a VisualizationOptions struct (or a simple boolean) defaulting to false, matching the Python default. Note that the .NET WorkflowVisualizer also does not expose this parameter — that cross-SDK gap already exists upstream — but Go should at minimum align with Python here.

// every bound executor (the start executor is highlighted), and edges are drawn
// from the reflected edge metadata. Conditional edges are dashed, edge labels
// are preserved, fan-out edges expand to one edge per target, and fan-in edges
// (more than one source) are drawn through a synthesized junction node. Nested
// sub-workflows (bindings whose RawValue is a *Workflow) are rendered as nested
// subgraphs.
func ToMermaidString(wf *Workflow) string {
var b strings.Builder
b.WriteString("flowchart TD\n")
b.WriteString(" classDef startNode fill:#2E7D32,stroke:#1B5E20,color:#ffffff;\n")
if wf != nil {
writeMermaidWorkflow(&b, wf, "", 1, map[*Workflow]bool{})
}
return b.String()
}

// ToDotString renders wf as a Graphviz DOT digraph definition.
//
// It mirrors .NET's WorkflowVisualizer.ToDotString using the same graph shape
// as [ToMermaidString]: the start executor is filled, conditional edges are
// dashed, edge labels are preserved, fan-in edges route through a junction
// node, and nested sub-workflows are emitted as DOT clusters.
func ToDotString(wf *Workflow) string {
var b strings.Builder
b.WriteString("digraph Workflow {\n")
b.WriteString(" rankdir=TB;\n")
b.WriteString(" node [shape=box];\n")
if wf != nil {
writeDotWorkflow(&b, wf, "", 1, map[*Workflow]bool{})
}
b.WriteString("}\n")
return b.String()
}

func writeMermaidWorkflow(b *strings.Builder, wf *Workflow, prefix string, depth int, visited map[*Workflow]bool) {
if visited[wf] {
return
}
visited[wf] = true
indent := strings.Repeat(" ", depth)
for _, id := range sortedExecutorIDs(wf) {
binding := wf.executorBindings[id]
nodeID := mermaidID(prefix + id)
if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
fmt.Fprintf(b, "%ssubgraph %s [\"%s\"]\n", indent, nodeID, mermaidLabel(id))
writeMermaidWorkflow(b, sub, prefix+id+"/", depth+1, visited)
fmt.Fprintf(b, "%send\n", indent)
continue
}
fmt.Fprintf(b, "%s%s[\"%s\"]\n", indent, nodeID, mermaidLabel(id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — start node label: .NET appends (Start) to the start executor label in both Mermaid and DOT (e.g. "myExecutor\n(Start)"). Go emits only the executor ID. This makes it harder to distinguish the start node from other nodes purely by label. Please append \n(Start) (DOT) or (Start) (Mermaid) to the start node label to match upstream.

if id == wf.startExecutorID {
fmt.Fprintf(b, "%sclass %s startNode;\n", indent, nodeID)
}
}
for _, info := range reflectUniqueEdges(wf) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditional edge default label: Go omits "conditional" when no custom label is set

In the Mermaid output for conditional edges, .NET uses -. conditional .-> when no custom label is set (WorkflowVisualizer.cs). Python likewise emits -. conditional .-> for unlabeled conditional edges.

The Go writeMermaidEdge uses "-.->" without inserting any label text when info.Label == "". This means an unlabeled conditional edge in Go renders as A -.-> B instead of A -. conditional .-> B.

Suggestion: When info.HasCondition && info.Label == "", default the label to "conditional" to match .NET and Python.

writeMermaidEdge(b, indent, prefix, info)
}
}

func writeMermaidEdge(b *strings.Builder, indent, prefix string, info EdgeInfo) {
sources := info.Connection.SourceIDs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid fan-in node shape: Go renders fan-in nodes as {{"fan-in"}} (hexagon). Both .NET (WorkflowVisualizer) and Python (WorkflowViz) use ((fan-in)) — the circle/stadium shape — for this node, producing a visually different diagram.

.NET: lines.Add($"{indent}{GetSafeId(nodeId)}((fan-in))");
Python: lines.append(f"{indent}{fan_node_id}((fan-in))")

Suggested fix: change {{"fan-in"}}((fan-in)).

sinks := info.Connection.SinkIDs
arrow := "-->"
if info.HasCondition {
arrow = "-.->"
}
label := ""
if info.Label != "" {
label = "|" + mermaidLabel(info.Label) + "|"
}
if len(sources) > 1 {
junction := mermaidID(prefix + fanInJunctionID(sources, sinks))
fmt.Fprintf(b, "%s%s{{\"fan-in\"}}\n", indent, junction)
for _, s := range sources {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid conditional edge default label missing: When a conditional edge has no explicit label, Go emits the edge with no label text. Both .NET and Python always emit "conditional" as the default label text for conditional edges.

.NET: string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
Python: lines.append(f"{indent}{s} -. conditional .-> {t};")

If the Go EdgeInfo.Label is empty and HasCondition is true, the edge label should fall back to "conditional" for visual parity.

fmt.Fprintf(b, "%s%s --> %s\n", indent, mermaidID(prefix+s), junction)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — conditional edge default label (Mermaid): .NET emits a default label "conditional" on conditional edges that have no user-supplied label, using the syntax -. conditional .-> (dotnet source). Go currently emits no label and uses -.-> syntax. Please add the default "conditional" label when info.Label == "" and use the -. label .-> Mermaid syntax to match upstream.

}
for _, t := range sinks {
fmt.Fprintf(b, "%s%s %s%s %s\n", indent, junction, arrow, label, mermaidID(prefix+t))
}
return
}
for _, s := range sources {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — fan-in node shape (Mermaid): Go emits a hexagon {{"fan-in"}} for fan-in junction nodes, but upstream .NET WorkflowVisualizer uses a circle/stadium ((fan-in)) (dotnet source). The visual convention should match: please change the Mermaid fan-in shape to (("fan-in")).

for _, t := range sinks {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOT start-node styling diverges from .NET and Python

Both .NET (WorkflowVisualizer.cs) and Python (_viz.py) emit the start node with fillcolor=lightgreen and label="<id>\n(Start)". The Go implementation uses fillcolor="#2E7D32", fontcolor="white" without the (Start) label suffix.

Suggestion: use fillcolor=lightgreen and append \n(Start) to the label to match the upstream visual convention. (The Mermaid side already appends a classDef startNode with the hex green — it's the DOT path that diverges.)

fmt.Fprintf(b, "%s%s %s%s %s\n", indent, mermaidID(prefix+s), arrow, label, mermaidID(prefix+t))
}
}
}

func writeDotWorkflow(b *strings.Builder, wf *Workflow, prefix string, depth int, visited map[*Workflow]bool) {
if visited[wf] {
return
}
visited[wf] = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — DOT global node style: .NET emits node [shape=box, style=filled, fillcolor=lightblue]; so all nodes get a light-blue fill by default, with the start node overriding to fillcolor=lightgreen. Go omits the global style and only fills the start node. Please add a global node style matching the .NET convention so non-start nodes are also filled.

indent := strings.Repeat(" ", depth)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — DOT start node label format: Go labels the start node as "id" (just the executor ID). Both .NET and Python label it as "id (Start)" to visually identify it as the entry point.

.NET: lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\n(Start)\"];");
Python: lines.append(f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\n(Start)"];')

Also, the start node fill color differs: Go uses #2E7D32 (dark green), while both upstream SDKs use lightgreen.

Suggested fix: change start node attributes to style=filled, fillcolor=lightgreen and append (Start) to the label.

for _, id := range sortedExecutorIDs(wf) {
binding := wf.executorBindings[id]
nodeID := prefix + id
if sub, ok := binding.RawValue.(*Workflow); ok && sub != nil {
fmt.Fprintf(b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(nodeID))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOT fan-in node shape diverges from .NET

The .NET WorkflowVisualizer emits fan-in junction nodes as shape=ellipse, fillcolor=lightgoldenrod (see WorkflowVisualizer.cs). The Go implementation uses shape=diamond with no fill color. The Python implementation also uses shape=ellipse, fillcolor=lightgoldenrod.

Suggestion: change the junction node to shape=ellipse, fillcolor=lightgoldenrod to match both .NET and Python.

fmt.Fprintf(b, "%s label=\"%s\";\n", indent, dotEscape(id))
writeDotWorkflow(b, sub, prefix+id+"/", depth+1, visited)
fmt.Fprintf(b, "%s}\n", indent)
continue
}
if id == wf.startExecutorID {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — DOT fan-in node shape/color: Go uses shape=diamond for fan-in junction nodes. Both .NET and Python use shape=ellipse, fillcolor=lightgoldenrod for these nodes.

.NET: lines.Add($"{indent}{GetSafeId(nodeId)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"]");
Python: lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')

Also note: .NET and Python emit a default node [shape=box, style=filled, fillcolor=lightblue] for all DOT nodes, which Go omits. This means regular executor nodes render without fill in Go output.

Suggested fixes:

  1. Change shape=diamondshape=ellipse, fillcolor=lightgoldenrod
  2. Add node [shape=box, style=filled, fillcolor=lightblue]; to the DOT header (alongside existing node [shape=box]).

fmt.Fprintf(b, "%s\"%s\" [label=\"%s\", style=filled, fillcolor=\"#2E7D32\", fontcolor=\"white\"];\n", indent, dotEscape(nodeID), dotEscape(id))
} else {
fmt.Fprintf(b, "%s\"%s\" [label=\"%s\"];\n", indent, dotEscape(nodeID), dotEscape(id))
}
}
for _, info := range reflectUniqueEdges(wf) {
writeDotEdge(b, indent, prefix, info)
}
}

func writeDotEdge(b *strings.Builder, indent, prefix string, info EdgeInfo) {
sources := info.Connection.SourceIDs
sinks := info.Connection.SinkIDs
var attrParts []string
if info.Label != "" {
attrParts = append(attrParts, fmt.Sprintf("label=\"%s\"", dotEscape(info.Label)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — DOT fan-in node shape: Go uses shape=diamond for fan-in junction nodes. Upstream .NET uses shape=ellipse (dotnet source). Please change to shape=ellipse to preserve visual parity.

}
if info.HasCondition {
attrParts = append(attrParts, "style=dashed")
}
attrs := ""
if len(attrParts) > 0 {
attrs = " [" + strings.Join(attrParts, ", ") + "]"
}
if len(sources) > 1 {
junction := prefix + fanInJunctionID(sources, sinks)
fmt.Fprintf(b, "%s\"%s\" [shape=diamond, label=\"fan-in\"];\n", indent, dotEscape(junction))
for _, s := range sources {
fmt.Fprintf(b, "%s\"%s\" -> \"%s\";\n", indent, dotEscape(prefix+s), dotEscape(junction))
}
for _, t := range sinks {
fmt.Fprintf(b, "%s\"%s\" -> \"%s\"%s;\n", indent, dotEscape(junction), dotEscape(prefix+t), attrs)
}
return
}
for _, s := range sources {
for _, t := range sinks {
fmt.Fprintf(b, "%s\"%s\" -> \"%s\"%s;\n", indent, dotEscape(prefix+s), dotEscape(prefix+t), attrs)
}
}
}

func sortedExecutorIDs(wf *Workflow) []string {
ids := make([]string, 0, len(wf.executorBindings))
for id := range wf.executorBindings {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}

// reflectUniqueEdges returns the workflow's edges deduplicated by connection and
// metadata, in a stable order. Fan-in edges are registered under every source in
// [Workflow.ReflectEdges], so deduplication avoids emitting them multiple times.
func reflectUniqueEdges(wf *Workflow) []EdgeInfo {
seen := map[string]bool{}
var out []EdgeInfo
for _, list := range wf.ReflectEdges() {
for _, info := range list {
key := edgeSignature(info)
if seen[key] {
continue
}
seen[key] = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential stability issue — fan-in junction ID uses name concatenation instead of a hash: fanInJunctionID builds its ID by concatenating source/sink names with _ and __. Both .NET and Python derive the junction node ID from a short SHA-256 digest of the sorted source names and target, which avoids collisions when executor IDs are long, contain underscores, or differ only in ordering.

.NET: var digest = ComputeFanInDigest(target, sources); (SHA-256, first 8 hex chars)
Python: hashlib.sha256((target + "|".join(sources)).encode()).hexdigest()[:8]

With the current approach, two different fan-in configurations can produce the same junction ID if their concatenated strings happen to match, silently corrupting the graph. Consider adopting the same digest-based approach.

out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool {
return edgeSignature(out[i]) < edgeSignature(out[j])
})
return out
}

func edgeSignature(info EdgeInfo) string {
return strings.Join(info.Connection.SourceIDs, ",") + ">" +
strings.Join(info.Connection.SinkIDs, ",") + "|" +
info.Label + "|" + strconv.FormatBool(info.HasCondition)
}

func fanInJunctionID(sources, sinks []string) string {
return "fanin_" + strings.Join(sources, "_") + "__" + strings.Join(sinks, "_")
}

func mermaidID(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
return "n"
}
if out[0] >= '0' && out[0] <= '9' {
return "n" + out

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap — Mermaid label escaping is incomplete: mermaidLabel only escapes "#quot;. Both .NET and Python escape a fuller set of characters that have special meaning in Mermaid syntax.

.NET escapes: &&, ||, "&quot;, <<, >>, <br/>
Python mirrors the same set.

Of these, | is the most critical: an unescaped pipe in a label string will break Mermaid's edge-label delimiter syntax and produce invalid output. Please align mermaidLabel with the upstream escaping.

}
return out
}

func mermaidLabel(s string) string {
return strings.ReplaceAll(s, "\"", "#quot;")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue — Mermaid label escaping: mermaidLabel only escapes " (as #quot;). Upstream .NET EscapeMermaidLabel also escapes &&, ||, <<, >>, \n<br/>, and strips \r (dotnet source). Unescaped | and </> will break Mermaid parsing for executor names containing those characters. Please bring mermaidLabel in line with the full upstream escaping set.

func dotEscape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
return s
}
115 changes: 115 additions & 0 deletions workflow/visualization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft. All rights reserved.

package workflow_test

import (
"strings"
"testing"

"github.com/microsoft/agent-framework-go/workflow"
"github.com/microsoft/agent-framework-go/workflow/inproc"
)

// buildVizWorkflow builds start -> {b, c} (fan-out) -> d (fan-in barrier) ->
// e (conditional, labeled) exercising every edge shape the renderers handle.
func buildVizWorkflow(t *testing.T) *workflow.Workflow {
t.Helper()
start := newNoOpExecutor("start")
b := newNoOpExecutor("b")
c := newNoOpExecutor("c")
d := newNoOpExecutor("d")
e := newNoOpExecutor("e")

wf, err := workflow.NewBuilder(start).
AddFanOutEdge(start, []workflow.ExecutorBinding{b, c}).
AddFanInBarrierEdge([]workflow.ExecutorBinding{b, c}, d).
AddDirectEdge(d, e, false, func(any) bool { return true }, workflow.WithEdgeLabel("approved")).
WithOutputFrom(e).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
return wf
}

func TestToMermaidString(t *testing.T) {
got := workflow.ToMermaidString(buildVizWorkflow(t))

wants := []string{
"flowchart TD",
"classDef startNode", // highlight class declared
"class start startNode;", // start node highlighted
`start["start"]`,
`b["b"]`,
`c["c"]`,
`d["d"]`,
`e["e"]`,
"start --> b", // fan-out expands to one edge per target
"start --> c",
`{{"fan-in"}}`, // synthesized fan-in junction node
"fanin_b_c", // junction id derived from sources
"-.->", // conditional edge is dashed
"|approved|", // edge label preserved
}
for _, want := range wants {
if !strings.Contains(got, want) {
t.Errorf("ToMermaidString output missing %q\n---\n%s", want, got)
}
}
}

func TestToDotString(t *testing.T) {
got := workflow.ToDotString(buildVizWorkflow(t))

wants := []string{
"digraph Workflow {",
`"start" [label="start", style=filled`, // start node highlighted
`"b" [label="b"]`,
`"c" [label="c"]`,
`"d" [label="d"]`,
`"e" [label="e"]`,
"fan-in", // junction label
`label="approved"`, // edge label preserved
"style=dashed", // conditional edge is dashed
`"start" -> "b"`, // fan-out edge
}
for _, want := range wants {
if !strings.Contains(got, want) {
t.Errorf("ToDotString output missing %q\n---\n%s", want, got)
}
}
}

func TestVisualization_NestedSubworkflow(t *testing.T) {
childStart := newNoOpExecutor("child-start")
child, err := workflow.NewBuilder(childStart).
WithOutputFrom(childStart).
Build()
if err != nil {
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child")
sink := newNoOpExecutor("sink")
parent, err := workflow.NewBuilder(host).
AddEdge(host, sink).
WithOutputFrom(sink).
Build()
if err != nil {
t.Fatalf("Build parent: %v", err)
}

mermaid := workflow.ToMermaidString(parent)
for _, want := range []string{"subgraph child", "child-start"} {
if !strings.Contains(mermaid, want) {
t.Errorf("ToMermaidString nested output missing %q\n---\n%s", want, mermaid)
}
}

dot := workflow.ToDotString(parent)
for _, want := range []string{`subgraph "cluster_child"`, "child-start"} {
if !strings.Contains(dot, want) {
t.Errorf("ToDotString nested output missing %q\n---\n%s", want, dot)
}
}
}
Loading