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 new file mode 100644 index 000000000..5c7e31646 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraph.java @@ -0,0 +1,190 @@ +/* + * 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.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.tools.BaseTool; +import com.google.adk.workflow.Edge; +import com.google.adk.workflow.JoinNode; +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 com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * An app's root and everything under it, as the Dev UI reads it from {@code + * /dev/apps/{appName}/build_graph}. + * + *

Keys are snake_case, though the server's mapper writes camelCase, because that is how the Dev + * UI reads this payload. + */ +record AppGraph(String name, @JsonProperty("root_agent") GraphNode rootAgent) { + + static final String AGENT_NODE_TYPE = "agent"; + static final String WORKFLOW_NODE_TYPE = "workflow"; + static final String TOOL_NODE_TYPE = "tool"; + static final String JOIN_NODE_TYPE = "join"; + + /** The name a toolset without a class name, such as an anonymous class, is drawn under. */ + private static final String TOOLSET_NAME = "Toolset"; + + /** Describes the app {@code appName}, rooted at {@code root}. */ + static AppGraph of(String appName, Node root) { + return new AppGraph(appName, graphNodeOf(root)); + } + + /** Describes {@code node} and everything under it. */ + static GraphNode graphNodeOf(Node node) { + if (node instanceof Workflow workflow) { + return new GraphNode( + node.name(), + typeOf(node), + /* subAgents= */ null, + /* tools= */ null, + workflowGraphOf(workflow)); + } + if (node instanceof BaseAgent agent) { + return new GraphNode( + agent.name(), + typeOf(agent), + agent.subAgents().stream().map(AppGraph::graphNodeOf).collect(toImmutableList()), + agent instanceof LlmAgent llmAgent ? toolsOf(llmAgent) : null, + /* graph= */ null); + } + return new GraphNode(node.name(), typeOf(node)); + } + + /** + * One node of an app: an agent with its sub-agents and tools, a workflow with its graph, or any + * other node. + * + * @param type how the graph draws the node: {@code agent}, {@code workflow}, {@code join}, {@code + * start} or {@code node} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record GraphNode( + String name, + String type, + @JsonProperty("sub_agents") @Nullable ImmutableList subAgents, + @Nullable ImmutableList tools, + @Nullable WorkflowGraph graph) { + + GraphNode(String name, String type) { + this(name, type, /* subAgents= */ null, /* tools= */ null, /* graph= */ null); + } + } + + record GraphTool(String name, String type) {} + + record WorkflowGraph(ImmutableList nodes, ImmutableList edges) {} + + /** An edge between two nodes; {@code route} is absent on an unconditional one. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + record GraphEdge( + @JsonProperty("from_node") GraphNodeRef from, + @JsonProperty("to_node") GraphNodeRef to, + @Nullable Object route) {} + + record GraphNodeRef(String name, String type) {} + + /** Returns the node type string used by the Dev UI graph renderer for {@code node}. */ + private static String typeOf(Node node) { + if (node instanceof Start) { + return "start"; + } + if (node instanceof Workflow) { + return WORKFLOW_NODE_TYPE; + } + if (node instanceof BaseAgent) { + return AGENT_NODE_TYPE; + } + if (node instanceof JoinNode) { + return JOIN_NODE_TYPE; + } + return "node"; + } + + private static @Nullable WorkflowGraph workflowGraphOf(Workflow workflow) { + if (workflow.edges().isEmpty()) { + return null; + } + // Preserve first-appearance order and deduplicate by node identity. + Set seen = Sets.newIdentityHashSet(); + List nodes = new ArrayList<>(); + for (Edge edge : workflow.edges()) { + for (Node node : List.of(edge.from(), edge.to())) { + if (seen.add(node)) { + nodes.add(node); + } + } + } + return new WorkflowGraph( + nodes.stream().map(AppGraph::graphNodeOf).collect(toImmutableList()), + workflow.edges().stream() + .map(edge -> new GraphEdge(refOf(edge.from()), refOf(edge.to()), routeOrNull(edge))) + .collect(toImmutableList())); + } + + private static GraphNodeRef refOf(Node node) { + return new GraphNodeRef(node.name(), typeOf(node)); + } + + /** + * Returns {@code edge}'s routes as null when the edge is unconditional, a single route as itself, + * which serializes as a scalar, and any other route list as a list. + */ + private static @Nullable Object routeOrNull(Edge edge) { + if (edge.isUnconditional()) { + return null; + } + ImmutableList routes = edge.routes(); + return routes.size() == 1 ? routes.get(0) : routes; + } + + /** The agent's tools and toolsets in declaration order, minus any named after a sub-agent. */ + private static ImmutableList toolsOf(LlmAgent agent) { + ImmutableSet subAgentNames = + agent.subAgents().stream().map(BaseAgent::name).collect(toImmutableSet()); + return agent.toolsUnion().stream() + .map(AppGraph::toolName) + .filter(name -> !subAgentNames.contains(name)) + .map(name -> new GraphTool(name, TOOL_NODE_TYPE)) + .collect(toImmutableList()); + } + + /** Names a tool by its name, and a toolset, which has none, by its class. */ + private static String toolName(Object toolOrToolset) { + if (toolOrToolset instanceof BaseTool tool) { + return tool.name(); + } + String className = toolOrToolset.getClass().getSimpleName(); + return className.isEmpty() ? TOOLSET_NAME : className; + } +} 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 new file mode 100644 index 000000000..90460821d --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/AppGraphController.java @@ -0,0 +1,59 @@ +/* + * 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 com.google.adk.web.service.RunnerService; +import com.google.adk.workflow.Node; +import java.util.Optional; +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.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** Serves the app structure that the Dev UI navigates and draws its graph views from. */ +@RestController +class AppGraphController { + + private static final Logger log = LoggerFactory.getLogger(AppGraphController.class); + + private final RunnerService runnerService; + + AppGraphController(RunnerService runnerService) { + this.runnerService = runnerService; + } + + @GetMapping("/dev/apps/{appName}/build_graph") + AppGraph buildGraph(@PathVariable String appName) { + return AppGraph.of(appName, loadRoot(appName)); + } + + /** Returns the root of the app {@code appName}, or throws the error response to send. */ + private Node loadRoot(String appName) { + Optional root; + try { + root = runnerService.loadRoot(appName); + } catch (RuntimeException e) { + log.warn("The requested app failed to load for its graph.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "App failed to load.", e); + } + return root.orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found.")); + } +} 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 new file mode 100644 index 000000000..1769d840a --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphControllerTest.java @@ -0,0 +1,104 @@ +/* + * 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 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.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.common.collect.ImmutableList; +import java.util.Optional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Bean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class AppGraphControllerTest { + + /** Serves a workflow app and an agent app, and lists one more app that fails to load. */ + @TestConfiguration + static class Apps { + @Bean + AppLoader appLoader() { + InMemoryAppLoader apps = + new InMemoryAppLoader( + App.builder().name("support_app").rootNode(GraphFixtures.supportWorkflow()).build(), + App.builder().name("assistant").rootAgent(GraphFixtures.assistantAgent()).build()); + return new AppLoader() { + @Override + public ImmutableList listApps() { + return ImmutableList.builder().addAll(apps.listApps()).add("broken").build(); + } + + @Override + public Optional loadApp(String appName) { + if (appName.equals("broken")) { + throw new IllegalStateException("cannot build the app"); + } + return apps.loadApp(appName); + } + }; + } + } + + @Autowired private MockMvc mockMvc; + + @Test + void buildGraph_workflowApp_describesItsGraphInSnakeCase() throws Exception { + mockMvc + .perform(get("/dev/apps/support_app/build_graph")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("support_app")) + .andExpect(jsonPath("$.root_agent.type").value("workflow")) + .andExpect(jsonPath("$.root_agent.graph.nodes[*].name", Matchers.hasItem("tech_flow"))) + .andExpect(jsonPath("$.root_agent.graph.nodes[2].sub_agents").isArray()) + .andExpect(jsonPath("$.root_agent.graph.edges[1].from_node.name").value("classify")) + .andExpect(jsonPath("$.root_agent.graph.edges[1].route").value("billing")) + .andExpect(jsonPath("$.root_agent.graph.edges[0].route").doesNotExist()); + } + + @Test + void buildGraph_agentApp_describesItsTree() throws Exception { + mockMvc + .perform(get("/dev/apps/assistant/build_graph")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.root_agent.type").value("agent")) + .andExpect(jsonPath("$.root_agent.sub_agents[0].name").value("helper")); + } + + @Test + void buildGraph_unknownApp_returnsNotFound() throws Exception { + mockMvc.perform(get("/dev/apps/missing/build_graph")).andExpect(status().isNotFound()); + } + + @Test + void buildGraph_appFailsToLoad_returnsServerError() throws Exception { + mockMvc + .perform(get("/dev/apps/broken/build_graph")) + .andExpect(status().isInternalServerError()); + } +} 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 new file mode 100644 index 000000000..7a3a44bcc --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/AppGraphTest.java @@ -0,0 +1,232 @@ +/* + * 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.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.JsonBaseModel; +import com.google.adk.agents.ReadonlyContext; +import com.google.adk.agents.SequentialAgent; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.BaseToolset; +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.JoinNode; +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 io.reactivex.rxjava3.core.Flowable; +import java.util.List; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class AppGraphTest { + + private static final ObjectMapper objectMapper = JsonBaseModel.getMapper(); + + @Test + void of_workflowRoot_describesStartRoutesAndNestedWorkflows() throws Exception { + JsonNode json = objectMapper.valueToTree(AppGraph.of("support_app", supportWorkflow())); + + assertThat(json) + .isEqualTo( + objectMapper.readTree( + """ + {"name": "support_app", "root_agent": {"name": "support", "type": "workflow", + "graph": { + "nodes": [ + {"name": "__START__", "type": "start"}, + {"name": "classify", "type": "node"}, + {"name": "billing_agent", "type": "agent", "sub_agents": [], + "tools": [{"name": "lookup_invoice", "type": "tool"}]}, + {"name": "tech_flow", "type": "workflow", "graph": { + "nodes": [ + {"name": "__START__", "type": "start"}, + {"name": "diagnose", "type": "node"}, + {"name": "fix", "type": "node"}], + "edges": [ + {"from_node": {"name": "__START__", "type": "start"}, + "to_node": {"name": "diagnose", "type": "node"}}, + {"from_node": {"name": "diagnose", "type": "node"}, + "to_node": {"name": "fix", "type": "node"}}]}}, + {"name": "fallback", "type": "node"}, + {"name": "summarize", "type": "node"}], + "edges": [ + {"from_node": {"name": "__START__", "type": "start"}, + "to_node": {"name": "classify", "type": "node"}}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "billing_agent", "type": "agent"}, "route": "billing"}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "tech_flow", "type": "workflow"}, "route": "tech"}, + {"from_node": {"name": "classify", "type": "node"}, + "to_node": {"name": "fallback", "type": "node"}, "route": "__DEFAULT__"}, + {"from_node": {"name": "billing_agent", "type": "agent"}, + "to_node": {"name": "summarize", "type": "node"}}, + {"from_node": {"name": "tech_flow", "type": "workflow"}, + "to_node": {"name": "summarize", "type": "node"}}, + {"from_node": {"name": "fallback", "type": "node"}, + "to_node": {"name": "summarize", "type": "node"}}]}}} + """)); + } + + @Test + void of_agentRoot_describesSubAgentsAndTools() throws Exception { + JsonNode json = objectMapper.valueToTree(AppGraph.of("assistant", assistantAgent())); + + assertThat(json) + .isEqualTo( + objectMapper.readTree( + """ + {"name": "assistant", "root_agent": {"name": "assistant", "type": "agent", + "tools": [], + "sub_agents": [{"name": "helper", "type": "agent", "sub_agents": [], + "tools": [{"name": "lookup_invoice", "type": "tool"}]}]}} + """)); + } + + @Test + void graphNodeOf_nonLlmAgent_describesSubAgentsWithoutTools() { + GraphNode node = + AppGraph.graphNodeOf( + SequentialAgent.builder() + .name("pipeline") + .subAgents(llmAgent("first", ImmutableList.of(), ImmutableList.of())) + .build()); + + JsonNode json = objectMapper.valueToTree(node); + assertThat(json.get("sub_agents").get(0).get("name").asText()).isEqualTo("first"); + assertThat(json.has("tools")).isFalse(); + } + + @Test + void graphNodeOf_toolNamedLikeASubAgent_skipsItAndNamesToolsetsByClass() { + BaseToolset anonymousToolset = + new BaseToolset() { + @Override + public Flowable getTools(@Nullable ReadonlyContext readonlyContext) { + return Flowable.empty(); + } + + @Override + public void close() {} + }; + GraphNode agent = + AppGraph.graphNodeOf( + llmAgent( + "root", + List.of(tool("helper"), new InvoiceToolset(), tool("search"), anonymousToolset), + List.of(llmAgent("helper", List.of(), List.of())))); + + assertThat(agent.tools()) + .containsExactly( + new GraphTool("InvoiceToolset", "tool"), + new GraphTool("search", "tool"), + new GraphTool("Toolset", "tool")) + .inOrder(); + } + + @Test + void graphNodeOf_routedEdges_describesRoutesAsScalarsOrAList() throws Exception { + Node a = step("a"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges( + new Edge(Start.INSTANCE, a), + new Edge(a, step("b"), new Route.Num(1)), + new Edge(a, step("c"), new Route.Flag(false)), + new Edge(a, step("d"), ImmutableList.of(new Route.Tag("x"), new Route.Tag("y")))) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.edges().stream().map(AppGraphTest::routeJson).toList()) + .containsExactly(null, "1", "false", "[\"x\",\"y\"]") + .inOrder(); + } + + @Test + void graphNodeOf_joinNode_describesJoinTypeInNodesAndEdges() { + Node a = step("a"); + JoinNode join = JoinNode.builder().name("merge").build(); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, a), new Edge(a, join)) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.nodes()).contains(new GraphNode("merge", "join")); + assertThat(graph.edges().get(1).to()).isEqualTo(new GraphNodeRef("merge", "join")); + } + + @Test + void graphNodeOf_nodeReachedByTwoEdges_listsItOnce() { + Node a = step("a"); + Node join = step("join"); + Workflow workflow = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, a), new Edge(a, join), new Edge(Start.INSTANCE, join)) + .build(); + + WorkflowGraph graph = AppGraph.graphNodeOf(workflow).graph(); + + assertThat(graph.nodes().stream().map(GraphNode::name).collect(toImmutableList())) + .containsExactly("__START__", "a", "join") + .inOrder(); + } + + @Test + void graphNodeOf_workflowWithoutEdges_hasNoGraph() { + GraphNode node = AppGraph.graphNodeOf(Workflow.builder().name("empty").build()); + + assertThat(node).isEqualTo(new GraphNode("empty", "workflow")); + assertThat(objectMapper.valueToTree(node).has("graph")).isFalse(); + } + + /** 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"); + return route == null ? null : route.toString(); + } + + /** A toolset the graph draws under its class name. */ + private static final class InvoiceToolset implements BaseToolset { + @Override + public Flowable getTools(@Nullable ReadonlyContext readonlyContext) { + return Flowable.empty(); + } + + @Override + public void close() {} + } +} diff --git a/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java b/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java new file mode 100644 index 000000000..2d69e4510 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/testing/GraphFixtures.java @@ -0,0 +1,87 @@ +/* + * 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.testing; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.tools.BaseTool; +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 java.util.List; + +/** Graphs shared by the graph tests. */ +public final class GraphFixtures { + + /** + * A support desk: {@code classify} routes to a billing agent with a tool, a nested {@code + * tech_flow} workflow, or a default fallback, and every branch ends in {@code summarize}. + */ + public static Workflow supportWorkflow() { + Node classify = step("classify"); + BaseAgent billing = llmAgent("billing_agent", List.of(tool("lookup_invoice")), List.of()); + Node diagnose = step("diagnose"); + Workflow techFlow = + Workflow.builder() + .name("tech_flow") + .edges(new Edge(Start.INSTANCE, diagnose), new Edge(diagnose, step("fix"))) + .build(); + Node fallback = step("fallback"); + Node summarize = step("summarize"); + return Workflow.builder() + .name("support") + .edges( + new Edge(Start.INSTANCE, classify), + new Edge(classify, billing, new Route.Tag("billing")), + new Edge(classify, techFlow, new Route.Tag("tech")), + new Edge(classify, fallback, Route.Default.INSTANCE), + new Edge(billing, summarize), + new Edge(techFlow, summarize), + new Edge(fallback, summarize)) + .build(); + } + + /** An agent tree: {@code assistant} delegates to {@code helper}, which has a tool. */ + public static BaseAgent assistantAgent() { + return llmAgent( + "assistant", + List.of(), + List.of(llmAgent("helper", List.of(tool("lookup_invoice")), List.of()))); + } + + public static Node step(String name) { + return new StepNode(name); + } + + /** An agent with {@code tools}, which may hold tools and toolsets, and {@code subAgents}. */ + public static LlmAgent llmAgent(String name, List tools, List subAgents) { + return LlmAgent.builder() + .name(name) + .model("gemini-2.5-flash") + .tools(tools) + .subAgents(subAgents) + .build(); + } + + public static BaseTool tool(String name) { + return new BaseTool(name, "test tool") {}; + } + + private GraphFixtures() {} +}