diff --git a/core/src/main/java/com/google/adk/workflow/Graph.java b/core/src/main/java/com/google/adk/workflow/Graph.java index 852456983..270bd62e1 100644 --- a/core/src/main/java/com/google/adk/workflow/Graph.java +++ b/core/src/main/java/com/google/adk/workflow/Graph.java @@ -31,8 +31,8 @@ import org.slf4j.LoggerFactory; /** - * A workflow graph. Its nodes are the ones its edges connect, each listed once, in the order the - * edges first mention them, so scheduling is deterministic. + * A validated workflow graph. Its nodes are the ones its edges connect, each listed once, in the + * order the edges first mention them, so scheduling is deterministic. */ @Experimental final class Graph { @@ -56,8 +56,11 @@ private Graph( } /** - * Builds the graph that {@code edges} describe, throwing {@link IllegalArgumentException} if a - * node's name is empty or contains {@code /}, {@code @}, or {@code .}. + * Builds and validates the graph that {@code edges} describe. + * + * @throws IllegalArgumentException if a node's name is empty or contains {@code /}, {@code @}, or + * {@code .} + * @throws GraphValidationException if the graph is malformed */ static Graph of(List edges) { ImmutableList edgeList = ImmutableList.copyOf(edges); @@ -76,8 +79,9 @@ static Graph of(List edges) { for (Node node : nodes) { NodeNames.validate(node.name()); } + GraphValidation.validate(nodes, edgeList); ImmutableMap nodesByName = - nodes.stream().collect(toImmutableMap(Node::name, node -> node, (first, last) -> last)); + nodes.stream().collect(toImmutableMap(Node::name, node -> node)); ImmutableSet sourceNames = edgeList.stream().map(edge -> edge.from().name()).collect(toImmutableSet()); ImmutableSet terminalNodeNames = diff --git a/core/src/main/java/com/google/adk/workflow/GraphValidation.java b/core/src/main/java/com/google/adk/workflow/GraphValidation.java new file mode 100644 index 000000000..a1c0b6333 --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/GraphValidation.java @@ -0,0 +1,218 @@ +/* + * 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.workflow; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap; +import static java.util.stream.Collectors.joining; + +import com.google.adk.annotations.Experimental; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Multimaps; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +/** + * Checks that a workflow graph can run, throwing {@link GraphValidationException} on the first + * problem. Each rule rejects a graph that would otherwise fail confusingly at run time, or never + * fail: a node nothing reaches never runs, and an unconditional cycle never terminates. + */ +@Experimental +final class GraphValidation { + + private GraphValidation() {} + + /** Validates the graph of {@code nodes}, each listed once, and the {@code edges} between them. */ + static void validate(List nodes, List edges) { + ImmutableSet nodeNames = validateUniqueNames(nodes); + validateStartPresent(nodeNames); + validateStartEdges(edges); + validateConnectivity(edges, nodeNames); + validateNoDuplicateEdges(edges); + validateDefaultRoutes(edges); + validateNoUnconditionalCycles(edges, nodeNames); + } + + private static GraphValidationException invalid(String message) { + return new GraphValidationException("Graph validation failed. " + message); + } + + /** Returns the node names in the order of {@code nodes}, throwing if two nodes share one. */ + private static ImmutableSet validateUniqueNames(List nodes) { + ImmutableListMultimap nodesByName = Multimaps.index(nodes, Node::name); + ImmutableList duplicates = + nodesByName.keySet().stream() + .filter(name -> nodesByName.get(name).size() > 1) + .sorted() + .collect(toImmutableList()); + if (!duplicates.isEmpty()) { + throw invalid( + "Duplicate node names found: " + + duplicates + + ". Distinct node objects share a name. To reuse a node, pass the same instance; to" + + " keep them distinct, give them different names."); + } + return nodesByName.keySet(); + } + + private static void validateStartPresent(Set nodeNames) { + if (!nodeNames.contains(Start.NAME)) { + throw invalid("START node (name: '" + Start.NAME + "') not found in graph nodes."); + } + } + + private static void validateStartEdges(List edges) { + for (Edge edge : edges) { + if (edge.from().name().equals(Start.NAME) && !edge.isUnconditional()) { + throw invalid( + "Edges from START must not have routes (edge to " + + edge.to().name() + + " has route " + + edge.routes().stream().map(GraphValidation::label).collect(joining(", ")) + + ")."); + } + } + } + + /** Returns {@code route} as error messages show it: its bare value, or the default sentinel. */ + private static String label(Route route) { + 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 Boolean.toString(flag.value()); + } + return Route.DEFAULT_ROUTE_SENTINEL; + } + + private static void validateConnectivity(List edges, Set nodeNames) { + ImmutableListMultimap successors = + edges.stream() + .collect(toImmutableListMultimap(edge -> edge.from().name(), edge -> edge.to().name())); + Set reachable = new HashSet<>(); + Queue pending = new ArrayDeque<>(); + pending.add(Start.NAME); + while (!pending.isEmpty()) { + String current = pending.remove(); + if (reachable.add(current)) { + pending.addAll(successors.get(current)); + } + } + + ImmutableList unreachable = + nodeNames.stream() + .filter(name -> !reachable.contains(name)) + .sorted() + .collect(toImmutableList()); + if (!unreachable.isEmpty()) { + throw invalid("The following nodes are unreachable from START: " + unreachable); + } + if (edges.stream().anyMatch(edge -> edge.to().name().equals(Start.NAME))) { + throw invalid("START node must not have incoming edges."); + } + } + + /** + * Rejects a second edge between the same two nodes unless both are routed and share no route, + * since an overlapping route would trigger the target twice for the same emitted route. + */ + private static void validateNoDuplicateEdges(List edges) { + // An empty set marks an unconditional edge, which overlaps every other edge in its pair. + Map> routesByPair = new HashMap<>(); + for (Edge edge : edges) { + NodePair pair = new NodePair(edge.from().name(), edge.to().name()); + Set seen = routesByPair.get(pair); + if (seen != null + && (seen.isEmpty() + || edge.isUnconditional() + || !Collections.disjoint(seen, edge.routes()))) { + throw invalid("Duplicate edge found: from=" + pair.from() + ", to=" + pair.to()); + } + routesByPair.computeIfAbsent(pair, unused -> new HashSet<>()).addAll(edge.routes()); + } + } + + private static void validateDefaultRoutes(List edges) { + for (Edge edge : edges) { + if (edge.routes().contains(Route.Default.INSTANCE) && edge.routes().size() > 1) { + throw invalid( + "DEFAULT_ROUTE cannot be combined with other routes in a list (edge from=" + + edge.from().name() + + ", to=" + + edge.to().name() + + "). Use a separate edge for DEFAULT_ROUTE."); + } + } + } + + private static void validateNoUnconditionalCycles(List edges, Set nodeNames) { + ImmutableListMultimap successors = + edges.stream() + .filter(Edge::isUnconditional) + .collect(toImmutableListMultimap(edge -> edge.from().name(), edge -> edge.to().name())); + Set done = new HashSet<>(); + for (String name : nodeNames) { + if (!done.contains(name)) { + visitUnconditional(name, successors, new LinkedHashSet<>(), done); + } + } + } + + /** + * Follows the unconditional edges depth-first from {@code node}, throwing on an edge back to a + * node on {@code path}, and adds each node it finishes exploring to {@code done}. + */ + private static void visitUnconditional( + String node, + ImmutableListMultimap successors, + LinkedHashSet path, + Set done) { + path.add(node); + for (String next : successors.get(node)) { + if (path.contains(next)) { + List pathList = new ArrayList<>(path); + List cycle = + new ArrayList<>(pathList.subList(pathList.indexOf(next), pathList.size())); + cycle.add(next); + throw invalid( + "Unconditional cycle detected: " + + String.join(" -> ", cycle) + + ". A cycle must include at least one routed edge, or it never terminates."); + } + if (!done.contains(next)) { + visitUnconditional(next, successors, path, done); + } + } + path.remove(node); + done.add(node); + } + + private record NodePair(String from, String to) {} +} diff --git a/core/src/main/java/com/google/adk/workflow/GraphValidationException.java b/core/src/main/java/com/google/adk/workflow/GraphValidationException.java new file mode 100644 index 000000000..65e201fff --- /dev/null +++ b/core/src/main/java/com/google/adk/workflow/GraphValidationException.java @@ -0,0 +1,31 @@ +/* + * 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.workflow; + +import com.google.adk.annotations.Experimental; + +/** + * Thrown when a workflow graph is malformed, such as when {@link Start} cannot reach a node or a + * cycle has no routed edge. Building the workflow throws it, so a malformed graph never runs. + */ +@Experimental +public final class GraphValidationException extends IllegalArgumentException { + + GraphValidationException(String message) { + super(message); + } +} diff --git a/core/src/main/java/com/google/adk/workflow/Workflow.java b/core/src/main/java/com/google/adk/workflow/Workflow.java index c405595c4..a2a8fdcdb 100644 --- a/core/src/main/java/com/google/adk/workflow/Workflow.java +++ b/core/src/main/java/com/google/adk/workflow/Workflow.java @@ -34,7 +34,8 @@ /** * A graph of nodes that is itself a node, so a workflow can go wherever a node can, including in - * another workflow. + * another workflow. Building a workflow validates its graph and throws {@link + * GraphValidationException} for a malformed one. */ @Experimental public final class Workflow extends BaseNode { @@ -207,6 +208,8 @@ public Builder stateSchema(@Nullable Schema stateSchema) { * * @throws IllegalArgumentException if the workflow's name or the name of a node in its graph is * empty or contains {@code /}, {@code @}, or {@code .} + * @throws GraphValidationException if the graph is malformed, for example if {@link Start} + * cannot reach one of its nodes */ public Workflow build() { checkState(name != null, "Workflow.Builder requires name to be set."); diff --git a/core/src/test/java/com/google/adk/workflow/GraphValidationTest.java b/core/src/test/java/com/google/adk/workflow/GraphValidationTest.java new file mode 100644 index 000000000..9c956c698 --- /dev/null +++ b/core/src/test/java/com/google/adk/workflow/GraphValidationTest.java @@ -0,0 +1,414 @@ +/* + * 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.workflow; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.adk.testing.TestNode; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Graph validation, rule by rule, matching ADK Python's {@code utils/_graph_validation.py}. */ +@RunWith(JUnit4.class) +public final class GraphValidationTest { + + private static final Route YES = new Route.Tag("yes"); + + @Test + public void of_rejectsAGraphWithoutStart() { + ImmutableList edges = ImmutableList.of(new Edge(TestNode.of("a"), TestNode.of("b"))); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Graph validation failed. START node (name: '__START__') not found in graph nodes."); + } + + @Test + public void of_rejectsARouteOnAStartEdge() { + ImmutableList edges = ImmutableList.of(new Edge(Start.INSTANCE, TestNode.of("a"), YES)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + // The route prints as its value, as ADK Python prints it. + assertThat(e) + .hasMessageThat() + .contains("Edges from START must not have routes (edge to a has route yes)."); + } + + @Test + public void of_namesADefaultRouteOnAStartEdgeByItsSentinel() { + ImmutableList edges = + ImmutableList.of(new Edge(Start.INSTANCE, TestNode.of("a"), Route.Default.INSTANCE)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("(edge to a has route __DEFAULT__)."); + } + + @Test + public void of_namesEachRouteOnAStartEdgeByItsValue() { + ImmutableList edges = + ImmutableList.of( + new Edge( + Start.INSTANCE, + TestNode.of("a"), + ImmutableList.of(new Route.Num(1), new Route.Flag(true)))); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("(edge to a has route 1, true)."); + } + + @Test + public void of_keepsItsOwnCopyOfTheEdges() { + Node a = TestNode.of("a"); + List edges = new ArrayList<>(); + edges.add(new Edge(Start.INSTANCE, a)); + Graph graph = Graph.of(edges); + + // An edge into START added afterwards would have failed validation. + edges.add(new Edge(a, Start.INSTANCE)); + + assertThat(graph.edges()).containsExactly(new Edge(Start.INSTANCE, a)); + } + + @Test + public void of_rejectsUnreachableNodesAndListsThemInOrder() { + Node a = TestNode.of("a"); + Node orphan = TestNode.of("orphan"); + Node sink = TestNode.of("sink"); + ImmutableList edges = + ImmutableList.of(new Edge(Start.INSTANCE, a), new Edge(sink, a), new Edge(orphan, sink)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e) + .hasMessageThat() + .contains("The following nodes are unreachable from START: [orphan, sink]"); + } + + @Test + public void of_treatsADisconnectedRoutedSubgraphAsUnreachable() { + // b and c only route to each other, so START reaches neither. + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + Node c = TestNode.of("c"); + ImmutableList edges = + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(b, c, new Route.Tag("x")), + new Edge(c, b, new Route.Tag("y"))); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("unreachable from START: [b, c]"); + } + + @Test + public void of_rejectsAnEdgeIntoStart() { + Node a = TestNode.of("a"); + ImmutableList edges = + ImmutableList.of(new Edge(Start.INSTANCE, a), new Edge(a, Start.INSTANCE)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e) + .hasMessageThat() + .isEqualTo("Graph validation failed. START node must not have incoming edges."); + } + + @Test + public void of_rejectsOverlappingEdgesBetweenTheSameTwoNodes() { + // An empty route list is unconditional, which overlaps every other edge. + ImmutableList, ImmutableList>> overlapping = + ImmutableList.of( + Map.entry(routes(), routes()), + Map.entry(routes("x"), routes("x")), + Map.entry(routes("x"), routes()), + Map.entry(routes(), routes("x")), + Map.entry(routes("x", "y"), routes("y")), + Map.entry(routes("x", "y"), routes("y", "z"))); + + for (Map.Entry, ImmutableList> pair : overlapping) { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + ImmutableList edges = + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, b, pair.getKey()), + new Edge(a, b, pair.getValue())); + + GraphValidationException e = + assertThrows(pair.toString(), GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e) + .hasMessageThat() + .isEqualTo("Graph validation failed. Duplicate edge found: from=a, to=b"); + } + } + + @Test + public void of_acceptsRoutedEdgesBetweenTheSameTwoNodesWithDisjointRoutes() { + ImmutableList, ImmutableList>> disjoint = + ImmutableList.of( + Map.entry(routes("w"), routes("x")), + Map.entry(routes("w", "x"), routes("y")), + Map.entry(routes("w", "x"), routes("y", "z"))); + + for (Map.Entry, ImmutableList> pair : disjoint) { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, b, pair.getKey()), + new Edge(a, b, pair.getValue()))); + + assertThat(graph.nodes()).containsExactly(Start.INSTANCE, a, b).inOrder(); + } + } + + @Test + public void nodesTriggeredBy_triggersATargetOnceForEachMatchedDisjointEdge() { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, b, new Route.Tag("x")), + new Edge(a, b, new Route.Tag("y")))); + + // One trigger per matched edge, as ADK Python's get_next_pending_nodes appends. + assertThat(graph.nodesTriggeredBy("a", routes("x", "y"))).containsExactly("b", "b"); + } + + @Test + public void of_acceptsANamedRouteAndTheDefaultRouteToOneTarget() { + // The routing-map shape {x: target, DEFAULT: target}. + Node router = TestNode.of("router"); + Node target = TestNode.of("target"); + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, router), + new Edge(router, target, new Route.Tag("x")), + new Edge(router, target, Route.Default.INSTANCE))); + + assertThat(graph.nodesTriggeredBy("router", routes("x"))).containsExactly("target"); + assertThat(graph.nodesTriggeredBy("router", routes("other"))).containsExactly("target"); + } + + @Test + public void of_acceptsAnEdgeListingTheSameRouteTwice() { + // Routes on one edge form a set, so a repeat is redundant rather than a conflict. + Node router = TestNode.of("router"); + Node target = TestNode.of("target"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, router), + new Edge(router, target, ImmutableList.of(YES, YES)))); + + assertThat(graph.nodesTriggeredBy("router", ImmutableList.of(YES))).containsExactly("target"); + } + + @Test + public void of_acceptsSeveralDefaultRouteEdgesFromOneNode() { + Node router = TestNode.of("router"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, router), + new Edge(router, TestNode.of("first"), Route.Default.INSTANCE), + new Edge(router, TestNode.of("second"), Route.Default.INSTANCE))); + + assertThat(graph.terminalNodeNames()).containsExactly("first", "second"); + } + + @Test + public void of_acceptsADefaultRouteBesideANamedRoute() { + Node router = TestNode.of("router"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, router), + new Edge(router, TestNode.of("fallback"), Route.Default.INSTANCE), + new Edge(router, TestNode.of("named"), new Route.Tag("another_route")))); + + assertThat(graph.terminalNodeNames()).containsExactly("fallback", "named"); + } + + @Test + public void of_rejectsADefaultRouteSharingAnEdgeWithAnotherRoute() { + Node router = TestNode.of("router"); + ImmutableList edges = + ImmutableList.of( + new Edge(Start.INSTANCE, router), + new Edge( + router, + TestNode.of("target"), + ImmutableList.of(new Route.Tag("x"), Route.Default.INSTANCE))); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e) + .hasMessageThat() + .contains( + "DEFAULT_ROUTE cannot be combined with other routes in a list (edge from=router," + + " to=target)"); + } + + @Test + public void of_rejectsTwoDistinctNodesSharingAName() { + Node a = TestNode.of("a"); + ImmutableList edges = + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, TestNode.of("dup")), + new Edge(a, TestNode.of("dup"))); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("Duplicate node names found: [dup]."); + } + + @Test + public void of_acceptsOneNodeInstanceReusedAcrossBranches() { + // A diamond, where both branches name the same join instance. + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + Node join = TestNode.of("join"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(Start.INSTANCE, b), + new Edge(a, join), + new Edge(b, join))); + + assertThat(graph.terminalNodeNames()).containsExactly("join"); + } + + @Test + public void of_rejectsAnUnconditionalCycle() { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + ImmutableList edges = + ImmutableList.of(new Edge(Start.INSTANCE, a), new Edge(a, b), new Edge(b, a)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("Unconditional cycle detected: a -> b -> a."); + } + + @Test + public void of_rejectsALongerUnconditionalCycle() { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + Node c = TestNode.of("c"); + ImmutableList edges = + ImmutableList.of( + new Edge(Start.INSTANCE, a), new Edge(a, b), new Edge(b, c), new Edge(c, a)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("Unconditional cycle detected: a -> b -> c -> a."); + } + + @Test + public void of_rejectsAnUnconditionalSelfLoop() { + Node a = TestNode.of("a"); + ImmutableList edges = ImmutableList.of(new Edge(Start.INSTANCE, a), new Edge(a, a)); + + GraphValidationException e = + assertThrows(GraphValidationException.class, () -> Graph.of(edges)); + + assertThat(e).hasMessageThat().contains("Unconditional cycle detected: a -> a."); + } + + @Test + public void of_acceptsACycleThroughARoutedBackEdge() { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, b), + new Edge(b, a, new Route.Tag("retry")))); + + assertThat(graph.nodes()).containsExactly(Start.INSTANCE, a, b).inOrder(); + } + + @Test + public void of_acceptsARoutedSelfLoop() { + Node a = TestNode.of("a"); + + Graph graph = + Graph.of( + ImmutableList.of( + new Edge(Start.INSTANCE, a), + new Edge(a, a, new Route.Tag("continue")), + new Edge(a, TestNode.of("b"), new Route.Tag("done")))); + + assertThat(graph.terminalNodeNames()).containsExactly("b"); + } + + @Test + public void of_throwsAnExceptionThatCallersCanCatchAsIllegalArgumentException() { + ImmutableList edges = ImmutableList.of(new Edge(TestNode.of("a"), TestNode.of("b"))); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> Graph.of(edges)); + + assertThat(e).isInstanceOf(GraphValidationException.class); + } + + private static ImmutableList routes(String... tags) { + return Arrays.stream(tags).map(Route.Tag::new).collect(toImmutableList()); + } +} diff --git a/core/src/test/java/com/google/adk/workflow/WorkflowTest.java b/core/src/test/java/com/google/adk/workflow/WorkflowTest.java index 20e1fb104..8a5d92fc0 100644 --- a/core/src/test/java/com/google/adk/workflow/WorkflowTest.java +++ b/core/src/test/java/com/google/adk/workflow/WorkflowTest.java @@ -206,6 +206,20 @@ public void build_rejectsANodeNameThatANodePathCannotCarry() { assertThat(e).hasMessageThat().contains("but 'a/b' does"); } + @Test + public void build_rejectsAMalformedGraph() { + Node a = TestNode.of("a"); + Node b = TestNode.of("b"); + Workflow.Builder builder = + Workflow.builder() + .name("flow") + .edges(new Edge(Start.INSTANCE, a), new Edge(a, b), new Edge(b, a)); + + GraphValidationException e = assertThrows(GraphValidationException.class, builder::build); + + assertThat(e).hasMessageThat().contains("Unconditional cycle detected: a -> b -> a."); + } + @Test public void aWorkflowCanBeANodeOfAnotherWorkflow() { Workflow inner =