From cbc645ad8b0432d77d7339bfbec1081ebe518749 Mon Sep 17 00:00:00 2001 From: Damian Osadczuk Date: Wed, 4 Nov 2026 07:46:47 -0800 Subject: [PATCH] refactor(dev): draw workflow graphs as DOT the way the Python dev server does PiperOrigin-RevId: 1013847299 --- .../adk/web/controller/GraphPlotter.java | 420 ++++++++++++++++++ .../adk/web/controller/GraphPlotterTest.java | 380 ++++++++++++++++ 2 files changed, 800 insertions(+) create mode 100644 dev/src/main/java/com/google/adk/web/controller/GraphPlotter.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/GraphPlotterTest.java diff --git a/dev/src/main/java/com/google/adk/web/controller/GraphPlotter.java b/dev/src/main/java/com/google/adk/web/controller/GraphPlotter.java new file mode 100644 index 000000000..53b18c263 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/GraphPlotter.java @@ -0,0 +1,420 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.adk.web.controller.AppGraph.AGENT_NODE_TYPE; +import static com.google.adk.web.controller.AppGraph.JOIN_NODE_TYPE; +import static com.google.adk.web.controller.AppGraph.TOOL_NODE_TYPE; +import static com.google.adk.web.controller.AppGraph.WORKFLOW_NODE_TYPE; +import static java.util.stream.Collectors.joining; + +import com.google.adk.web.controller.AppGraph.GraphEdge; +import com.google.adk.web.controller.AppGraph.GraphNode; +import com.google.adk.web.controller.AppGraph.GraphTool; +import com.google.adk.web.controller.AppGraph.WorkflowGraph; +import com.google.adk.workflow.Route; +import com.google.adk.workflow.Start; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Renders a graph as Graphviz DOT source for the Dev UI: a workflow graph from START to END, or an + * agent tree with attached tools drawn as dashed nodes. + */ +final class GraphPlotter { + + private static final Palette DARK_PALETTE = + new Palette( + "#0F172A", "#1E293B", "#475569", "#F8FAFC", "#94A3B8", "#CBD5E1", "#059669", "#047857", + "#DC2626", "#B91C1C"); + + private static final Palette LIGHT_PALETTE = + new Palette( + "#F8FAFC", "#FFFFFF", "#94A3B8", "#0F172A", "#64748B", "#475569", "#10B981", "#059669", + "#EF4444", "#DC2626"); + + private static final ImmutableMap ICONS = + ImmutableMap.of( + AGENT_NODE_TYPE, + new Icon("✦", "#42A5F5"), + WORKFLOW_NODE_TYPE, + new Icon("⊷", "#9333EA"), + "function", + new Icon("ƒ", "#10B981"), + JOIN_NODE_TYPE, + new Icon("⌵", "#F59E0B"), + TOOL_NODE_TYPE, + new Icon("🔧", "#6B7280")); + + private static final String START_NODE_NAME = Start.INSTANCE.name(); + + private static final String END_NODE_NAME = "__END__"; + + private static final String NO_DEFAULT_WARNING = "⚠️ [NO DEFAULT]"; + + private static final Pattern DOT_ID = + Pattern.compile("[a-zA-Z_][a-zA-Z0-9_]*|-?(\\.[0-9]+|[0-9]+(\\.[0-9]*)?)"); + + private static final ImmutableSet DOT_KEYWORDS = + ImmutableSet.of("node", "edge", "graph", "digraph", "subgraph", "strict"); + + /** An even run of backslashes, then a double quote that may already be escaped. */ + private static final Pattern OPTIONALLY_ESCAPED_QUOTE = Pattern.compile("((?:\\\\\\\\)*)\\\\?\""); + + static String plot(GraphNode root, boolean darkMode) { + Palette palette = darkMode ? DARK_PALETTE : LIGHT_PALETTE; + List nodes = new ArrayList<>(); + List edges = new ArrayList<>(); + WorkflowGraph graph = root.graph(); + if (graph != null) { + for (GraphNode node : graph.nodes()) { + nodes.add(new PlotNode(node.name(), node.type(), toolsOf(node))); + } + for (GraphEdge edge : graph.edges()) { + edges.add(new PlotEdge(edge.from().name(), edge.to().name(), edge.route(), false)); + } + } else { + addAgentTree(root, nodes, edges); + } + addTools(nodes, edges); + + DotWriter dot = new DotWriter(); + dot.statement( + "graph", + Map.of( + "bgcolor", palette.background(), + "pad", "0.5", + "nodesep", "0.5", + "ranksep", "0.8", + "fontname", "Helvetica", + "splines", "spline")); + dot.statement( + "node", + Map.of( + "shape", "rect", + "style", "rounded,filled", + "fillcolor", palette.nodeFill(), + "color", palette.nodeLine(), + "penwidth", "1.5", + "fontname", "Helvetica", + "fontcolor", palette.nodeFont(), + "fontsize", "12", + "margin", "0.25,0.15")); + dot.statement( + "edge", + Map.of( + "color", palette.edgeLine(), + "penwidth", "1.2", + "fontname", "Helvetica", + "fontcolor", palette.edgeFont(), + "fontsize", "10", + "arrowhead", "vee", + "arrowsize", "0.7")); + for (PlotNode node : nodes) { + if (!node.name().equals(START_NODE_NAME)) { + drawNode(dot, node, edges, palette); + } + } + for (PlotEdge edge : edges) { + if (edge.from().equals(START_NODE_NAME)) { + drawTerminus( + dot, START_NODE_NAME, "START", palette.startFill(), palette.startLine(), palette); + } + if (edge.isToolEdge()) { + dot.edge( + edge.from(), edge.to(), null, Map.of("style", "dashed", "color", palette.edgeLine())); + } else { + String label = edge.route() == null ? "" : " " + routeLabel(edge.route()); + dot.edge(edge.from(), edge.to(), plainLabel(label), Map.of()); + } + } + List terminalNodes = + nodes.stream() + .filter( + node -> + !node.name().equals(START_NODE_NAME) + && !node.name().equals(END_NODE_NAME) + && !node.type().equals(TOOL_NODE_TYPE) + && edges.stream() + .noneMatch( + edge -> edge.from().equals(node.name()) && !edge.isToolEdge())) + .toList(); + if (graph != null && !terminalNodes.isEmpty()) { + drawTerminus(dot, END_NODE_NAME, "END", palette.endFill(), palette.endLine(), palette); + for (PlotNode node : terminalNodes) { + dot.edge(node.name(), END_NODE_NAME, null, Map.of()); + } + } + return dot.build(); + } + + private record PlotNode(String name, String type, ImmutableList tools) {} + + /** An edge to draw; {@code route} is a {@link Route}, a list of them, or null. */ + private record PlotEdge(String from, String to, @Nullable Object route, boolean isToolEdge) {} + + private record Palette( + String background, + String nodeFill, + String nodeLine, + String nodeFont, + String edgeLine, + String edgeFont, + String startFill, + String startLine, + String endFill, + String endLine) {} + + private record Icon(String glyph, String color) {} + + /** A label in DOT form: a quoted string, or HTML-like markup, which DOT takes unquoted. */ + private record DotLabel(String dot) {} + + private static ImmutableList toolsOf(GraphNode node) { + return node.tools() == null ? ImmutableList.of() : node.tools(); + } + + private static void addAgentTree(GraphNode agent, List nodes, List edges) { + nodes.add(new PlotNode(agent.name(), AGENT_NODE_TYPE, toolsOf(agent))); + if (agent.subAgents() == null) { + return; + } + for (GraphNode sub : agent.subAgents()) { + edges.add(new PlotEdge(agent.name(), sub.name(), null, false)); + addAgentTree(sub, nodes, edges); + } + } + + private static void addTools(List nodes, List edges) { + Map toolTypes = new LinkedHashMap<>(); + List toolEdges = new ArrayList<>(); + for (PlotNode node : nodes) { + if (node.name().equals(START_NODE_NAME)) { + continue; + } + for (GraphTool tool : node.tools()) { + if (tool.name().isEmpty()) { + continue; + } + toolTypes.putIfAbsent(tool.name(), tool.type()); + toolEdges.add(new PlotEdge(node.name(), tool.name(), null, true)); + } + } + for (Map.Entry tool : toolTypes.entrySet()) { + if (nodes.stream().noneMatch(node -> node.name().equals(tool.getKey()))) { + nodes.add(new PlotNode(tool.getKey(), tool.getValue(), ImmutableList.of())); + } + } + edges.addAll(toolEdges); + } + + private static void drawNode( + DotWriter dot, PlotNode node, List edges, Palette palette) { + List outgoing = + edges.stream().filter(edge -> edge.from().equals(node.name())).toList(); + Icon icon = ICONS.get(node.type()); + Map attributes = new LinkedHashMap<>(); + attributes.put("tooltip", capitalize(node.type())); + attributes.put("fillcolor", palette.nodeFill()); + if (outgoing.stream().anyMatch(edge -> edge.route() != null)) { + boolean hasDefault = + outgoing.stream() + .anyMatch( + edge -> + !edge.isToolEdge() + && (edge.route() == null || edge.route() == Route.Default.INSTANCE)); + DotLabel label; + if (hasDefault) { + label = nodeLabel(node.name(), icon); + } else if (icon != null) { + label = + htmlLabel( + iconHtml(icon) + + " " + + escapeHtml(node.name()) + + "

" + + NO_DEFAULT_WARNING + + ""); + } else { + label = + htmlLabel( + escapeHtml(node.name()) + + "

" + + NO_DEFAULT_WARNING + + ""); + } + attributes.put("shape", "diamond"); + attributes.put("style", "filled"); + attributes.put("height", "1.2"); + attributes.put("width", "0.8"); + attributes.put("margin", "0.0,0.0"); + dot.node(node.name(), label, attributes); + } else if (node.type().equals(JOIN_NODE_TYPE)) { + attributes.put("shape", "oval"); + attributes.put("style", "filled"); + attributes.put("margin", "0.05,0.05"); + dot.node(node.name(), nodeLabel(node.name(), icon), attributes); + } else if (node.type().equals(TOOL_NODE_TYPE)) { + attributes.put("style", "rounded,filled,dashed"); + dot.node(node.name(), nodeLabel(node.name(), icon), attributes); + } else { + attributes.put("style", "rounded,filled"); + dot.node(node.name(), nodeLabel(node.name(), icon), attributes); + } + } + + private static void drawTerminus( + DotWriter dot, String name, String label, String fill, String line, Palette palette) { + dot.node( + name, + plainLabel(label), + Map.of( + "shape", "oval", + "style", "filled", + "fillcolor", fill, + "color", line, + "fontcolor", palette.nodeFont(), + "fontname", "Helvetica-Bold", + "width", "0.9", + "fixedsize", "true")); + } + + private static DotLabel nodeLabel(String name, @Nullable Icon icon) { + return icon == null ? plainLabel(name) : htmlLabel(iconHtml(icon) + " " + escapeHtml(name)); + } + + private static String iconHtml(Icon icon) { + return "" + icon.glyph() + ""; + } + + /** Formats a route, or a list of routes, as an edge label the way Python prints them. */ + private static String routeLabel(Object route) { + if (route instanceof List routes) { + return routes.stream() + .map( + item -> + item instanceof Route.Tag tag ? quoteRouteItem(tag.value()) : routeLabel(item)) + .collect(joining(", ", "[", "]")); + } + if (route instanceof Route.Tag tag) { + return tag.value(); + } + if (route instanceof Route.Num num) { + return Long.toString(num.value()); + } + if (route instanceof Route.Flag flag) { + return flag.value() ? "True" : "False"; + } + return Route.DEFAULT_ROUTE_SENTINEL; + } + + /** Quotes and escapes a string element inside a multi-route edge label, as Python's repr does. */ + private static String quoteRouteItem(String text) { + String quote = text.contains("'") && !text.contains("\"") ? "\"" : "'"; + String escaped = + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace(quote, "\\" + quote); + return quote + escaped + quote; + } + + private static String escapeHtml(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static String capitalize(String text) { + return text.isEmpty() ? text : Character.toTitleCase(text.charAt(0)) + text.substring(1); + } + + private static DotLabel plainLabel(String text) { + return new DotLabel(quoteDot(text)); + } + + private static DotLabel htmlLabel(String markup) { + return new DotLabel("<" + markup + ">"); + } + + /** + * Formats {@code value} as a DOT identifier, quoting it and escaping unescaped double quotes + * unless it is a plain identifier or number. + */ + private static String quoteDot(String value) { + if (DOT_ID.matcher(value).matches() && !DOT_KEYWORDS.contains(value.toLowerCase(Locale.ROOT))) { + return value; + } + Matcher matcher = OPTIONALLY_ESCAPED_QUOTE.matcher(value); + return "\"" + + matcher.replaceAll(match -> Matcher.quoteReplacement(match.group(1) + "\\\"")) + + "\""; + } + + /** Builds Graphviz DOT source with {@code label} placed before other sorted attributes. */ + private static final class DotWriter { + private final StringBuilder out = new StringBuilder("// Workflow Visualization\ndigraph {\n"); + + void statement(String kind, Map attributes) { + line(kind + " " + attributeList(null, attributes)); + } + + void node(String name, DotLabel label, Map attributes) { + line(quoteDot(name) + " " + attributeList(label, attributes)); + } + + void edge(String from, String to, @Nullable DotLabel label, Map attributes) { + String list = + label == null && attributes.isEmpty() ? "" : " " + attributeList(label, attributes); + line(quoteDot(from) + " -> " + quoteDot(to) + list); + } + + String build() { + return out.append("}\n").toString(); + } + + private void line(String text) { + out.append('\t').append(text).append('\n'); + } + + private static String attributeList(@Nullable DotLabel label, Map attributes) { + List items = new ArrayList<>(); + if (label != null) { + items.add("label=" + label.dot()); + } + attributes.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach( + attribute -> items.add(attribute.getKey() + "=" + quoteDot(attribute.getValue()))); + return "[" + String.join(" ", items) + "]"; + } + } + + private GraphPlotter() {} +} diff --git a/dev/src/test/java/com/google/adk/web/controller/GraphPlotterTest.java b/dev/src/test/java/com/google/adk/web/controller/GraphPlotterTest.java new file mode 100644 index 000000000..b8de5faa1 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/GraphPlotterTest.java @@ -0,0 +1,380 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.adk.web.testing.GraphFixtures.assistantAgent; +import static com.google.adk.web.testing.GraphFixtures.llmAgent; +import static com.google.adk.web.testing.GraphFixtures.step; +import static com.google.adk.web.testing.GraphFixtures.supportWorkflow; +import static com.google.adk.web.testing.GraphFixtures.tool; +import static com.google.common.truth.Truth.assertThat; + +import com.google.adk.tools.BaseTool; +import com.google.adk.web.controller.AppGraph.GraphEdge; +import com.google.adk.web.controller.AppGraph.GraphNode; +import com.google.adk.web.controller.AppGraph.GraphNodeRef; +import com.google.adk.web.controller.AppGraph.GraphTool; +import com.google.adk.web.controller.AppGraph.WorkflowGraph; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Node; +import com.google.adk.workflow.Route; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class GraphPlotterTest { + + private static final String START = Start.INSTANCE.name(); + + private static final String LIGHT_GRAPH = + "graph [bgcolor=\"#F8FAFC\" fontname=Helvetica nodesep=0.5 pad=0.5 ranksep=0.8" + + " splines=spline]"; + private static final String LIGHT_NODE = + "node [color=\"#94A3B8\" fillcolor=\"#FFFFFF\" fontcolor=\"#0F172A\" fontname=Helvetica" + + " fontsize=12 margin=\"0.25,0.15\" penwidth=1.5 shape=rect style=\"rounded,filled\"]"; + private static final String LIGHT_EDGE = + "edge [arrowhead=vee arrowsize=0.7 color=\"#64748B\" fontcolor=\"#475569\"" + + " fontname=Helvetica fontsize=10 penwidth=1.2]"; + private static final String LIGHT_START = + "__START__ [label=START color=\"#059669\" fillcolor=\"#10B981\" fixedsize=true" + + " fontcolor=\"#0F172A\" fontname=\"Helvetica-Bold\" shape=oval style=filled" + + " width=0.9]"; + private static final String LIGHT_END = + "__END__ [label=END color=\"#DC2626\" fillcolor=\"#EF4444\" fixedsize=true" + + " fontcolor=\"#0F172A\" fontname=\"Helvetica-Bold\" shape=oval style=filled" + + " width=0.9]"; + + @Test + void plot_workflow_drawsItsGraphFromStartToEnd() { + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(supportWorkflow()), false); + + assertThat(dot) + .isEqualTo( + expectedDot( + LIGHT_GRAPH, + LIGHT_NODE, + LIGHT_EDGE, + "classify [label=classify fillcolor=\"#FFFFFF\" height=1.2 margin=\"0.0,0.0\"" + + " shape=diamond style=filled tooltip=\"Node\" width=0.8]", + "billing_agent [label=<✦" + + " billing_agent> fillcolor=\"#FFFFFF\" style=\"rounded,filled\"" + + " tooltip=Agent]", + "tech_flow [label=<⊷ tech_flow>" + + " fillcolor=\"#FFFFFF\" style=\"rounded,filled\" tooltip=Workflow]", + "fallback [label=fallback fillcolor=\"#FFFFFF\" style=\"rounded,filled\"" + + " tooltip=\"Node\"]", + "summarize [label=summarize fillcolor=\"#FFFFFF\" style=\"rounded,filled\"" + + " tooltip=\"Node\"]", + "lookup_invoice [label=<🔧" + + " lookup_invoice> fillcolor=\"#FFFFFF\" style=\"rounded,filled,dashed\"" + + " tooltip=Tool]", + LIGHT_START, + "__START__ -> classify [label=\"\"]", + "classify -> billing_agent [label=\" billing\"]", + "classify -> tech_flow [label=\" tech\"]", + "classify -> fallback [label=\" __DEFAULT__\"]", + "billing_agent -> summarize [label=\"\"]", + "tech_flow -> summarize [label=\"\"]", + "fallback -> summarize [label=\"\"]", + "billing_agent -> lookup_invoice [color=\"#64748B\" style=dashed]", + LIGHT_END, + "summarize -> __END__")); + } + + @Test + void plot_agentTree_drawsAgentsAndToolsWithoutStartOrEnd() { + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(assistantAgent()), false); + + assertThat(dot) + .isEqualTo( + expectedDot( + LIGHT_GRAPH, + LIGHT_NODE, + LIGHT_EDGE, + "assistant [label=<✦ assistant>" + + " fillcolor=\"#FFFFFF\" style=\"rounded,filled\" tooltip=Agent]", + "helper [label=<✦ helper>" + + " fillcolor=\"#FFFFFF\" style=\"rounded,filled\" tooltip=Agent]", + "lookup_invoice [label=<🔧" + + " lookup_invoice> fillcolor=\"#FFFFFF\" style=\"rounded,filled,dashed\"" + + " tooltip=Tool]", + "assistant -> helper [label=\"\"]", + "helper -> lookup_invoice [color=\"#64748B\" style=dashed]")); + } + + @Test + void plot_darkMode_drawsWithTheDarkPalette() { + GraphNode techFlow = + AppGraph.graphNodeOf(supportWorkflow()).graph().nodes().stream() + .filter(node -> node.name().equals("tech_flow")) + .findFirst() + .orElseThrow(); + + String dot = GraphPlotter.plot(techFlow, true); + + assertThat(dot) + .isEqualTo( + expectedDot( + "graph [bgcolor=\"#0F172A\" fontname=Helvetica nodesep=0.5 pad=0.5 ranksep=0.8" + + " splines=spline]", + "node [color=\"#475569\" fillcolor=\"#1E293B\" fontcolor=\"#F8FAFC\"" + + " fontname=Helvetica fontsize=12 margin=\"0.25,0.15\" penwidth=1.5" + + " shape=rect style=\"rounded,filled\"]", + "edge [arrowhead=vee arrowsize=0.7 color=\"#94A3B8\" fontcolor=\"#CBD5E1\"" + + " fontname=Helvetica fontsize=10 penwidth=1.2]", + "diagnose [label=diagnose fillcolor=\"#1E293B\" style=\"rounded,filled\"" + + " tooltip=\"Node\"]", + "fix [label=fix fillcolor=\"#1E293B\" style=\"rounded,filled\" tooltip=\"Node\"]", + "__START__ [label=START color=\"#047857\" fillcolor=\"#059669\" fixedsize=true" + + " fontcolor=\"#F8FAFC\" fontname=\"Helvetica-Bold\" shape=oval" + + " style=filled width=0.9]", + "__START__ -> diagnose [label=\"\"]", + "diagnose -> fix [label=\"\"]", + "__END__ [label=END color=\"#B91C1C\" fillcolor=\"#DC2626\" fixedsize=true" + + " fontcolor=\"#F8FAFC\" fontname=\"Helvetica-Bold\" shape=oval" + + " style=filled width=0.9]", + "fix -> __END__")); + } + + @Test + void plot_routesWithoutDefaultJoinsAndEscapedNames_drawsExpectedShapesAndLabels() { + String quoted = "say \"hi\""; + GraphNode root = + workflowNode( + "edge_cases", + ImmutableList.of( + new GraphNode(START, "start"), + new GraphNode("pick", "node"), + new GraphNode( + "fn", + "function", + null, + ImmutableList.of(new GraphTool("a
⚠️ [NO DEFAULT]>" + + " fillcolor=\"#FFFFFF\" height=1.2 margin=\"0.0,0.0\" shape=diamond" + + " style=filled tooltip=\"Node\" width=0.8]", + "fn [label=<ƒ fn

⚠️ [NO DEFAULT]> fillcolor=\"#FFFFFF\"" + + " height=1.2 margin=\"0.0,0.0\" shape=diamond style=filled" + + " tooltip=Function width=0.8]", + "merge [label=<⌵ merge>" + + " fillcolor=\"#FFFFFF\" margin=\"0.05,0.05\" shape=oval style=filled" + + " tooltip=Join]", + "\"say \\\"hi\\\"\" [label=\"say \\\"hi\\\"\" fillcolor=\"#FFFFFF\"" + + " style=\"rounded,filled\" tooltip=\"Node\"]", + "\"a🔧" + + " a<b&"c'> fillcolor=\"#FFFFFF\"" + + " style=\"rounded,filled,dashed\" tooltip=Tool]", + LIGHT_START, + "__START__ -> pick [label=\"\"]", + "pick -> fn [label=\" a\"]", + "pick -> merge [label=\" True\"]", + "fn -> merge [label=\" ['x', 'y']\"]", + "fn -> \"say \\\"hi\\\"\" [label=\" 1\"]", + "merge -> \"say \\\"hi\\\"\" [label=\"\"]", + "fn -> \"a __END__")); + } + + @Test + void plot_backslashesQuotesAndListRoutes_quotesIdentifiersAndListLabels() { + String backslash = "a\\b"; + String escapedQuotes = "say \\\"hi\\\""; + GraphNode root = + workflowNode( + "quirks", + ImmutableList.of( + new GraphNode(START, "start"), + new GraphNode("pick", "node"), + new GraphNode(backslash, "node"), + new GraphNode(escapedQuotes, "node")), + ImmutableList.of( + edge(START, "pick", null), + edge( + "pick", + backslash, + ImmutableList.of( + new Route.Tag("won't"), new Route.Tag("x\"y"), new Route.Tag("p\\q"))), + edge("pick", escapedQuotes, Route.Default.INSTANCE))); + + String dot = GraphPlotter.plot(root, false); + + assertThat(dot) + .isEqualTo( + expectedDot( + LIGHT_GRAPH, + LIGHT_NODE, + LIGHT_EDGE, + "pick [label=pick fillcolor=\"#FFFFFF\" height=1.2 margin=\"0.0,0.0\"" + + " shape=diamond style=filled tooltip=\"Node\" width=0.8]", + "\"a\\b\" [label=\"a\\b\" fillcolor=\"#FFFFFF\" style=\"rounded,filled\"" + + " tooltip=\"Node\"]", + "\"say \\\"hi\\\"\" [label=\"say \\\"hi\\\"\" fillcolor=\"#FFFFFF\"" + + " style=\"rounded,filled\" tooltip=\"Node\"]", + LIGHT_START, + "__START__ -> pick [label=\"\"]", + "pick -> \"a\\b\" [label=\" [\\\"won't\\\", 'x\\\"y', 'p\\\\q']\"]", + "pick -> \"say \\\"hi\\\"\" [label=\" __DEFAULT__\"]", + LIGHT_END, + "\"a\\b\" -> __END__", + "\"say \\\"hi\\\"\" -> __END__")); + } + + @Test + void plot_falseRoute_labelsItAndMakesItsSourceConditional() { + Node pick = step("pick"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, pick), new Edge(pick, step("no"), new Route.Flag(false))) + .build(); + + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(workflow), false); + + assertThat(dot).contains("pick -> no [label=\" False\"]"); + assertThat(dot) + .contains("pick [label=
⚠️ [NO DEFAULT]>"); + } + + @Test + void plot_trueAndFalseRoutes_warnNoDefault() { + Node pick = step("pick"); + Node count = step("count"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, pick), + new Edge(pick, step("yes"), new Route.Flag(true)), + new Edge(pick, step("no"), new Route.Flag(false)), + new Edge(Start.INSTANCE, count), + new Edge(count, step("zero"), new Route.Num(0)), + new Edge(count, step("one"), new Route.Num(1))) + .build(); + + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(workflow), false); + + assertThat(dot) + .contains("pick [label=
⚠️ [NO DEFAULT]>"); + assertThat(dot) + .contains("count [label=
⚠️ [NO DEFAULT]>"); + } + + @Test + void plot_plainNameThatLooksLikeHtml_quotesItInsteadOfParsingMarkup() { + GraphNode root = + workflowNode("root", ImmutableList.of(new GraphNode("", "node")), ImmutableList.of()); + + String dot = GraphPlotter.plot(root, false); + + assertThat(dot).contains("\t\"\" [label=\"\" fillcolor="); + } + + @Test + void plot_plainNameThatLooksLikePort_quotesItInsteadOfReadingAPort() { + GraphNode root = + workflowNode("root", ImmutableList.of(new GraphNode("a:b", "node")), ImmutableList.of()); + + String dot = GraphPlotter.plot(root, false); + + assertThat(dot).contains("\t\"a:b\" -> __END__\n"); + } + + @Test + void plot_routedAgentWithDefaultRoute_drawsAnIconDiamondWithoutWarning() { + Node router = llmAgent("router", List.of(), List.of()); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, router), + new Edge(router, step("special"), new Route.Tag("x")), + new Edge(router, step("usual"), Route.Default.INSTANCE)) + .build(); + + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(workflow), false); + + assertThat(dot) + .contains( + "router [label=<✦ router>" + + " fillcolor=\"#FFFFFF\" height=1.2 margin=\"0.0,0.0\" shape=diamond" + + " style=filled tooltip=Agent width=0.8]"); + assertThat(dot).doesNotContain("NO DEFAULT"); + } + + @Test + void plot_agentsWithOnlyToolEdges_endAtEndAndShareOneToolNode() { + BaseTool sharedTool = tool("t"); + Node step = step("step"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, step), + new Edge(step, llmAgent("first_agent", List.of(sharedTool), List.of())), + new Edge(step, llmAgent("second_agent", List.of(sharedTool), List.of()))) + .build(); + + String dot = GraphPlotter.plot(AppGraph.graphNodeOf(workflow), false); + + assertThat(dot).contains("\tfirst_agent -> __END__\n"); + assertThat(dot).contains("\tsecond_agent -> __END__\n"); + assertThat(dot).doesNotContain("step -> __END__"); + assertThat(dot.lines().filter(line -> line.startsWith("\tt [")).count()).isEqualTo(1); + } + + private static GraphNode workflowNode( + String name, ImmutableList nodes, ImmutableList edges) { + return new GraphNode(name, "workflow", null, null, new WorkflowGraph(nodes, edges)); + } + + private static GraphEdge edge(String from, String to, @Nullable Object route) { + return new GraphEdge(new GraphNodeRef(from, "node"), new GraphNodeRef(to, "node"), route); + } + + private static String expectedDot(String... statements) { + StringBuilder dot = new StringBuilder("// Workflow Visualization\ndigraph {\n"); + for (String statement : statements) { + dot.append('\t').append(statement).append('\n'); + } + return dot.append("}\n").toString(); + } +}