Skip to content

Commit 27c53cf

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: pause a workflow for user input
PiperOrigin-RevId: 1004727066
1 parent a313ce4 commit 27c53cf

8 files changed

Lines changed: 714 additions & 27 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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,9 @@ public ImmutableSet<String> interruptIds() {
196196
}
197197

198198
/**
199-
* Returns the user's answers to the long-running calls that this node or its descendants raised
200-
* in an earlier turn, keyed by interrupt ID. A null answer has a null value in the map. The map
201-
* 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.
202202
*/
203203
public Map<String, @Nullable Object> resumeInputs() {
204204
return resumeInputs;
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)