-
Notifications
You must be signed in to change notification settings - Fork 47
Add Workflow Mermaid/DOT visualization export (parity with .NET WorkflowVisualizer) #633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fec832a
3b546a1
4050932
a53129d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| // 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — start node label: .NET appends |
||
| if id == wf.startExecutorID { | ||
| fmt.Fprintf(b, "%sclass %s startNode;\n", indent, nodeID) | ||
| } | ||
| } | ||
| for _, info := range reflectUniqueEdges(wf) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Conditional edge default label: Go omits In the Mermaid output for conditional edges, .NET uses The Go Suggestion: When |
||
| writeMermaidEdge(b, indent, prefix, info) | ||
| } | ||
| } | ||
|
|
||
| func writeMermaidEdge(b *strings.Builder, indent, prefix string, info EdgeInfo) { | ||
| sources := info.Connection.SourceIDs | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 .NET: Suggested fix: change |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 .NET: If the Go |
||
| fmt.Fprintf(b, "%s%s --> %s\n", indent, mermaidID(prefix+s), junction) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — conditional edge default label (Mermaid): .NET emits a default label |
||
| } | ||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — fan-in node shape (Mermaid): Go emits a hexagon |
||
| for _, t := range sinks { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DOT start-node styling diverges from .NET and Python Both .NET ( Suggestion: use |
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — DOT global node style: .NET emits |
||
| indent := strings.Repeat(" ", depth) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 .NET: Also, the start node fill color differs: Go uses Suggested fix: change start node attributes to |
||
| 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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DOT fan-in node shape diverges from .NET The .NET Suggestion: change the junction node to |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity gap — DOT fan-in node shape/color: Go uses .NET: Also note: .NET and Python emit a default Suggested fixes:
|
||
| 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))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — DOT fan-in node shape: Go uses |
||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: .NET: 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity gap — Mermaid label escaping is incomplete: .NET escapes: Of these, |
||
| } | ||
| return out | ||
| } | ||
|
|
||
| func mermaidLabel(s string) string { | ||
| return strings.ReplaceAll(s, "\"", "#quot;") | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parity issue — Mermaid label escaping: |
||
| func dotEscape(s string) string { | ||
| s = strings.ReplaceAll(s, "\\", "\\\\") | ||
| s = strings.ReplaceAll(s, "\"", "\\\"") | ||
| return s | ||
| } | ||
| 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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Parity gap:
include_internal_executorsoption missingThe Python
WorkflowViz.to_digraph()andto_mermaid()both acceptinclude_internal_executors: bool = False(see_viz.py). This parameter controls whether internal (framework-managed) executors are included in the rendered graph. Its default isFalse, meaning internal executors are hidden by default.The Go
ToMermaidStringandToDotStringfunctions 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
VisualizationOptionsstruct (or a simple boolean) defaulting tofalse, matching the Python default. Note that the .NETWorkflowVisualizeralso does not expose this parameter — that cross-SDK gap already exists upstream — but Go should at minimum align with Python here.