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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions dev/src/main/java/com/google/adk/web/controller/AppGraph.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 @<run id>} suffix on
* any segment.
*/
Optional<GraphNode> find(String path) {
List<String> names =
Arrays.stream(path.split("/", -1))
.map(segment -> segment.split("@", -1)[0])
.filter(name -> !name.isEmpty())
.toList();
List<String> below =
!names.isEmpty() && names.get(0).equals(name) ? names.subList(1, names.size()) : names;
Optional<GraphNode> 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<String, GraphNode> nestedWorkflowsByPath() {
Map<String, GraphNode> workflows = new LinkedHashMap<>();
collectNestedWorkflows(this, name, workflows);
return ImmutableMap.copyOf(workflows);
}

private static void collectNestedWorkflows(
GraphNode node, String path, Map<String, GraphNode> 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<GraphNode> children() {
ImmutableList.Builder<GraphNode> 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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> TRUE_QUERY_VALUES =
ImmutableSet.of("1", "on", "t", "true", "y", "yes");

private static final ImmutableSet<String> FALSE_QUERY_VALUES =
ImmutableSet.of("0", "off", "f", "false", "n", "no");

private final RunnerService runnerService;

AppGraphController(RunnerService runnerService) {
Expand All @@ -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<String, DotGraph> 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<Node> root;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> listApps() {
Expand Down Expand Up @@ -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();
}
}
Loading
Loading