Skip to content

Commit 78c63ee

Browse files
committed
fix(flows): avoid StackOverflowError after many steps in BaseLlmFlow
BaseLlmFlow.run subscribed to the next step from inside the previous step's completion, through concatWith, toList, flatMapPublisher and PersistBarrier.awaitPersisted(...).andThen(run(...)). When a step completed on the subscribing thread, as it does with the core models and the session services, each step ran 23 stack frames deeper than the one before. With a 1 MB thread stack, the default on x86-64, an agent that kept calling tools failed with StackOverflowError after roughly 250-300 LLM calls, before the default maxLlmCalls of 500 could end the run, and the error never reached onError. Drive the steps with repeatUntil instead, as LoopAgent does on its resumable path. repeatUntil resubscribes from a loop when the source completes synchronously, so every step starts at the same stack depth, like the while loop in adk-python's BaseLlmFlow.run_async. The end-of-flow checks, the pause on a pending long-running call, the PersistBarrier wait between steps and maxSteps are unchanged.
1 parent 4092a1f commit 78c63ee

2 files changed

Lines changed: 61 additions & 5 deletions

File tree

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
import java.util.List;
6161
import java.util.Optional;
6262
import java.util.Set;
63+
import java.util.concurrent.atomic.AtomicBoolean;
64+
import java.util.concurrent.atomic.AtomicInteger;
6365
import java.util.concurrent.atomic.AtomicReference;
6466
import java.util.function.BiFunction;
6567
import org.slf4j.Logger;
@@ -511,11 +513,37 @@ private Flowable<Event> runOneStep(Context spanContext, InvocationContext contex
511513
*/
512514
@Override
513515
public Flowable<Event> run(InvocationContext invocationContext) {
514-
return run(Context.current(), invocationContext, 0);
516+
Context spanContext = Context.current();
517+
return Flowable.defer(
518+
() -> {
519+
AtomicInteger stepsCompleted = new AtomicInteger(0);
520+
AtomicBoolean continueFlow = new AtomicBoolean(false);
521+
// Run one step per subscription and let repeatUntil subscribe again until the flow ends.
522+
// Subscribing to the next step from the previous step's completion would nest the steps
523+
// on the stack whenever a step completes synchronously, as it does with the built-in
524+
// models.
525+
return Flowable.defer(
526+
() -> {
527+
continueFlow.set(false);
528+
return runStep(
529+
spanContext,
530+
invocationContext,
531+
stepsCompleted.getAndIncrement(),
532+
continueFlow);
533+
})
534+
.repeatUntil(() -> !continueFlow.get());
535+
});
515536
}
516537

517-
private Flowable<Event> run(
518-
Context spanContext, InvocationContext invocationContext, int stepsCompleted) {
538+
/**
539+
* Runs {@link #runOneStep} once. If the flow should run another step, sets {@code continueFlow}
540+
* and waits until the Runner has persisted this step's events.
541+
*/
542+
private Flowable<Event> runStep(
543+
Context spanContext,
544+
InvocationContext invocationContext,
545+
int stepsCompleted,
546+
AtomicBoolean continueFlow) {
519547
Flowable<Event> currentStepEvents = runOneStep(spanContext, invocationContext).cache();
520548
if (stepsCompleted + 1 >= maxSteps) {
521549
logger.debug("Ending flow execution because max steps reached.");
@@ -543,10 +571,10 @@ private Flowable<Event> run(
543571
return Flowable.empty();
544572
} else {
545573
logger.debug("Continuing to next step of the flow.");
574+
continueFlow.set(true);
546575
// Wait until the Runner has persisted this step's events so the next step's
547576
// request is not built from a stale session (see PersistBarrier).
548-
return PersistBarrier.awaitPersisted(invocationContext, eventList)
549-
.andThen(run(spanContext, invocationContext, stepsCompleted + 1));
577+
return PersistBarrier.awaitPersisted(invocationContext, eventList).toFlowable();
550578
}
551579
}));
552580
}

‎core/src/test/java/com/google/adk/flows/llmflows/BaseLlmFlowTest.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.google.adk.events.Event;
3636
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
3737
import com.google.adk.flows.llmflows.ResponseProcessor.ResponseProcessingResult;
38+
import com.google.adk.models.LlmCallsLimitExceededException;
3839
import com.google.adk.models.LlmRequest;
3940
import com.google.adk.models.LlmResponse;
4041
import com.google.adk.testing.TestLlm;
@@ -59,6 +60,7 @@
5960
import io.reactivex.rxjava3.core.Maybe;
6061
import io.reactivex.rxjava3.core.Single;
6162
import io.reactivex.rxjava3.schedulers.Schedulers;
63+
import io.reactivex.rxjava3.subscribers.TestSubscriber;
6264
import java.util.List;
6365
import java.util.Map;
6466
import java.util.Optional;
@@ -191,6 +193,32 @@ public void run_withFunctionCallsAndMaxSteps_stopsAfterMaxSteps() {
191193
Content.fromParts(Part.fromFunctionResponse("my_function", testResponse)));
192194
}
193195

196+
@Test
197+
public void run_modelRespondingOnSubscribingThread_reachesMaxLlmCallsWithoutStackOverflow() {
198+
// The built-in models emit a non-streaming response on the subscribing thread, as
199+
// Flowable.just does here. Running the steps must not nest one step's subscription inside the
200+
// previous step's completion, or the stack overflows long before maxLlmCalls is reached.
201+
int maxLlmCalls = 2_000;
202+
Content contentWithFunctionCall =
203+
Content.fromParts(Part.fromFunctionCall("my_function", ImmutableMap.of("arg1", "value1")));
204+
TestLlm testLlm =
205+
createTestLlm(() -> Flowable.just(createLlmResponse(contentWithFunctionCall)));
206+
ImmutableMap<String, Object> testResponse =
207+
ImmutableMap.<String, Object>of("response", "response for my_function");
208+
InvocationContext invocationContext =
209+
createInvocationContext(
210+
createTestAgentBuilder(testLlm)
211+
.tools(ImmutableList.of(new TestTool("my_function", testResponse)))
212+
.build(),
213+
RunConfig.builder().maxLlmCalls(maxLlmCalls).build());
214+
BaseLlmFlow baseLlmFlow = createBaseLlmFlowWithoutProcessors();
215+
216+
TestSubscriber<Event> subscriber = baseLlmFlow.run(invocationContext).test();
217+
218+
subscriber.assertError(LlmCallsLimitExceededException.class);
219+
assertThat(testLlm.getRequests()).hasSize(maxLlmCalls);
220+
}
221+
194222
@Test
195223
public void run_withLongRunningFunctionCall_returnsCorrectEventsWithLongRunningToolIds() {
196224
Content firstContent =

0 commit comments

Comments
 (0)