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/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");