Skip to content

Commit d5ff721

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: run a workflow node as the root of an invocation
PiperOrigin-RevId: 999474852
1 parent 0ee0d4b commit d5ff721

17 files changed

Lines changed: 2695 additions & 71 deletions

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ public ImmutableList<? extends AfterAgentCallback> canonicalAfterAgentCallbacks(
273273
*/
274274
private InvocationContext createInvocationContext(InvocationContext parentContext) {
275275
InvocationContext.Builder builder = parentContext.toBuilder();
276-
builder.agent(this);
276+
// An agent's context runs that agent, not the node that its parent context ran.
277+
builder.agent(this).node(null);
277278
// Check for branch to be truthy (not None, not empty string),
278279
parentContext
279280
.branch()
@@ -489,13 +490,21 @@ private Event checkpointEvent(InvocationContext context, EventActions actions) {
489490

490491
/**
491492
* Runs this agent as a workflow node through its {@link #runAsync} lifecycle, including its
492-
* callbacks. The node input is not used.
493+
* callbacks. The node input is not used. Each event's author is set with {@link
494+
* com.google.adk.workflow.NodeExecutionState#eventAuthor(String)}, so the agent's events keep
495+
* their own authors instead of an inherited author or the node's name.
493496
*/
494497
@Experimental
495498
@Override
496499
public Flowable<Event> runNode(
497500
com.google.adk.agents.Context context, @Nullable Object nodeInput) {
498-
return runAsync(context.invocationContext());
501+
return runAsync(context.invocationContext())
502+
.doOnNext(
503+
event -> {
504+
if (!isNullOrEmpty(event.author())) {
505+
context.requireNodeState().eventAuthor(event.author());
506+
}
507+
});
499508
}
500509

501510
/**

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
package com.google.adk.agents;
1818

19+
import com.google.adk.annotations.Experimental;
1920
import com.google.adk.artifacts.ListArtifactsResponse;
2021
import com.google.adk.events.EventActions;
2122
import com.google.adk.events.ToolConfirmation;
2223
import com.google.adk.memory.SearchMemoryResponse;
2324
import com.google.adk.sessions.State;
25+
import com.google.adk.workflow.NodeExecutionState;
2426
import com.google.common.base.Preconditions;
2527
import com.google.genai.types.Part;
2628
import io.reactivex.rxjava3.core.Completable;
@@ -31,12 +33,13 @@
3133
import org.jspecify.annotations.Nullable;
3234

3335
/**
34-
* The context passed to callbacks and tools during an invocation.
36+
* The context passed to callbacks, tools, and workflow nodes during an invocation.
3537
*
3638
* <p>Agent and model callbacks receive a {@link CallbackContext}, and tools and tool callbacks a
3739
* {@link com.google.adk.tools.ToolContext}; both extend this class, so a helper that serves both,
3840
* or a function tool's {@code toolContext} parameter, can take a {@code Context}. {@link
39-
* #requestConfirmation()} needs a function call ID, which only a tool call has.
41+
* #requestConfirmation()} needs a function call ID, which only a tool call has, and {@link
42+
* #requireNodeState()} needs the context of a workflow node.
4043
*/
4144
public class Context extends ReadonlyContext {
4245

@@ -45,6 +48,7 @@ public class Context extends ReadonlyContext {
4548

4649
private final State state;
4750
private final @Nullable String eventId;
51+
private final @Nullable NodeExecutionState nodeState;
4852
private Optional<String> functionCallId = Optional.empty();
4953
private Optional<ToolConfirmation> toolConfirmation = Optional.empty();
5054

@@ -59,10 +63,32 @@ public class Context extends ReadonlyContext {
5963
InvocationContext invocationContext,
6064
@Nullable EventActions eventActions,
6165
@Nullable String eventId) {
66+
this(invocationContext, eventActions, eventId, /* nodeState= */ null);
67+
}
68+
69+
/**
70+
* Creates the context of the workflow node run whose state is {@code nodeState}. Only the
71+
* workflow runtime creates a node state.
72+
*/
73+
@Experimental
74+
public Context(InvocationContext invocationContext, NodeExecutionState nodeState) {
75+
this(
76+
invocationContext,
77+
/* eventActions= */ null,
78+
/* eventId= */ null,
79+
Preconditions.checkNotNull(nodeState));
80+
}
81+
82+
private Context(
83+
InvocationContext invocationContext,
84+
@Nullable EventActions eventActions,
85+
@Nullable String eventId,
86+
@Nullable NodeExecutionState nodeState) {
6287
super(invocationContext);
6388
this.eventActions = eventActions != null ? eventActions : EventActions.builder().build();
6489
this.state = new State(invocationContext.session().state(), this.eventActions.stateDelta());
6590
this.eventId = eventId;
91+
this.nodeState = nodeState;
6692
}
6793

6894
/** Returns the delta-aware state of the current context. */
@@ -223,4 +249,18 @@ public Single<SearchMemoryResponse> searchMemory(String query) {
223249
.searchMemory(
224250
invocationContext.session().appName(), invocationContext.session().userId(), query);
225251
}
252+
253+
/**
254+
* Returns the state of the workflow node run this context belongs to.
255+
*
256+
* @throws UnsupportedOperationException if this is not the context of a workflow node.
257+
*/
258+
@Experimental
259+
public final NodeExecutionState requireNodeState() {
260+
if (nodeState == null) {
261+
throw new UnsupportedOperationException(
262+
"Only the context of a workflow node has a node state.");
263+
}
264+
return nodeState;
265+
}
226266
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.google.adk.sessions.BaseSessionService;
3434
import com.google.adk.sessions.Session;
3535
import com.google.adk.summarizer.EventsCompactionConfig;
36+
import com.google.adk.workflow.Node;
3637
import com.google.common.collect.ImmutableList;
3738
import com.google.common.collect.ImmutableMap;
3839
import com.google.common.collect.ImmutableSet;
@@ -84,6 +85,7 @@ public class InvocationContext {
8485

8586
@Nullable private String branch;
8687
private BaseAgent agent;
88+
private final @Nullable Node node;
8789
private boolean endInvocation;
8890

8991
protected InvocationContext(Builder builder) {
@@ -97,6 +99,7 @@ protected InvocationContext(Builder builder) {
9799
// Resolved here so subclasses that construct from a builder without build() also get an id.
98100
this.invocationId = builder.resolveInvocationId();
99101
this.agent = builder.agent;
102+
this.node = builder.node;
100103
this.session = builder.session;
101104
this.userContent = builder.userContent;
102105
this.runConfig = builder.runConfig;
@@ -182,6 +185,15 @@ public BaseAgent agent() {
182185
return agent;
183186
}
184187

188+
/**
189+
* Returns the workflow node this invocation runs, or empty when it runs an agent. When it runs a
190+
* node, {@link #agent()} returns a {@link NodeViewAgent} that stands in for it.
191+
*/
192+
@Experimental
193+
public Optional<Node> node() {
194+
return Optional.ofNullable(node);
195+
}
196+
185197
/** Returns the session associated with this invocation. */
186198
public Session session() {
187199
return session;
@@ -653,6 +665,7 @@ private Builder(InvocationContext context) {
653665
this.invocationId = context.invocationId;
654666
this.invocationIdSet = true;
655667
this.agent = context.agent;
668+
this.node = context.node;
656669
this.session = context.session;
657670
this.userContent = context.userContent;
658671
this.runConfig = context.runConfig;
@@ -684,6 +697,7 @@ private Builder(InvocationContext context) {
684697
// Distinguishes "never set" (an id is minted from the UuidProvider) from an explicit null.
685698
private boolean invocationIdSet;
686699
private BaseAgent agent;
700+
private @Nullable Node node;
687701
private Session session;
688702
@Nullable private Content userContent = null;
689703
private RunConfig runConfig = RunConfig.builder().build();
@@ -796,6 +810,17 @@ public Builder agent(BaseAgent agent) {
796810
return this;
797811
}
798812

813+
/**
814+
* Sets the workflow node the invocation runs, or null when it runs an agent. When the
815+
* invocation runs a node, its agent is a {@link NodeViewAgent} that stands in for the node.
816+
*/
817+
@CanIgnoreReturnValue
818+
@Experimental
819+
public Builder node(@Nullable Node node) {
820+
this.node = node;
821+
return this;
822+
}
823+
799824
/**
800825
* Sets the session associated with this invocation.
801826
*
@@ -968,6 +993,9 @@ private static void validate(Builder builder) {
968993
if (builder.agent == null) {
969994
throw new IllegalStateException("Agent must be set.");
970995
}
996+
if (builder.agent instanceof NodeViewAgent && builder.node == null) {
997+
throw new IllegalStateException("A node view agent requires the node it stands in for.");
998+
}
971999
if (builder.session == null) {
9721000
throw new IllegalStateException("Session must be set.");
9731001
}
@@ -994,6 +1022,7 @@ public boolean equals(Object o) {
9941022
&& Objects.equals(branch, that.branch)
9951023
&& Objects.equals(invocationId, that.invocationId)
9961024
&& Objects.equals(agent, that.agent)
1025+
&& Objects.equals(node, that.node)
9971026
&& Objects.equals(session, that.session)
9981027
&& Objects.equals(userContent, that.userContent)
9991028
&& Objects.equals(runConfig, that.runConfig)
@@ -1019,6 +1048,7 @@ public int hashCode() {
10191048
branch,
10201049
invocationId,
10211050
agent,
1051+
node,
10221052
session,
10231053
userContent,
10241054
runConfig,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.agents;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
21+
import com.google.adk.annotations.Experimental;
22+
import com.google.adk.events.Event;
23+
import com.google.adk.workflow.Node;
24+
import io.reactivex.rxjava3.core.Flowable;
25+
26+
/**
27+
* A read-only agent that stands in for a non-agent workflow node so code reading the agent of an
28+
* app, runner, or invocation keeps working when they run a node. It never runs; the runner runs the
29+
* node itself.
30+
*/
31+
@Experimental
32+
public final class NodeViewAgent extends BaseAgent {
33+
34+
private final Node node;
35+
36+
/**
37+
* Creates an agent that stands in for {@code node}, using its name and description.
38+
*
39+
* @throws IllegalArgumentException if {@code node} is an agent, or if its name is not a valid
40+
* agent name.
41+
*/
42+
public NodeViewAgent(Node node) {
43+
super(
44+
node.name(),
45+
node.description(),
46+
/* subAgents= */ null,
47+
/* beforeAgentCallback= */ null,
48+
/* afterAgentCallback= */ null);
49+
checkArgument(!(node instanceof BaseAgent), "An agent needs no node view.");
50+
this.node = node;
51+
}
52+
53+
/** Returns the node this agent stands in for. */
54+
public Node node() {
55+
return node;
56+
}
57+
58+
@Override
59+
protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
60+
return Flowable.error(neverRuns());
61+
}
62+
63+
@Override
64+
protected Flowable<Event> runLiveImpl(InvocationContext invocationContext) {
65+
return Flowable.error(neverRuns());
66+
}
67+
68+
private UnsupportedOperationException neverRuns() {
69+
return new UnsupportedOperationException(
70+
"A node view agent never runs; it stands in for node '" + name() + "'.");
71+
}
72+
}

0 commit comments

Comments
 (0)