Skip to content

Commit af9d584

Browse files
MiloszSobczykcopybara-github
authored andcommitted
feat: run a node once per item of a list input
PiperOrigin-RevId: 1002968674
1 parent bca0591 commit af9d584

2 files changed

Lines changed: 974 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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.fasterxml.jackson.core.JsonProcessingException;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectReader;
22+
import com.google.adk.JsonBaseModel;
23+
import com.google.adk.agents.Context;
24+
import com.google.adk.annotations.Experimental;
25+
import com.google.common.collect.ImmutableList;
26+
import com.google.genai.types.Content;
27+
import com.google.genai.types.Part;
28+
import com.google.genai.types.Schema;
29+
import com.google.genai.types.Type;
30+
import io.reactivex.rxjava3.core.Flowable;
31+
import io.reactivex.rxjava3.core.Single;
32+
import java.util.ArrayList;
33+
import java.util.Collections;
34+
import java.util.List;
35+
import java.util.concurrent.atomic.AtomicBoolean;
36+
import java.util.concurrent.atomic.AtomicReferenceArray;
37+
import org.jspecify.annotations.Nullable;
38+
39+
/**
40+
* Static factories for workflow nodes that run a node once per item of a list input. Each item runs
41+
* as a child of the returned node on its own branch, and the node outputs the items' results as a
42+
* list in input order. A failed item cancels the others and fails the node, while an item that asks
43+
* for input stops queued items from starting.
44+
*/
45+
@Experimental
46+
public final class ParallelWorkers {
47+
48+
/**
49+
* Returns a node that runs {@code node} on every item at once. Otherwise it behaves as {@link
50+
* #parallelNode(Node, int)} describes.
51+
*
52+
* @throws WorkflowConfigurationException if {@code node} is {@link Start}
53+
*/
54+
public static Node parallelNode(Node node) {
55+
return parallelNode(node, Integer.MAX_VALUE);
56+
}
57+
58+
/**
59+
* Returns a node that runs {@code node} on each item of a list input, at most {@code
60+
* maxParallelWorkers} at a time and starting them in input order; the returned node takes {@code
61+
* node}'s name, description, state schema and {@link Node#requiresAllPredecessors()}, and leaves
62+
* its config and input and output schemas to each item's run. A text-only {@link Content}, such
63+
* as a user message, is read as JSON, and a JSON list fans out. Its text reaches the items as a
64+
* {@code String} if it is not JSON, or if it is a non-list value and the item's input schema
65+
* takes a string; any other non-list input runs as one item.
66+
*
67+
* @throws WorkflowConfigurationException if {@code node} is {@link Start} or {@code
68+
* maxParallelWorkers} is less than 1
69+
*/
70+
public static Node parallelNode(Node node, int maxParallelWorkers) {
71+
if (node instanceof Start) {
72+
throw new WorkflowConfigurationException("parallelNode cannot wrap Start.");
73+
}
74+
if (maxParallelWorkers < 1) {
75+
throw new WorkflowConfigurationException(
76+
String.format("maxParallelWorkers must be at least 1, but was %s.", maxParallelWorkers));
77+
}
78+
return new ParallelWorkerNode(node, maxParallelWorkers);
79+
}
80+
81+
private static final class ParallelWorkerNode extends BaseNode {
82+
83+
private static final ObjectReader JSON_READER =
84+
JsonBaseModel.getMapper()
85+
.readerFor(Object.class)
86+
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
87+
// No run ID: items subscribe in input order, so each gets the same generated ID on resume.
88+
private static final RunNodeConfig ITEM_CONFIG =
89+
RunNodeConfig.builder().useSubBranch(true).build();
90+
91+
private final Node worker;
92+
private final int maxParallelWorkers;
93+
94+
ParallelWorkerNode(Node worker, int maxParallelWorkers) {
95+
super(
96+
worker.name(),
97+
worker.description(),
98+
/* rerunOnResume= */ true,
99+
/* waitForOutput= */ false,
100+
NodeConfig.builder().build(),
101+
/* inputSchema= */ null,
102+
/* outputSchema= */ null,
103+
worker.stateSchema().orElse(null));
104+
this.worker = worker;
105+
this.maxParallelWorkers = maxParallelWorkers;
106+
}
107+
108+
@Override
109+
public boolean requiresAllPredecessors() {
110+
return worker.requiresAllPredecessors();
111+
}
112+
113+
@Override
114+
public Flowable<?> runNode(Context context, @Nullable Object nodeInput) {
115+
return Flowable.defer(
116+
() -> {
117+
Object input = nodeInput instanceof Content content ? unwrap(content) : nodeInput;
118+
List<?> items = input instanceof List<?> list ? list : Collections.singletonList(input);
119+
return new FanOut(context, items).run();
120+
});
121+
}
122+
123+
/**
124+
* Returns the JSON value of text-only {@code content}, or its text if that is not JSON or is a
125+
* non-list value for an item that takes a string; any other content is returned as it is.
126+
*/
127+
private @Nullable Object unwrap(Content content) {
128+
List<Part> parts = content.parts().orElse(ImmutableList.of());
129+
if (parts.isEmpty() || parts.stream().anyMatch(part -> part.text().isEmpty())) {
130+
return content;
131+
}
132+
String text = content.text();
133+
Object json;
134+
try {
135+
json = JSON_READER.readValue(text);
136+
} catch (JsonProcessingException e) {
137+
return text;
138+
}
139+
if (json instanceof List<?> list) {
140+
return list;
141+
}
142+
return worker.inputSchema().map(ParallelWorkerNode::acceptsString).orElse(false)
143+
? text
144+
: json;
145+
}
146+
147+
private static boolean acceptsString(Schema schema) {
148+
return schema.type().map(type -> type.knownEnum() == Type.Known.STRING).orElse(false)
149+
|| schema.anyOf().orElse(ImmutableList.of()).stream()
150+
.anyMatch(ParallelWorkerNode::acceptsString);
151+
}
152+
153+
private final class FanOut {
154+
private final Context context;
155+
private final List<?> items;
156+
// Written by the item runs, which may finish on different threads.
157+
private final AtomicReferenceArray<Object> outputs;
158+
private final AtomicReferenceArray<DynamicNodeFailedException> failures;
159+
private final AtomicBoolean interrupted = new AtomicBoolean();
160+
161+
FanOut(Context context, List<?> items) {
162+
this.context = context;
163+
this.items = items;
164+
this.outputs = new AtomicReferenceArray<>(items.size());
165+
this.failures = new AtomicReferenceArray<>(items.size());
166+
}
167+
168+
Flowable<?> run() {
169+
return Flowable.range(0, items.size())
170+
// An interrupt stops only the items that had to wait for a slot.
171+
.takeWhile(index -> index < maxParallelWorkers || !interrupted.get())
172+
.flatMapSingle(this::runItem, /* delayErrors= */ false, maxParallelWorkers)
173+
// A failed item stops the rest; the typed parameter rules out takeUntil(Publisher).
174+
.takeUntil((Boolean failed) -> failed)
175+
.ignoreElements()
176+
.andThen(Flowable.defer(this::finish));
177+
}
178+
179+
/** Runs one item, or replays its result on resume, and emits whether it failed. */
180+
private Single<Boolean> runItem(int index) {
181+
return context
182+
.runNode(worker, items.get(index), ITEM_CONFIG)
183+
.doOnSuccess(output -> outputs.set(index, output))
184+
.ignoreElement()
185+
.toSingleDefault(false)
186+
.onErrorResumeNext(error -> settle(index, error));
187+
}
188+
189+
/**
190+
* Records a failure so the lowest index wins a race, and keeps an interrupt from ending the
191+
* rest.
192+
*/
193+
private Single<Boolean> settle(int index, Throwable error) {
194+
if (error instanceof DynamicNodeFailedException failure) {
195+
failures.set(index, failure);
196+
return Single.just(true);
197+
}
198+
if (error instanceof NodeInterruptedException) {
199+
// The runtime has already added the item's interrupt IDs to this node.
200+
interrupted.set(true);
201+
return Single.just(false);
202+
}
203+
return Single.error(error);
204+
}
205+
206+
/**
207+
* Fails with the lowest-index failure, or else waits with the items that asked for input, or
208+
* else outputs the items' results. Each failure's error event stays at its item.
209+
*/
210+
private Flowable<?> finish() {
211+
List<@Nullable Object> results = new ArrayList<>(items.size());
212+
for (int i = 0; i < items.size(); i++) {
213+
DynamicNodeFailedException failure = failures.get(i);
214+
if (failure != null) {
215+
return Flowable.error(failure);
216+
}
217+
results.add(outputs.get(i));
218+
}
219+
if (interrupted.get()) {
220+
return Flowable.error(new NodeInterruptedException());
221+
}
222+
return Flowable.just(Collections.unmodifiableList(results));
223+
}
224+
}
225+
}
226+
227+
private ParallelWorkers() {}
228+
}

0 commit comments

Comments
 (0)