diff --git a/dev/src/main/java/com/google/adk/web/controller/AppGraph.java b/dev/src/main/java/com/google/adk/web/controller/AppGraph.java index 5c7e31646..94a6bad88 100644 --- a/dev/src/main/java/com/google/adk/web/controller/AppGraph.java +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraph.java @@ -31,10 +31,15 @@ import com.google.adk.workflow.Start; import com.google.adk.workflow.Workflow; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Sets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -99,6 +104,63 @@ record GraphNode( GraphNode(String name, String type) { this(name, type, /* subAgents= */ null, /* tools= */ null, /* graph= */ null); } + + /** + * Returns the node at the {@code /}-separated {@code path}, or empty if no child matches. + * Accepts a path that starts with this node's name, and ignores an {@code @} suffix on + * any segment. + */ + Optional find(String path) { + List names = + Arrays.stream(path.split("/", -1)) + .map(segment -> segment.split("@", -1)[0]) + .filter(name -> !name.isEmpty()) + .toList(); + List below = + !names.isEmpty() && names.get(0).equals(name) ? names.subList(1, names.size()) : names; + Optional node = Optional.of(this); + for (String childName : below) { + node = + node.flatMap( + parent -> + parent.children().stream() + .filter(child -> child.name().equals(childName)) + .findFirst()); + } + return node; + } + + /** + * Returns every node below this one that has a graph, keyed by its {@code /}-separated path + * from this node's name, in depth-first order. + */ + ImmutableMap nestedWorkflowsByPath() { + Map workflows = new LinkedHashMap<>(); + collectNestedWorkflows(this, name, workflows); + return ImmutableMap.copyOf(workflows); + } + + private static void collectNestedWorkflows( + GraphNode node, String path, Map workflows) { + for (GraphNode child : node.children()) { + String childPath = path + "/" + child.name(); + if (child.graph() != null) { + workflows.put(childPath, child); + } + collectNestedWorkflows(child, childPath, workflows); + } + } + + private ImmutableList children() { + ImmutableList.Builder children = ImmutableList.builder(); + if (graph != null) { + children.addAll(graph.nodes()); + } + if (subAgents != null) { + children.addAll(subAgents); + } + return children.build(); + } } record GraphTool(String name, String type) {} diff --git a/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java b/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java index 90460821d..d4ebb85f2 100644 --- a/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java @@ -16,23 +16,35 @@ package com.google.adk.web.controller; +import com.google.adk.web.controller.AppGraph.GraphNode; import com.google.adk.web.service.RunnerService; import com.google.adk.workflow.Node; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.util.Locale; import java.util.Optional; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -/** Serves the app structure that the Dev UI navigates and draws its graph views from. */ +/** Serves the app structure and graph images that the Dev UI draws its graph views from. */ @RestController class AppGraphController { private static final Logger log = LoggerFactory.getLogger(AppGraphController.class); + private static final ImmutableSet TRUE_QUERY_VALUES = + ImmutableSet.of("1", "on", "t", "true", "y", "yes"); + + private static final ImmutableSet FALSE_QUERY_VALUES = + ImmutableSet.of("0", "off", "f", "false", "n", "no"); + private final RunnerService runnerService; AppGraphController(RunnerService runnerService) { @@ -44,6 +56,55 @@ AppGraph buildGraph(@PathVariable String appName) { return AppGraph.of(appName, loadRoot(appName)); } + /** + * Returns DOT source for the root and every workflow below it, keyed by node path, or a single + * {@link DotGraph} for the subtree a non-empty {@code node} names. + */ + @GetMapping("/dev/apps/{appName}/build_graph_image") + Object buildGraphImage( + @PathVariable String appName, + @RequestParam(name = "dark_mode", required = false) @Nullable String darkModeValue, + @RequestParam(name = "node", defaultValue = "") String nodePath) { + boolean darkMode = parseDarkMode(darkModeValue); + GraphNode target = + AppGraph.graphNodeOf(loadRoot(appName)) + .find(nodePath) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Node not found.")); + if (!nodePath.isEmpty()) { + // The Dev UI's drill-in dialog reads a bare DotGraph. + return new DotGraph(GraphPlotter.plot(target, darkMode)); + } + // Keys below the root start with its name: the Dev UI drops each key's first segment. + ImmutableMap.Builder graphs = ImmutableMap.builder(); + graphs.put("", new DotGraph(GraphPlotter.plot(target, darkMode))); + target + .nestedWorkflowsByPath() + .forEach( + (path, workflow) -> + graphs.put(path, new DotGraph(GraphPlotter.plot(workflow, darkMode)))); + return graphs.buildOrThrow(); + } + + /** DOT source for one graph, under the {@code dotSrc} key the Dev UI reads. */ + record DotGraph(String dotSrc) {} + + /** Returns whether {@code value} turns dark mode on, or throws the error response to send. */ + private static boolean parseDarkMode(@Nullable String value) { + // Not a defaultValue, which Spring would also apply to an empty value. + if (value == null) { + return false; + } + String lowercase = value.toLowerCase(Locale.ROOT); + if (TRUE_QUERY_VALUES.contains(lowercase)) { + return true; + } + if (FALSE_QUERY_VALUES.contains(lowercase)) { + return false; + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dark_mode must be true or false."); + } + /** Returns the root of the app {@code appName}, or throws the error response to send. */ private Node loadRoot(String appName) { Optional root; 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/AppGraphControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java index 1769d840a..f805f89bd 100644 --- a/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java @@ -16,14 +16,21 @@ package com.google.adk.web.controller; +import static com.google.adk.web.testing.GraphFixtures.step; +import static com.google.common.truth.Truth.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.apps.App; import com.google.adk.web.AppLoader; import com.google.adk.web.InMemoryAppLoader; import com.google.adk.web.testing.GraphFixtures; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.Start; +import com.google.adk.workflow.Workflow; import com.google.common.collect.ImmutableList; import java.util.Optional; import org.hamcrest.Matchers; @@ -39,15 +46,27 @@ @AutoConfigureMockMvc class AppGraphControllerTest { - /** Serves a workflow app and an agent app, and lists one more app that fails to load. */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Serves a workflow app, an agent app and a two-level nested workflow app, and lists one more app + * that fails to load. + */ @TestConfiguration static class Apps { @Bean AppLoader appLoader() { + Workflow inner = + Workflow.builder().name("inner").edges(new Edge(Start.INSTANCE, step("leaf"))).build(); + Workflow middle = + Workflow.builder().name("middle").edges(new Edge(Start.INSTANCE, inner)).build(); + Workflow outer = + Workflow.builder().name("outer").edges(new Edge(Start.INSTANCE, middle)).build(); InMemoryAppLoader apps = new InMemoryAppLoader( App.builder().name("support_app").rootNode(GraphFixtures.supportWorkflow()).build(), - App.builder().name("assistant").rootAgent(GraphFixtures.assistantAgent()).build()); + App.builder().name("assistant").rootAgent(GraphFixtures.assistantAgent()).build(), + App.builder().name("nested").rootNode(outer).build()); return new AppLoader() { @Override public ImmutableList listApps() { @@ -101,4 +120,126 @@ void buildGraph_appFailsToLoad_returnsServerError() throws Exception { .perform(get("/dev/apps/broken/build_graph")) .andExpect(status().isInternalServerError()); } + + @Test + void buildGraphImage_workflowApp_drawsTheRootAndEveryNestedWorkflow() throws Exception { + JsonNode graphs = json("/dev/apps/support_app/build_graph_image?dark_mode=false"); + + assertThat(ImmutableList.copyOf(graphs.fieldNames())) + .containsExactly("", "support/tech_flow") + .inOrder(); + assertThat(dotSrc(graphs, "")).contains("__START__ -> classify"); + assertThat(dotSrc(graphs, "")).contains("classify -> tech_flow [label=\" tech\"]"); + assertThat(dotSrc(graphs, "")).contains("bgcolor=\"#F8FAFC\""); + assertThat(dotSrc(graphs, "support/tech_flow")).contains("diagnose -> fix"); + } + + @Test + void buildGraphImage_nestedWorkflows_prefixesNonRootKeysWithTheRootName() throws Exception { + JsonNode graphs = json("/dev/apps/nested/build_graph_image"); + + assertThat(ImmutableList.copyOf(graphs.fieldNames())) + .containsExactly("", "outer/middle", "outer/middle/inner") + .inOrder(); + assertThat(dotSrc(graphs, "outer/middle/inner")).contains("__START__ -> leaf"); + } + + @Test + void buildGraphImage_darkModeValues_selectDarkOrLightBackground() throws Exception { + assertThat(dotSrc(json("/dev/apps/support_app/build_graph_image?dark_mode=true"), "")) + .contains("bgcolor=\"#0F172A\""); + assertThat(dotSrc(json("/dev/apps/support_app/build_graph_image?dark_mode=YES"), "")) + .contains("bgcolor=\"#0F172A\""); + assertThat(dotSrc(json("/dev/apps/support_app/build_graph_image"), "")) + .contains("bgcolor=\"#F8FAFC\""); + assertThat(dotSrc(json("/dev/apps/support_app/build_graph_image?dark_mode=0"), "")) + .contains("bgcolor=\"#F8FAFC\""); + } + + @Test + void buildGraphImage_darkMode_appliesToNestedAndDrillInGraphs() throws Exception { + JsonNode graphs = json("/dev/apps/support_app/build_graph_image?dark_mode=true"); + JsonNode nested = json("/dev/apps/support_app/build_graph_image?node=tech_flow&dark_mode=true"); + + assertThat(dotSrc(graphs, "support/tech_flow")).contains("bgcolor=\"#0F172A\""); + assertThat(nested.get("dotSrc").asText()).contains("bgcolor=\"#0F172A\""); + } + + @Test + void buildGraphImage_node_returnsBareDotGraphForTarget() throws Exception { + JsonNode nested = json("/dev/apps/support_app/build_graph_image?node=tech_flow"); + JsonNode agent = json("/dev/apps/support_app/build_graph_image?node=support/billing_agent"); + + assertThat(ImmutableList.copyOf(nested.fieldNames())).containsExactly("dotSrc"); + assertThat(nested.get("dotSrc").asText()).contains("diagnose -> fix"); + assertThat(ImmutableList.copyOf(agent.fieldNames())).containsExactly("dotSrc"); + assertThat(agent.get("dotSrc").asText()).contains("billing_agent -> lookup_invoice"); + assertThat(agent.get("dotSrc").asText()).doesNotContain("__START__"); + } + + @Test + void buildGraphImage_emptyNode_returnsThePathKeyedMap() throws Exception { + JsonNode graphs = json("/dev/apps/support_app/build_graph_image?node="); + + assertThat(ImmutableList.copyOf(graphs.fieldNames())) + .containsExactly("", "support/tech_flow") + .inOrder(); + } + + @Test + void buildGraphImage_nodeNamingTheRoot_returnsTheRootGraph() throws Exception { + JsonNode root = json("/dev/apps/support_app/build_graph_image?node=support"); + + assertThat(ImmutableList.copyOf(root.fieldNames())).containsExactly("dotSrc"); + assertThat(root.get("dotSrc").asText()).contains("__START__ -> classify"); + } + + @Test + void buildGraphImage_agentApp_drawsItsTree() throws Exception { + JsonNode graphs = json("/dev/apps/assistant/build_graph_image"); + + assertThat(ImmutableList.copyOf(graphs.fieldNames())).containsExactly(""); + assertThat(dotSrc(graphs, "")).contains("assistant -> helper"); + assertThat(dotSrc(graphs, "")).doesNotContain("__END__"); + } + + @Test + void buildGraphImage_unknownNode_returnsNotFound() throws Exception { + mockMvc + .perform(get("/dev/apps/support_app/build_graph_image?node=tech_flow/missing")) + .andExpect(status().isNotFound()); + } + + @Test + void buildGraphImage_unknownApp_returnsNotFound() throws Exception { + mockMvc.perform(get("/dev/apps/missing/build_graph_image")).andExpect(status().isNotFound()); + } + + @Test + void buildGraphImage_invalidDarkMode_returnsBadRequest() throws Exception { + mockMvc + .perform(get("/dev/apps/support_app/build_graph_image?dark_mode=maybe")) + .andExpect(status().isBadRequest()); + } + + @Test + void buildGraphImage_emptyDarkMode_returnsBadRequest() throws Exception { + mockMvc + .perform(get("/dev/apps/support_app/build_graph_image?dark_mode=")) + .andExpect(status().isBadRequest()); + } + + private JsonNode json(String path) throws Exception { + return objectMapper.readTree( + mockMvc + .perform(get(path)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString()); + } + + private static String dotSrc(JsonNode graphs, String path) { + return graphs.get(path).get("dotSrc").asText(); + } } diff --git a/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java b/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java index 7a3a44bcc..92a8b8bef 100644 --- a/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java @@ -42,6 +42,7 @@ import com.google.adk.workflow.Start; import com.google.adk.workflow.Workflow; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import io.reactivex.rxjava3.core.Flowable; import java.util.List; import org.jspecify.annotations.Nullable; @@ -213,6 +214,64 @@ void graphNodeOf_workflowWithoutEdges_hasNoGraph() { assertThat(objectMapper.valueToTree(node).has("graph")).isFalse(); } + @Test + void find_pathThroughGraphsAndSubAgents_returnsTheNode() { + GraphNode support = AppGraph.graphNodeOf(supportWorkflow()); + GraphNode assistant = AppGraph.graphNodeOf(assistantAgent()); + + assertThat(support.find("")).hasValue(support); + assertThat(support.find("tech_flow").map(GraphNode::type)).hasValue("workflow"); + assertThat(support.find("support/tech_flow/diagnose").map(GraphNode::name)) + .hasValue("diagnose"); + assertThat(support.find("/tech_flow/fix/").map(GraphNode::name)).hasValue("fix"); + assertThat(assistant.find("helper").map(GraphNode::tools)) + .hasValue(ImmutableList.of(new GraphTool("lookup_invoice", "tool"))); + } + + @Test + void find_segmentsWithRunIdSuffix_ignoreTheSuffix() { + GraphNode support = AppGraph.graphNodeOf(supportWorkflow()); + + assertThat(support.find("support@1/tech_flow@2/diagnose@1").map(GraphNode::name)) + .hasValue("diagnose"); + } + + @Test + void find_nameMatchingNoChild_returnsEmpty() { + GraphNode support = AppGraph.graphNodeOf(supportWorkflow()); + + assertThat(support.find("missing")).isEmpty(); + assertThat(support.find("tech_flow/missing")).isEmpty(); + assertThat(support.find("classify/anything")).isEmpty(); + } + + @Test + void nestedWorkflowsByPath_nestedWorkflows_keysEachByItsPathFromThisNode() { + Workflow inner = + Workflow.builder().name("inner").edges(new Edge(Start.INSTANCE, step("leaf"))).build(); + Workflow middle = + Workflow.builder().name("middle").edges(new Edge(Start.INSTANCE, inner)).build(); + Workflow outer = + Workflow.builder() + .name("outer") + .edges(new Edge(Start.INSTANCE, middle), new Edge(middle, step("z"))) + .build(); + GraphNode root = AppGraph.graphNodeOf(outer); + + ImmutableMap fromRoot = root.nestedWorkflowsByPath(); + ImmutableMap fromMiddle = + root.find("middle").orElseThrow().nestedWorkflowsByPath(); + + assertThat(fromRoot.keySet()).containsExactly("outer/middle", "outer/middle/inner").inOrder(); + assertThat(fromRoot.get("outer/middle/inner").name()).isEqualTo("inner"); + assertThat(fromMiddle.keySet()).containsExactly("middle/inner"); + } + + @Test + void nestedWorkflowsByPath_agentTree_isEmpty() { + assertThat(AppGraph.graphNodeOf(assistantAgent()).nestedWorkflowsByPath()).isEmpty(); + } + /** Returns the edge's {@code route} as JSON text, or null when the edge omits it. */ private static @Nullable String routeJson(AppGraph.GraphEdge edge) { JsonNode route = objectMapper.valueToTree(edge).get("route"); 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(); + } +}