Skip to content

Commit 9e5c433

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: add experimental workflow node state to Context
PiperOrigin-RevId: 999474851
1 parent 0ee0d4b commit 9e5c433

9 files changed

Lines changed: 1458 additions & 33 deletions

File tree

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,13 +489,21 @@ private Event checkpointEvent(InvocationContext context, EventActions actions) {
489489

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

501509
/**

‎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
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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.workflow;
18+
19+
import static com.google.common.base.Preconditions.checkNotNull;
20+
import static com.google.common.base.Preconditions.checkState;
21+
22+
import com.google.adk.agents.Context;
23+
import com.google.adk.annotations.Experimental;
24+
import com.google.common.collect.ImmutableList;
25+
import com.google.common.collect.ImmutableSet;
26+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
27+
import java.util.Collection;
28+
import java.util.LinkedHashSet;
29+
import java.util.List;
30+
import java.util.Optional;
31+
import java.util.Set;
32+
import org.jspecify.annotations.Nullable;
33+
34+
/**
35+
* The state of one workflow node run, held by the run's {@link Context}. Only the workflow runtime
36+
* creates it and updates the bookkeeping that records which results an event has carried.
37+
*/
38+
@Experimental
39+
public final class NodeExecutionState {
40+
41+
private final Node node;
42+
private final NodeRunner.EventSink eventSink;
43+
private final @Nullable Context parent;
44+
private final String runId;
45+
private final String nodePath;
46+
private String eventAuthor;
47+
private final Set<String> interruptIds = new LinkedHashSet<>();
48+
private @Nullable Object output;
49+
private boolean outputEmitted;
50+
private @Nullable ImmutableList<Route> selectedRoutes;
51+
private boolean routesEmitted;
52+
private @Nullable Exception failure;
53+
54+
private NodeExecutionState(Builder builder) {
55+
checkState(builder.node != null, "A node state needs a node.");
56+
checkState(builder.eventSink != null, "A node state needs an event sink.");
57+
checkState(builder.runId != null, "A node state needs a run ID.");
58+
this.node = builder.node;
59+
this.eventSink = builder.eventSink;
60+
this.parent = builder.parent;
61+
this.runId = builder.runId;
62+
NodeExecutionState parentState = parent == null ? null : parent.requireNodeState();
63+
this.nodePath =
64+
NodeRunner.appendSegment(
65+
parentState == null ? null : parentState.nodePath,
66+
node.name(),
67+
runId,
68+
/* separator= */ '/');
69+
this.eventAuthor = parentState == null ? "" : parentState.eventAuthor;
70+
}
71+
72+
/** Returns a builder for the state of one node run. */
73+
static Builder builder() {
74+
return new Builder();
75+
}
76+
77+
/** Returns the node this run belongs to. */
78+
public Node node() {
79+
return node;
80+
}
81+
82+
/**
83+
* Returns the path of this run: {@code name@runId} segments joined by {@code /}, starting at the
84+
* outermost node.
85+
*/
86+
public String nodePath() {
87+
return nodePath;
88+
}
89+
90+
/** Returns the ID of this run among the node's runs, counting from {@code "1"}. */
91+
public String runId() {
92+
return runId;
93+
}
94+
95+
/** Returns the context of the node that ran this node, or empty if this is a root node. */
96+
public Optional<Context> parent() {
97+
return Optional.ofNullable(parent);
98+
}
99+
100+
/**
101+
* Returns the author of the events this run emits, or empty to use the node's name. A run under a
102+
* parent starts with the parent's author.
103+
*/
104+
public String eventAuthor() {
105+
return eventAuthor;
106+
}
107+
108+
/** Sets the author of the events this run emits. */
109+
public void eventAuthor(String eventAuthor) {
110+
this.eventAuthor = checkNotNull(eventAuthor);
111+
}
112+
113+
/** Returns the node's output, or empty if it has produced none. */
114+
public Optional<Object> output() {
115+
return Optional.ofNullable(output);
116+
}
117+
118+
/**
119+
* Records the node's output, which an event carries when the node finishes unless an earlier
120+
* event already carried it. A node produces at most one output.
121+
*
122+
* @throws IllegalStateException if the node already has an output.
123+
*/
124+
public void produceOutput(Object output) {
125+
checkNotNull(output);
126+
checkState(
127+
this.output == null,
128+
"Node '%s' produced a second output; a node produces at most one output.",
129+
node.name());
130+
this.output = output;
131+
}
132+
133+
/**
134+
* Returns the routes the node selected, which decide the outgoing edges to follow, or empty if it
135+
* made no routing decision. An empty list is a decision that selects no route.
136+
*/
137+
public Optional<ImmutableList<Route>> selectedRoutes() {
138+
return Optional.ofNullable(selectedRoutes);
139+
}
140+
141+
/**
142+
* Selects the node's routes, replacing any earlier selection. An event carries them when the node
143+
* finishes unless an earlier event already carried them.
144+
*/
145+
public void selectRoutes(List<? extends Route> routes) {
146+
this.selectedRoutes = ImmutableList.copyOf(routes);
147+
this.routesEmitted = false;
148+
}
149+
150+
/** Returns the IDs of the long-running tool calls the node is waiting on. */
151+
public ImmutableSet<String> interruptIds() {
152+
return ImmutableSet.copyOf(interruptIds);
153+
}
154+
155+
NodeRunner.EventSink eventSink() {
156+
return eventSink;
157+
}
158+
159+
/** Returns whether an event has carried the node's output. */
160+
boolean outputEmitted() {
161+
return outputEmitted;
162+
}
163+
164+
void markOutputEmitted() {
165+
outputEmitted = true;
166+
}
167+
168+
/** Returns whether an event has carried the node's current route selection. */
169+
boolean routesEmitted() {
170+
return routesEmitted;
171+
}
172+
173+
void markRoutesEmitted() {
174+
routesEmitted = true;
175+
}
176+
177+
void addInterruptIds(Collection<String> ids) {
178+
interruptIds.addAll(ids);
179+
}
180+
181+
/** Returns the exception the node failed with, if it failed. */
182+
Optional<Exception> failure() {
183+
return Optional.ofNullable(failure);
184+
}
185+
186+
void failure(Exception failure) {
187+
this.failure = checkNotNull(failure);
188+
}
189+
190+
/** Builder for {@link NodeExecutionState}. */
191+
@Experimental
192+
static final class Builder {
193+
private @Nullable Node node;
194+
private NodeRunner.@Nullable EventSink eventSink;
195+
private @Nullable Context parent;
196+
private @Nullable String runId;
197+
198+
private Builder() {}
199+
200+
/** Sets the node this run belongs to. */
201+
@CanIgnoreReturnValue
202+
Builder node(Node node) {
203+
this.node = checkNotNull(node);
204+
return this;
205+
}
206+
207+
/** Sets the sink that receives the events of the run. */
208+
@CanIgnoreReturnValue
209+
Builder eventSink(NodeRunner.EventSink eventSink) {
210+
this.eventSink = checkNotNull(eventSink);
211+
return this;
212+
}
213+
214+
/**
215+
* Sets the context of the node that ran this node, or null for a root node. A run under a
216+
* parent extends the parent's node path and starts with the parent's event author.
217+
*/
218+
@CanIgnoreReturnValue
219+
Builder parent(@Nullable Context parent) {
220+
this.parent = parent;
221+
return this;
222+
}
223+
224+
/** Sets the ID of this run among the node's runs. */
225+
@CanIgnoreReturnValue
226+
Builder runId(String runId) {
227+
this.runId = checkNotNull(runId);
228+
return this;
229+
}
230+
231+
/**
232+
* Builds the state.
233+
*
234+
* @throws IllegalStateException if the node, the event sink, or the run ID is not set.
235+
*/
236+
NodeExecutionState build() {
237+
return new NodeExecutionState(this);
238+
}
239+
}
240+
}

0 commit comments

Comments
 (0)