Skip to content

Commit 3731a10

Browse files
Merge pull request #1587 from DABH:deterministic-scheduler-seam
PiperOrigin-RevId: 993621169
2 parents 4508265 + 1b89d23 commit 3731a10

14 files changed

Lines changed: 577 additions & 21 deletions

File tree

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
import com.google.genai.types.Content;
4040
import com.google.genai.types.FunctionCall;
4141
import com.google.genai.types.FunctionResponse;
42+
import io.reactivex.rxjava3.core.Scheduler;
43+
import io.reactivex.rxjava3.schedulers.Schedulers;
4244
import java.util.ArrayList;
4345
import java.util.Collections;
4446
import java.util.HashSet;
@@ -74,6 +76,7 @@ public class InvocationContext {
7476
// Shared by reference so a sub-agent's checkpoint is visible to its parent and the runner.
7577
private final Map<String, Map<String, Object>> agentStates;
7678
private final Map<String, Boolean> endOfAgents;
79+
@Nullable private final Scheduler scheduler;
7780

7881
@Nullable private String branch;
7982
private BaseAgent agent;
@@ -103,6 +106,7 @@ protected InvocationContext(Builder builder) {
103106
this.callbackContextData = builder.callbackContextData;
104107
this.agentStates = builder.agentStates;
105108
this.endOfAgents = builder.endOfAgents;
109+
this.scheduler = builder.scheduler;
106110
}
107111

108112
/** Returns a new {@link Builder} for creating {@link InvocationContext} instances. */
@@ -288,6 +292,17 @@ public String userId() {
288292
return session.userId();
289293
}
290294

295+
/**
296+
* Returns the {@link Scheduler} on which this invocation runs the work ADK hands to worker
297+
* threads: the sub-agents of a {@code ParallelAgent}, tool calls in {@code
298+
* ToolExecutionMode.PARALLEL_SUBSCRIBE} mode, and the hop that starts the live send loop. An
299+
* agent-level setting ({@code ParallelAgent.Builder#scheduler}, {@code
300+
* LlmAgent.Builder#executor}) takes precedence over it. Defaults to {@link Schedulers#io()}.
301+
*/
302+
public Scheduler scheduler() {
303+
return scheduler != null ? scheduler : Schedulers.io();
304+
}
305+
291306
/** Generates a new unique ID for an invocation context. */
292307
public static String newInvocationContextId() {
293308
return "e-" + UUID.randomUUID();
@@ -620,6 +635,7 @@ private Builder(InvocationContext context) {
620635
// Shared by reference, not copied: the checkpoints belong to the invocation, not a context.
621636
this.agentStates = context.agentStates;
622637
this.endOfAgents = context.endOfAgents;
638+
this.scheduler = context.scheduler;
623639
}
624640

625641
private BaseSessionService sessionService;
@@ -642,6 +658,7 @@ private Builder(InvocationContext context) {
642658
private Map<String, Object> callbackContextData = new ConcurrentHashMap<>();
643659
private Map<String, Map<String, Object>> agentStates = new ConcurrentHashMap<>();
644660
private Map<String, Boolean> endOfAgents = new ConcurrentHashMap<>();
661+
@Nullable private Scheduler scheduler = null;
645662

646663
/**
647664
* Sets the session service for managing session state.
@@ -823,6 +840,19 @@ public Builder resumabilityConfig(@Nullable ResumabilityConfig resumabilityConfi
823840
return this;
824841
}
825842

843+
/**
844+
* Sets the scheduler for the invocation's internal fan-out; see {@link
845+
* InvocationContext#scheduler()}. {@code null} (the default) selects {@link Schedulers#io()}.
846+
*
847+
* @param scheduler the scheduler, or {@code null} for the default.
848+
* @return this builder instance for chaining.
849+
*/
850+
@CanIgnoreReturnValue
851+
public Builder scheduler(@Nullable Scheduler scheduler) {
852+
this.scheduler = scheduler;
853+
return this;
854+
}
855+
826856
/**
827857
* Sets the callback context data for the invocation.
828858
*
@@ -892,7 +922,8 @@ public boolean equals(Object o) {
892922
&& Objects.equals(contextCacheConfig, that.contextCacheConfig)
893923
&& Objects.equals(resumabilityConfig, that.resumabilityConfig)
894924
&& Objects.equals(invocationCostManager, that.invocationCostManager)
895-
&& Objects.equals(callbackContextData, that.callbackContextData);
925+
&& Objects.equals(callbackContextData, that.callbackContextData)
926+
&& Objects.equals(scheduler, that.scheduler);
896927
}
897928

898929
@Override
@@ -915,6 +946,7 @@ public int hashCode() {
915946
contextCacheConfig,
916947
resumabilityConfig,
917948
invocationCostManager,
918-
callbackContextData);
949+
callbackContextData,
950+
scheduler);
919951
}
920952
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ public Builder outputSchema(Schema outputSchema) {
553553
return this;
554554
}
555555

556+
/**
557+
* Sets the executor for this agent's offloaded work: tool calls in {@code
558+
* ToolExecutionMode.PARALLEL_SUBSCRIBE} mode and the live request loop. When unset, that work
559+
* runs on the invocation's scheduler ({@link InvocationContext#scheduler()}).
560+
*/
556561
@CanIgnoreReturnValue
557562
public Builder executor(Executor executor) {
558563
this.executor = executor;

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2525
import io.reactivex.rxjava3.core.Flowable;
2626
import io.reactivex.rxjava3.core.Scheduler;
27-
import io.reactivex.rxjava3.schedulers.Schedulers;
2827
import java.util.ArrayList;
2928
import java.util.List;
3029
import java.util.Objects;
3130
import java.util.Set;
3231
import java.util.concurrent.atomic.AtomicBoolean;
32+
import org.jspecify.annotations.Nullable;
3333
import org.slf4j.Logger;
3434
import org.slf4j.LoggerFactory;
3535

@@ -75,7 +75,7 @@
7575
public class ParallelAgent extends BaseAgent {
7676

7777
private static final Logger logger = LoggerFactory.getLogger(ParallelAgent.class);
78-
private final Scheduler scheduler;
78+
@Nullable private final Scheduler scheduler;
7979

8080
/**
8181
* Constructor for ParallelAgent.
@@ -85,27 +85,38 @@ public class ParallelAgent extends BaseAgent {
8585
* @param subAgents The list of sub-agents to run in parallel.
8686
* @param beforeAgentCallback Optional callback before the agent runs.
8787
* @param afterAgentCallback Optional callback after the agent runs.
88-
* @param scheduler The scheduler to use for parallel execution.
88+
* @param scheduler The scheduler to use for parallel execution, or {@code null} to use the
89+
* invocation's scheduler.
8990
*/
9091
private ParallelAgent(
9192
String name,
9293
String description,
9394
List<? extends BaseAgent> subAgents,
9495
List<Callbacks.BeforeAgentCallback> beforeAgentCallback,
9596
List<Callbacks.AfterAgentCallback> afterAgentCallback,
96-
Scheduler scheduler) {
97+
@Nullable Scheduler scheduler) {
9798

9899
super(name, description, subAgents, beforeAgentCallback, afterAgentCallback);
99100
this.scheduler = scheduler;
100101
}
101102

103+
/** Returns the agent's own scheduler when configured, otherwise the invocation's. */
104+
private Scheduler schedulerFor(InvocationContext invocationContext) {
105+
return scheduler != null ? scheduler : invocationContext.scheduler();
106+
}
107+
102108
/** Builder for {@link ParallelAgent}. */
103109
public static class Builder extends BaseAgent.Builder<Builder> {
104110

105-
private Scheduler scheduler = Schedulers.io();
111+
@Nullable private Scheduler scheduler = null;
106112

113+
/**
114+
* Sets the scheduler the sub-agents are subscribed on, or {@code null} (the default) to inherit
115+
* the invocation's scheduler ({@link InvocationContext#scheduler()}: {@code Schedulers.io()}
116+
* unless the {@code Runner} is configured with another one).
117+
*/
107118
@CanIgnoreReturnValue
108-
public Builder scheduler(Scheduler scheduler) {
119+
public Builder scheduler(@Nullable Scheduler scheduler) {
109120
this.scheduler = scheduler;
110121
return this;
111122
}
@@ -186,11 +197,13 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
186197
.filter(Objects::nonNull)
187198
.collect(ImmutableSet.toImmutableSet());
188199

200+
Scheduler fanOutScheduler = schedulerFor(invocationContext);
189201
if (!invocationContext.isResumable()) {
190202
var updatedInvocationContext = setBranchForCurrentAgent(this, invocationContext);
191203
List<Flowable<Event>> agentFlowables = new ArrayList<>();
192204
for (BaseAgent subAgent : currentSubAgents) {
193-
agentFlowables.add(subAgent.runAsync(updatedInvocationContext).subscribeOn(scheduler));
205+
agentFlowables.add(
206+
subAgent.runAsync(updatedInvocationContext).subscribeOn(fanOutScheduler));
194207
}
195208
return Flowable.merge(agentFlowables)
196209
.takeUntil((Event event) -> asksThisAgentToExit(event, directSubAgentNames));
@@ -219,7 +232,7 @@ protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
219232
agentFlowables.add(
220233
subAgent
221234
.runAsync(updatedInvocationContext)
222-
.subscribeOn(scheduler)
235+
.subscribeOn(fanOutScheduler)
223236
.doOnNext(
224237
event -> {
225238
if (invocationContext.shouldPauseInvocation(event)) {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ public enum StreamingMode {
5858
*
5959
* <p>PARALLEL_SUBSCRIBE: like {@code PARALLEL}, but every tool is additionally subscribed on a
6060
* worker thread, so blocking tools also run concurrently. Tool implementations must be
61-
* thread-safe. The worker is the agent's executor when set, otherwise the RxJava IO scheduler.
61+
* thread-safe. The worker is the agent's executor when set, otherwise the invocation's scheduler
62+
* ({@code Runner.Builder#scheduler}; the RxJava IO scheduler by default).
6263
*/
6364
public enum ToolExecutionMode {
6465
NONE,

‎core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
import io.reactivex.rxjava3.core.Single;
5656
import io.reactivex.rxjava3.disposables.Disposable;
5757
import io.reactivex.rxjava3.observers.DisposableCompletableObserver;
58-
import io.reactivex.rxjava3.schedulers.Schedulers;
5958
import java.util.ArrayList;
6059
import java.util.List;
6160
import java.util.Optional;
@@ -636,7 +635,7 @@ public Flowable<Event> runLive(InvocationContext invocationContext) {
636635
});
637636
Disposable sendTask =
638637
historySent
639-
.observeOn(agent.executor().map(Schedulers::from).orElse(Schedulers.io()))
638+
.observeOn(Functions.resolveWorkerScheduler(invocationContext))
640639
.andThen(
641640
liveRequests
642641
.onBackpressureBuffer()

‎core/src/main/java/com/google/adk/flows/llmflows/Functions.java‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ private static Observable<Event> buildToolExecutionObservable(
263263
return Observable.fromIterable(validFunctionCalls).concatMapMaybe(functionCallMapper);
264264
}
265265
if (mode == ToolExecutionMode.PARALLEL_SUBSCRIBE) {
266-
Scheduler scheduler = resolveToolExecutionScheduler(invocationContext);
266+
Scheduler scheduler = resolveWorkerScheduler(invocationContext);
267267
return Observable.fromIterable(validFunctionCalls)
268268
.concatMapEager(
269269
call -> functionCallMapper.apply(call).toObservable().subscribeOn(scheduler));
@@ -274,12 +274,16 @@ private static Observable<Event> buildToolExecutionObservable(
274274
.concatMapEager(call -> functionCallMapper.apply(call).toObservable());
275275
}
276276

277-
/** Agent executor if set, otherwise the IO scheduler. */
278-
private static Scheduler resolveToolExecutionScheduler(InvocationContext invocationContext) {
277+
/**
278+
* Returns the scheduler for the work this invocation hands to a worker (tool calls in {@code
279+
* ToolExecutionMode.PARALLEL_SUBSCRIBE} mode and the live send loop): the agent's executor when
280+
* set, otherwise the invocation's scheduler.
281+
*/
282+
static Scheduler resolveWorkerScheduler(InvocationContext invocationContext) {
279283
if (invocationContext.agent() instanceof LlmAgent llmAgent) {
280-
return llmAgent.executor().map(Schedulers::from).orElse(Schedulers.io());
284+
return llmAgent.executor().map(Schedulers::from).orElseGet(invocationContext::scheduler);
281285
}
282-
return Schedulers.io();
286+
return invocationContext.scheduler();
283287
}
284288

285289
private static Function<FunctionCall, Maybe<Event>> getFunctionCallMapper(

‎core/src/main/java/com/google/adk/runner/Runner.java‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
import io.reactivex.rxjava3.core.Completable;
7272
import io.reactivex.rxjava3.core.Flowable;
7373
import io.reactivex.rxjava3.core.Maybe;
74+
import io.reactivex.rxjava3.core.Scheduler;
7475
import io.reactivex.rxjava3.core.Single;
7576
import io.reactivex.rxjava3.subjects.CompletableSubject;
7677
import java.util.ArrayList;
@@ -96,6 +97,7 @@ public class Runner {
9697
@Nullable private final EventsCompactionConfig eventsCompactionConfig;
9798
@Nullable private final ContextCacheConfig contextCacheConfig;
9899
private final @Nullable ResumabilityConfig resumabilityConfig;
100+
@Nullable private final Scheduler scheduler;
99101
private final ConcurrentMap<String, Completable> activeSessionCompletables =
100102
new MapMaker().weakValues().makeMap();
101103

@@ -108,6 +110,7 @@ public static class Builder {
108110
private BaseSessionService sessionService = new InMemorySessionService();
109111
@Nullable private BaseMemoryService memoryService = null;
110112
private List<? extends Plugin> plugins = ImmutableList.of();
113+
@Nullable private Scheduler scheduler = null;
111114

112115
@CanIgnoreReturnValue
113116
public Builder app(App app) {
@@ -162,6 +165,25 @@ public Builder plugins(Plugin... plugins) {
162165
return this;
163166
}
164167

168+
/**
169+
* Sets the {@link Scheduler} for the work ADK hands off to worker threads in this runner's
170+
* invocations: the sub-agents of a {@code ParallelAgent}, tool calls in {@link
171+
* RunConfig.ToolExecutionMode#PARALLEL_SUBSCRIBE} mode, and the hop that starts the live send
172+
* loop. The runs that an {@code AgentTool} nests inherit it. An agent-level {@code
173+
* ParallelAgent.Builder#scheduler} or {@code LlmAgent.Builder#executor} takes precedence.
174+
* {@code null} (the default) selects {@code Schedulers.io()}.
175+
*
176+
* <p>Only these hand-offs are affected; work that completes asynchronously elsewhere (model
177+
* clients, session services, MCP, plugins) stays on its own threads. {@code
178+
* Schedulers.trampoline()} runs the hand-offs inline, so sub-agents and tools are subscribed
179+
* sequentially, but anything they start asynchronously can still overlap.
180+
*/
181+
@CanIgnoreReturnValue
182+
public Builder scheduler(@Nullable Scheduler scheduler) {
183+
this.scheduler = scheduler;
184+
return this;
185+
}
186+
165187
public Runner build() {
166188
BaseAgent buildAgent;
167189
String buildAppName;
@@ -213,7 +235,8 @@ public Runner build() {
213235
buildPlugins,
214236
buildEventsCompactionConfig,
215237
buildContextCacheConfig,
216-
buildResumabilityConfig);
238+
buildResumabilityConfig,
239+
scheduler);
217240
}
218241
}
219242

@@ -295,6 +318,30 @@ protected Runner(
295318
@Nullable EventsCompactionConfig eventsCompactionConfig,
296319
@Nullable ContextCacheConfig contextCacheConfig,
297320
@Nullable ResumabilityConfig resumabilityConfig) {
321+
this(
322+
agent,
323+
appName,
324+
artifactService,
325+
sessionService,
326+
memoryService,
327+
plugins,
328+
eventsCompactionConfig,
329+
contextCacheConfig,
330+
resumabilityConfig,
331+
/* scheduler= */ null);
332+
}
333+
334+
private Runner(
335+
BaseAgent agent,
336+
String appName,
337+
BaseArtifactService artifactService,
338+
BaseSessionService sessionService,
339+
@Nullable BaseMemoryService memoryService,
340+
List<? extends Plugin> plugins,
341+
@Nullable EventsCompactionConfig eventsCompactionConfig,
342+
@Nullable ContextCacheConfig contextCacheConfig,
343+
@Nullable ResumabilityConfig resumabilityConfig,
344+
@Nullable Scheduler scheduler) {
298345
this.agent = agent;
299346
this.appName = appName;
300347
this.artifactService = artifactService;
@@ -304,6 +351,7 @@ protected Runner(
304351
this.eventsCompactionConfig = createEventsCompactionConfig(agent, eventsCompactionConfig);
305352
this.contextCacheConfig = contextCacheConfig;
306353
this.resumabilityConfig = resumabilityConfig;
354+
this.scheduler = scheduler;
307355
}
308356

309357
/**
@@ -1217,6 +1265,7 @@ private InvocationContext.Builder newInvocationContextBuilder(Session session, B
12171265
.eventsCompactionConfig(this.eventsCompactionConfig)
12181266
.contextCacheConfig(this.contextCacheConfig)
12191267
.resumabilityConfig(this.resumabilityConfig)
1268+
.scheduler(this.scheduler)
12201269
.agent(agent);
12211270
}
12221271

‎core/src/main/java/com/google/adk/tools/AgentTool.java‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
import com.google.adk.agents.RunConfig;
2929
import com.google.adk.agents.RunConfig.StreamingMode;
3030
import com.google.adk.events.Event;
31+
import com.google.adk.memory.InMemoryMemoryService;
3132
import com.google.adk.plugins.Plugin;
32-
import com.google.adk.runner.InMemoryRunner;
3333
import com.google.adk.runner.Runner;
3434
import com.google.adk.sessions.State;
3535
import com.google.common.annotations.VisibleForTesting;
@@ -187,7 +187,15 @@ public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContex
187187
this.includePlugins
188188
? ImmutableList.of(toolContext.invocationContext().pluginManager())
189189
: ImmutableList.of();
190-
Runner runner = new InMemoryRunner(this.agent, toolContext.agentName(), plugins);
190+
// The nested run inherits the caller's scheduler so its fan-out stays on the same scheduler.
191+
Runner runner =
192+
Runner.builder()
193+
.agent(this.agent)
194+
.appName(toolContext.agentName())
195+
.plugins(plugins)
196+
.memoryService(new InMemoryMemoryService())
197+
.scheduler(toolContext.invocationContext().scheduler())
198+
.build();
191199
// Follow the caller's RunConfig but run unary: only the last event becomes the result.
192200
RunConfig callerRunConfig = toolContext.invocationContext().runConfig();
193201
RunConfig runConfig =

0 commit comments

Comments
 (0)