Skip to content

Commit e293898

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: run child nodes from a workflow node with Context.runNode
PiperOrigin-RevId: 1004682697
1 parent ad1ea25 commit e293898

6 files changed

Lines changed: 828 additions & 20 deletions

File tree

‎core/src/main/java/com/google/adk/agents/Context.java‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@
2222
import com.google.adk.events.ToolConfirmation;
2323
import com.google.adk.memory.SearchMemoryResponse;
2424
import com.google.adk.sessions.State;
25+
import com.google.adk.workflow.DynamicNodeFailedException;
26+
import com.google.adk.workflow.Node;
2527
import com.google.adk.workflow.NodeExecutionState;
28+
import com.google.adk.workflow.NodeRunner;
2629
import com.google.adk.workflow.Route;
30+
import com.google.adk.workflow.RunNodeConfig;
2731
import com.google.common.base.Preconditions;
2832
import com.google.common.collect.ImmutableList;
2933
import com.google.genai.types.Part;
@@ -285,7 +289,8 @@ public final void eventAuthor(String eventAuthor) {
285289
}
286290

287291
/**
288-
* Returns this workflow node's output, or empty if it has produced none.
292+
* Returns this workflow node's output, which is the output of its {@code useAsOutput} child once
293+
* that child emits one, or empty if it has none.
289294
*
290295
* @throws IllegalStateException if this is not the context of a workflow node.
291296
*/
@@ -315,4 +320,46 @@ public final Optional<ImmutableList<Route>> routes() {
315320
public final void routes(List<? extends Route> routes) {
316321
requireNodeState().selectRoutes(routes);
317322
}
323+
324+
/**
325+
* Runs {@code node} as a child of this workflow node without an input, as {@link #runNode(Node,
326+
* Object, RunNodeConfig)} does with the default {@link RunNodeConfig}.
327+
*/
328+
@Experimental
329+
public final Maybe<Object> runNode(Node node) {
330+
return runNode(node, /* nodeInput= */ null);
331+
}
332+
333+
/**
334+
* Runs {@code node} as a child of this workflow node, as {@link #runNode(Node, Object,
335+
* RunNodeConfig)} does with the default {@link RunNodeConfig}.
336+
*
337+
* @throws IllegalArgumentException if {@code nodeInput} is a {@link RunNodeConfig}, which belongs
338+
* in the three-argument overload.
339+
*/
340+
@Experimental
341+
public final Maybe<Object> runNode(Node node, @Nullable Object nodeInput) {
342+
Preconditions.checkArgument(
343+
!(nodeInput instanceof RunNodeConfig),
344+
"Pass the RunNodeConfig as the third argument of runNode, not as the node input.");
345+
return runNode(node, nodeInput, RunNodeConfig.builder().build());
346+
}
347+
348+
/**
349+
* Returns a {@link Maybe} that, when subscribed, runs {@code node} as a child of this workflow
350+
* node and emits the child's output, or completes empty if it has none. Include it in this node's
351+
* run and catch only failures you handle, such as {@link DynamicNodeFailedException}: when the
352+
* child waits, it fails with an internal {@link RuntimeException} that must propagate to end this
353+
* node's run, so that it waits too.
354+
*
355+
* <p>It fails with {@link DynamicNodeFailedException} if the child fails; with {@link
356+
* IllegalStateException} if this is not a workflow node's context, if this node's {@link
357+
* Node#rerunOnResume} is false, or if {@code config} delegates an output that this node already
358+
* has or has delegated; and with {@link IllegalArgumentException} if the child's name cannot form
359+
* a node path.
360+
*/
361+
@Experimental
362+
public final Maybe<Object> runNode(Node node, @Nullable Object nodeInput, RunNodeConfig config) {
363+
return NodeRunner.runChild(this, node, nodeInput, config);
364+
}
318365
}

‎core/src/main/java/com/google/adk/workflow/DynamicNodeFailedException.java‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@
1919
import com.google.adk.annotations.Experimental;
2020

2121
/**
22-
* An exception that {@link NodeRunner#runChild} signals when the child node fails. If the caller
23-
* does not handle this exception, the caller fails with the child's error without emitting an error
24-
* event of its own.
22+
* An exception that {@link com.google.adk.agents.Context#runNode} signals when the child node
23+
* fails. If the caller does not handle this exception, the caller fails with the child's error
24+
* without emitting an error event of its own.
2525
*/
2626
@Experimental
27-
final class DynamicNodeFailedException extends RuntimeException {
27+
public final class DynamicNodeFailedException extends RuntimeException {
2828

2929
private final String errorNodePath;
3030

‎core/src/main/java/com/google/adk/workflow/NodeRunner.java‎

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
* The runner for one workflow node. It stamps every emitted event with its author, node path, and
5050
* branch before forwarding it, and records the node's output, routes, interrupts, and failure on
5151
* the node state of the returned context. {@link #runRoot} runs a node as the root of an
52-
* invocation, and {@code runChild} runs one as a child that another node starts itself.
52+
* invocation, and {@link #runChild} runs one as a child that another node starts itself.
5353
*/
5454
@Experimental
5555
public final class NodeRunner {
@@ -179,19 +179,12 @@ public static Flowable<Event> runRoot(
179179
}
180180

181181
/**
182-
* Returns a {@link Maybe} that, when subscribed, runs {@code node} as a child of the node whose
183-
* context is {@code parent} and emits the child's output, or completes empty if it has none. The
184-
* caller includes it in its run and catches only failures it handles, such as {@link
185-
* DynamicNodeFailedException}: when the child waits, it fails with an internal {@link
186-
* RuntimeException} that must propagate to end the caller's run, so that the caller waits too.
187-
*
188-
* <p>It fails with {@link DynamicNodeFailedException} if the child fails; with {@link
189-
* IllegalStateException} if {@code parent} is not a workflow node's context, if the caller's
190-
* {@link Node#rerunOnResume} is false, or if {@code config} delegates an output that the caller
191-
* already has or has delegated; and with {@link IllegalArgumentException} if the child's name
192-
* cannot form a node path.
182+
* Runs {@code node} as a child of the node whose context is {@code parent}, as {@link
183+
* Context#runNode(Node, Object, RunNodeConfig)} describes. Nodes call that method; this one is
184+
* public only so {@link Context} can call it from another package.
193185
*/
194-
static Maybe<Object> runChild(
186+
@Experimental
187+
public static Maybe<Object> runChild(
195188
Context parent, Node node, @Nullable Object nodeInput, RunNodeConfig config) {
196189
checkNotNull(parent);
197190
checkNotNull(node);

‎core/src/main/java/com/google/adk/workflow/RunNodeConfig.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
import java.util.Optional;
2525
import org.jspecify.annotations.Nullable;
2626

27-
/** Configuration for running a child node with {@link NodeRunner#runChild}. */
27+
/** Configuration for running a child node with {@link com.google.adk.agents.Context#runNode}. */
2828
@Experimental
2929
@AutoValue
30-
abstract class RunNodeConfig {
30+
public abstract class RunNodeConfig {
3131

3232
/**
3333
* Returns the ID of the child's run, which names the run in node paths and branches. When empty,

‎core/src/test/java/com/google/adk/agents/ContextTest.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@
3131
import com.google.adk.runner.Runner;
3232
import com.google.adk.sessions.Session;
3333
import com.google.adk.sessions.State;
34+
import com.google.adk.testing.TestNode;
3435
import com.google.adk.tools.FunctionTool;
3536
import com.google.adk.tools.ToolContext;
37+
import com.google.adk.workflow.Node;
38+
import com.google.adk.workflow.RunNodeConfig;
3639
import com.google.common.collect.ImmutableList;
3740
import com.google.common.collect.ImmutableMap;
3841
import com.google.genai.types.Content;
@@ -94,6 +97,25 @@ public void nodeMethods_onCallbackContext_throwIllegalStateException() {
9497
assertThrows(IllegalStateException.class, () -> context.routes(ImmutableList.of()));
9598
}
9699

100+
@Test
101+
public void runNode_onCallbackContext_failsWithIllegalStateException() {
102+
CallbackContext context = new CallbackContext(newInvocationContext(), null);
103+
104+
context.runNode(TestNode.of("child")).test().assertError(IllegalStateException.class);
105+
}
106+
107+
@Test
108+
public void runNode_configPassedAsTheInput_throwsIllegalArgumentException() {
109+
CallbackContext context = new CallbackContext(newInvocationContext(), null);
110+
Node child = TestNode.of("child");
111+
RunNodeConfig config = RunNodeConfig.builder().useAsOutput(true).build();
112+
113+
IllegalArgumentException thrown =
114+
assertThrows(IllegalArgumentException.class, () -> context.runNode(child, config));
115+
116+
assertThat(thrown).hasMessageThat().contains("third argument");
117+
}
118+
97119
@Test
98120
public void searchMemory_onCallbackContext_findsRememberedEvents() {
99121
InvocationContext invocationContext = newInvocationContext();

0 commit comments

Comments
 (0)