Skip to content

Commit c760030

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: pause a workflow for user input
PiperOrigin-RevId: 1004727066
1 parent 2e88a6d commit c760030

12 files changed

Lines changed: 1130 additions & 36 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,9 @@ public final void routes(List<? extends Route> routes) {
323323
}
324324

325325
/**
326-
* Returns the user's answers to the long-running calls that this workflow node or its descendants
327-
* made in an earlier turn, keyed by interrupt ID. A node that reruns on resume reads these
328-
* answers. A null answer has a null value in the map.
326+
* Returns the user's answers to the requests for input and long-running calls that this workflow
327+
* node or its descendants made in an earlier turn, keyed by interrupt ID. A node that reruns on
328+
* resume reads these answers. A null answer has a null value in the map.
329329
*
330330
* @throws IllegalStateException if this is not the context of a workflow node.
331331
*/

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,21 +119,27 @@ public Optional<Schema> stateSchema() {
119119

120120
/**
121121
* Runs the node and emits its events. Each emission of {@link #runNode} that is an {@link Event}
122-
* passes through, and any other value becomes the output of a new event.
122+
* passes through, a {@link RequestInput} becomes the event that asks for it, and any other value
123+
* becomes the output of a new event.
123124
*/
124125
final Flowable<Event> run(Context context, @Nullable Object nodeInput) {
125126
InvocationContext invocationContext = checkNotNull(context).invocationContext();
126127
// The node runner fills in the author, as only it knows the node's place in the graph.
127128
return Flowable.<Object>defer(() -> runNode(context, nodeInput))
128129
.map(
129-
item ->
130-
item instanceof Event event
131-
? event
132-
: Event.builder()
133-
.id(invocationContext.newUuid())
134-
.timestamp(invocationContext.now().toEpochMilli())
135-
.author("")
136-
.output(item)
137-
.build());
130+
item -> {
131+
if (item instanceof Event event) {
132+
return event;
133+
}
134+
if (item instanceof RequestInput request) {
135+
return request.toEvent(invocationContext);
136+
}
137+
return Event.builder()
138+
.id(invocationContext.newUuid())
139+
.timestamp(invocationContext.now().toEpochMilli())
140+
.author("")
141+
.output(item)
142+
.build();
143+
});
138144
}
139145
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ default boolean requiresAllPredecessors() {
9898
/**
9999
* Runs the node on {@code nodeInput}, the output it receives from its predecessors.
100100
*
101-
* <p>An emitted {@link Event} passes through, and any other emitted value becomes the node's
102-
* output (at most one per run); emit nothing for no output, since RxJava forbids null. The
103-
* wildcard lets an implementation return a {@code Flowable} of its own type.
101+
* <p>An emitted {@link Event} passes through, an emitted {@link RequestInput} pauses the workflow
102+
* until the user answers it, and any other emitted value becomes the node's output (at most one
103+
* per run); emit nothing for no output, since RxJava forbids null. The wildcard lets an
104+
* implementation return a {@code Flowable} of its own type.
104105
*/
105106
@Experimental
106107
Flowable<?> runNode(Context context, @Nullable Object nodeInput);

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.common.collect.ImmutableMap;
2727
import com.google.common.collect.ImmutableSet;
2828
import com.google.errorprone.annotations.CanIgnoreReturnValue;
29+
import io.reactivex.rxjava3.subjects.SingleSubject;
2930
import java.util.Collection;
3031
import java.util.Collections;
3132
import java.util.LinkedHashMap;
@@ -61,6 +62,7 @@ public final class NodeExecutionState {
6162
private final ImmutableList<String> outputFor;
6263
private final Map<String, @Nullable Object> resumeInputs;
6364
private final ConcurrentMap<String, Integer> childRunCounts = new ConcurrentHashMap<>();
65+
private final ConcurrentMap<String, SingleSubject<Context>> childRuns = new ConcurrentHashMap<>();
6466
// The paths of the node runs started but not finished in this turn, shared under one root.
6567
private final Set<String> unfinishedNodePaths;
6668
private final Set<String> interruptIds = new CopyOnWriteArraySet<>();
@@ -194,9 +196,9 @@ public ImmutableSet<String> interruptIds() {
194196
}
195197

196198
/**
197-
* Returns the user's answers to the long-running calls that this node or its descendants raised
198-
* in an earlier turn, keyed by interrupt ID. A null answer has a null value in the map. The map
199-
* is empty unless the node reruns on resume.
199+
* Returns the user's answers to the requests for input and long-running calls that this node or
200+
* its descendants raised in an earlier turn, keyed by interrupt ID. A null answer has a null
201+
* value in the map. The map is empty unless the node reruns on resume.
200202
*/
201203
public Map<String, @Nullable Object> resumeInputs() {
202204
return resumeInputs;
@@ -224,6 +226,14 @@ String nextChildRunId(String name) {
224226
return String.valueOf(childRunCounts.merge(name, 1, Integer::sum));
225227
}
226228

229+
/**
230+
* Returns the runs of the children this node runs itself, keyed by {@code name@runId}, which
231+
* calls with the same run ID share.
232+
*/
233+
ConcurrentMap<String, SingleSubject<Context>> childRuns() {
234+
return childRuns;
235+
}
236+
227237
/** Records that this run started; it counts as canceled mid-run until it finishes. */
228238
void markStarted() {
229239
unfinishedNodePaths.add(nodePath);
@@ -255,6 +265,11 @@ synchronized ImmutableMap<String, ResumeScan.RecoveredNode> recoveredChildren(
255265
return recoveredChildren;
256266
}
257267

268+
/** Returns the parent's state if this run's output is also the parent's, or null. */
269+
@Nullable NodeExecutionState outputParent() {
270+
return outputParent;
271+
}
272+
258273
/** Returns whether an event has carried the node's own or delegated output. */
259274
boolean outputEmitted() {
260275
OutputStatus status = outputState.get().status();

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

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@
4040
import io.reactivex.rxjava3.processors.FlowableProcessor;
4141
import io.reactivex.rxjava3.processors.UnicastProcessor;
4242
import io.reactivex.rxjava3.subjects.CompletableSubject;
43+
import io.reactivex.rxjava3.subjects.SingleSubject;
4344
import java.util.Map;
4445
import java.util.Optional;
4546
import java.util.Queue;
4647
import java.util.concurrent.ConcurrentLinkedQueue;
48+
import java.util.concurrent.ConcurrentMap;
4749
import java.util.function.Function;
4850
import org.jspecify.annotations.Nullable;
4951

@@ -218,8 +220,7 @@ private static Maybe<Object> startChild(
218220
parentState.claimOutputDelegation();
219221
}
220222
String runId = config.runId().orElseGet(() -> parentState.nextChildRunId(node.name()));
221-
// Deferred so that a throw from scan or intercept releases the delegation, as a failure does.
222-
return Single.defer(() -> runOrReplayChild(parent, node, nodeInput, runId, config))
223+
return sharedChildRun(parent, node, nodeInput, runId, config)
223224
.doOnError(
224225
error -> {
225226
if (delegatesOutput) {
@@ -229,6 +230,50 @@ private static Maybe<Object> startChild(
229230
.flatMapMaybe(child -> childResult(parent, child, delegatesOutput, config.raiseOnWait()));
230231
}
231232

233+
/**
234+
* Returns the run of {@code node} under {@code runId} shared by the caller's calls with that run
235+
* ID, starting it if needed. A run that fails or waits for an output it did not produce is not
236+
* shared, so a later call runs the child again. When the call that started a run is canceled, a
237+
* call still waiting on it runs the child itself.
238+
*/
239+
private static Single<Context> sharedChildRun(
240+
Context parent, Node node, @Nullable Object nodeInput, String runId, RunNodeConfig config) {
241+
return Single.defer(
242+
() -> {
243+
ConcurrentMap<String, SingleSubject<Context>> childRuns =
244+
parent.requireNodeState().childRuns();
245+
String childKey = segment(node.name(), runId);
246+
SingleSubject<Context> run = SingleSubject.create();
247+
SingleSubject<Context> shared = childRuns.putIfAbsent(childKey, run);
248+
if (shared != null) {
249+
return shared.onErrorResumeNext(
250+
error ->
251+
error instanceof ChildRunCanceledException
252+
? sharedChildRun(parent, node, nodeInput, runId, config)
253+
: Single.error(error));
254+
}
255+
// Defer so a throw from scan or intercept releases the claimed run instead of leaking it.
256+
return Single.defer(() -> runOrReplayChild(parent, node, nodeInput, runId, config))
257+
.doOnSuccess(
258+
child -> {
259+
if (!keepsItsRun(child)) {
260+
childRuns.remove(childKey, run);
261+
}
262+
run.onSuccess(child);
263+
})
264+
.doOnError(
265+
error -> {
266+
childRuns.remove(childKey, run);
267+
run.onError(error);
268+
})
269+
.doOnDispose(
270+
() -> {
271+
childRuns.remove(childKey, run);
272+
run.onError(new ChildRunCanceledException());
273+
});
274+
});
275+
}
276+
232277
/**
233278
* Runs {@code node} as a child of {@code parent}, or replays the result it recorded in an earlier
234279
* turn under the same run ID.
@@ -258,6 +303,22 @@ private static Single<Context> runOrReplayChild(
258303
.run(nodeInput);
259304
}
260305

306+
/**
307+
* Returns whether a later call with the same run ID shares this finished run of a child: a failed
308+
* child runs again, as does one that waits for an output and left no outcome.
309+
*/
310+
private static boolean keepsItsRun(Context child) {
311+
NodeExecutionState state = child.requireNodeState();
312+
if (state.failure().isPresent()) {
313+
return false;
314+
}
315+
return !state.interruptIds().isEmpty()
316+
|| state.output().isPresent()
317+
|| state.selectedRoutes().isPresent()
318+
|| child.eventActions().transferToAgent().isPresent()
319+
|| !state.node().waitForOutput();
320+
}
321+
261322
/**
262323
* Returns the child's output, or fails with the child's failure or with an interrupt, which the
263324
* caller takes on as its own.
@@ -292,9 +353,11 @@ private static Maybe<Object> childResult(
292353
&& (childNode instanceof Workflow || childNode.waitForOutput())) {
293354
return Maybe.error(new NodeInterruptedException());
294355
}
295-
// No event of this run carries a delegate's replayed output, so the caller emits it.
356+
// No event of this run carries a delegate's replayed or shared output, so the caller emits it.
296357
Optional<Object> output = childState.output();
297-
if (delegatesOutput && output.isPresent() && !childState.outputEmitted()) {
358+
boolean emittedForCaller =
359+
childState.outputEmitted() && childState.outputParent() == parentState;
360+
if (delegatesOutput && output.isPresent() && !emittedForCaller) {
298361
parentState.adoptReplayedOutput(output.get());
299362
}
300363
return Maybe.fromOptional(output);
@@ -532,6 +595,13 @@ private Event.Builder newEvent() {
532595
}
533596
}
534597

598+
/** Ends the waiting calls of a shared child run whose first caller was canceled. */
599+
private static final class ChildRunCanceledException extends RuntimeException {
600+
ChildRunCanceledException() {
601+
super("The call that ran this child was canceled.");
602+
}
603+
}
604+
535605
/**
536606
* An event queued for the runner, paired with the signal that completes once the caller receives
537607
* the event and, for an error event, with the failure it reports.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 com.google.adk.JsonBaseModel;
20+
import com.google.adk.agents.InvocationContext;
21+
import com.google.adk.annotations.Experimental;
22+
import com.google.adk.events.Event;
23+
import com.google.auto.value.AutoValue;
24+
import com.google.common.collect.ImmutableSet;
25+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
26+
import com.google.genai.types.Content;
27+
import com.google.genai.types.FunctionCall;
28+
import com.google.genai.types.Part;
29+
import com.google.genai.types.Schema;
30+
import java.util.LinkedHashMap;
31+
import java.util.Map;
32+
import java.util.Optional;
33+
import java.util.UUID;
34+
import org.jspecify.annotations.Nullable;
35+
36+
/**
37+
* A node's request for user input, which pauses the workflow until the user answers it. A node
38+
* emits it from {@link Node#runNode}, and the runtime records it as a long-running {@value
39+
* #FUNCTION_CALL_NAME} function call whose ID is {@link #interruptId}. The user answers with a
40+
* function response that carries the same ID.
41+
*/
42+
@Experimental
43+
@AutoValue
44+
public abstract class RequestInput {
45+
46+
/** The name of the function call that carries a request for input. */
47+
public static final String FUNCTION_CALL_NAME = "adk_request_input";
48+
49+
static final String INTERRUPT_ID_KEY = "interruptId";
50+
static final String MESSAGE_KEY = "message";
51+
static final String PAYLOAD_KEY = "payload";
52+
static final String RESPONSE_SCHEMA_KEY = "response_schema";
53+
54+
/** Returns the ID of the request, which the answering function response carries. */
55+
public abstract String interruptId();
56+
57+
/** Returns the message to show the user, if any. */
58+
public abstract Optional<String> message();
59+
60+
/** Returns additional data for rendering the request, if any. */
61+
public abstract Optional<Object> payload();
62+
63+
/**
64+
* Returns the schema the answer must match, if any. An answer of the form {@code {"result":
65+
* value}} is unwrapped first, and a string value is read as JSON unless the schema accepts a
66+
* string.
67+
*/
68+
public abstract Optional<Schema> responseSchema();
69+
70+
/** Returns a builder whose interrupt ID is a new random UUID. */
71+
public static Builder builder() {
72+
return new AutoValue_RequestInput.Builder().interruptId(UUID.randomUUID().toString());
73+
}
74+
75+
public abstract Builder toBuilder();
76+
77+
/**
78+
* Returns the long-running function-call event that records this request. It has the model role,
79+
* so history rewriting keeps it as the call that the answer responds to.
80+
*
81+
* @throws IllegalArgumentException if the payload has no JSON form
82+
*/
83+
Event toEvent(InvocationContext invocationContext) {
84+
Map<String, @Nullable Object> args = new LinkedHashMap<>();
85+
args.put(INTERRUPT_ID_KEY, interruptId());
86+
args.put(MESSAGE_KEY, message().orElse(null));
87+
// Converted here so a payload with no JSON form fails the asking node, not the session service.
88+
args.put(PAYLOAD_KEY, payload().map(RequestInput::toJsonValue).orElse(null));
89+
args.put(RESPONSE_SCHEMA_KEY, responseSchema().map(RequestInput::toJsonValue).orElse(null));
90+
FunctionCall call =
91+
FunctionCall.builder().name(FUNCTION_CALL_NAME).id(interruptId()).args(args).build();
92+
return Event.builder()
93+
.id(invocationContext.newUuid())
94+
.timestamp(invocationContext.now().toEpochMilli())
95+
.author("")
96+
.content(
97+
Content.builder()
98+
.role("model")
99+
.parts(Part.builder().functionCall(call).build())
100+
.build())
101+
.longRunningToolIds(ImmutableSet.of(interruptId()))
102+
.build();
103+
}
104+
105+
private static @Nullable Object toJsonValue(Object value) {
106+
return JsonBaseModel.getMapper().convertValue(value, Object.class);
107+
}
108+
109+
/** Builder for {@link RequestInput}. */
110+
@AutoValue.Builder
111+
public abstract static class Builder {
112+
113+
@CanIgnoreReturnValue
114+
public abstract Builder interruptId(String interruptId);
115+
116+
@CanIgnoreReturnValue
117+
public abstract Builder message(@Nullable String message);
118+
119+
@CanIgnoreReturnValue
120+
public abstract Builder payload(@Nullable Object payload);
121+
122+
@CanIgnoreReturnValue
123+
public abstract Builder responseSchema(@Nullable Schema responseSchema);
124+
125+
public abstract RequestInput build();
126+
}
127+
}

0 commit comments

Comments
 (0)