Repository navigation
fix(flows): avoid StackOverflowError after many steps in BaseLlmFlow - #1565
Conversation
|
Hi @innoprej, thank you for your contribution We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team we will keep you posted if any additional information is required. thank you. |
78c63ee to
5d92339
Compare
|
Helllo, could you please merge the newest changes that were introduced in the code? Afterwards, please ensure that the code still works correctly. |
5d92339 to
7470b43
Compare
|
Done. I rebased the single commit onto the current On Windows 11 with JDK 17, |
|
@MiloszSobczyk Could you take another look when you have a moment? Your approval is on the previous commit (5d92339), and the rebased commit (7470b43) has no |
|
Hello, sorry for the delay. Thank you for the rebase, the only change since my approval is the import, so this still LGTM. One optional idea, please feel free to skip it: the regression test passes on the old code with an 8 MB stack. Recording the |
| // The built-in models emit a non-streaming response on the subscribing thread, as | ||
| // Flowable.just does here. Running the steps must not nest one step's subscription inside the | ||
| // previous step's completion, or the stack overflows long before maxLlmCalls is reached. |
There was a problem hiding this comment.
No need for that comment. Reword to say // Flowable.just answers on the subscribing thread, like the built-in models.
There was a problem hiding this comment.
Replaced the three lines with that comment.
7470b43 to
9a98d4d
Compare
|
Thanks for taking another look. I added it: the test model records the Since 7470b43 only |
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.
9a98d4d to
31ac2df
Compare
|
Rebased onto the current |
|
Thank you for your contribution @innoprej. The PR has been merged after some internal changes and will be part of the next non bugfix release. |
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
StackOverflowErrorinsidePersistBarrier.awaitPersisted(a single step with many events)2. Or, if no issue exists, describe the change:
Problem:
BaseLlmFlow.runsubscribes to each step from inside the previous step's completion (concatWith→toList→flatMapPublisher→PersistBarrier.awaitPersisted(...).andThen(run(...))). When a step completes on the subscribing thread, the next step starts on the same call stack, 23 frames deeper. The built-in models and session services complete on the subscribing thread (GeminithroughFlowable.fromFuture,Claudeand the non-streamingLangChain4jandSpringAIpaths throughFlowable.justafter a blocking call, the session services throughSingle.justorSingle.fromCallable), so with a 1 MB thread stack, the default on x86-64, an agent that keeps calling tools fails withStackOverflowErrorafter roughly 250–300 LLM calls, before the defaultmaxLlmCallsof 500 can end the run. On aarch64 the default thread stack is 2 MB; on a 2 MB stack the same agent made 635–663 calls in my runs, so there the default limit usually comes first. The error never reachesonError: it is thrown out ofsubscribe(), or, when the run is subscribed on a scheduler thread, it goes toRxJavaPluginsas anUndeliverableExceptionand the subscriber never receives a terminal signal (in my runs,blockingGet()returned only when a 15-secondtimeoutfired). The issue has the measurements.Solution:
Drive the steps with
repeatUntil, asLoopAgentloops over its sub-agents on its resumable path:run(InvocationContext)now runs one step per subscription of an innerFlowable.defer(...)and letsrepeatUntilsubscribe again until the flow ends. When a step completes synchronously,repeatUntilresubscribes from a loop instead of from the completion callback (FlowableRepeatUntil.RepeatSubscriber.subscribeNextin RxJava 3.1.12), so every step starts at the same stack depth. This is the Java counterpart of thewhile True:loop in adk-python'sBaseLlmFlow.run_async.runtorunStep, since it no longer runs the rest of the flow, and gets a short Javadoc. Its end-of-flow checks, the pause on a pending long-running call and themaxStepscut-off are unchanged. Where it used to subscribe to the next step, it now sets acontinueFlowflag and ends the step with thePersistBarrierwait.Flowable.defer.Events are still emitted as each step produces them, the next step still starts only after the
Runnerhas persisted the previous step's events, and an error still ends the run. One difference: subscribing twice to the sameFlowablereturned byrun(ctx)now runs the flow twice from the start, where the old code replayed the first step from itscache()and ran the later steps again. Agents do not do this:BaseAgentcallsrunAsyncImplagain for every subscription (throughFlowable.defer), andLlmAgent.runAsyncImplcallsruneach time.Testing Plan
Unit Tests:
core, see the table below)BaseLlmFlowTest.run_modelRespondingOnSubscribingThread_reachesMaxLlmCallsWithoutStackOverflowruns the flow with a model that returns a function call throughFlowable.justevery time andmaxLlmCallsset to 2,000, and expects the run to end withLlmCallsLimitExceededExceptionafter 2,000 requests. The model also records theStackWalkerdepth at every call, and the test expects the same depth for all 2,000 calls, so whether it fails onmaindoes not depend on the thread stack size: in my runs it fails withStackOverflowErroron the default 1 MB stack, and on 8 MB and 16 MB stacks, wheremainreaches 2,000 calls, the depth check fails because the depth grows by 23 frames per step. It runs the flow without aRunner, so the history does not grow, and it takes about a second.Windows 11 on x86-64, Microsoft OpenJDK 17.0.19, Maven 4.0.0-rc-3 via
mvnw:main(BaseLlmFlow.javafrommain, with the new test)./mvnw -pl core test -Dtest=BaseLlmFlowTestjava.lang.StackOverflowError./mvnw -pl core clean testdefault-testandbasiceach run 2,205 tests, 24 skipped, with no failures. Of the other four surefire executions, three pass andapigee-llm-proxy-urlruns no tests (#1557).Manual End-to-End (E2E) Tests:
Not tested against a live model. The reproduction in the issue runs an
LlmAgentwith anEchoToolin anInMemoryRunner, with aTestLlmthat asks for the tool on every call and answers on the subscribing thread:mainStackOverflowErrorafter 253–265 LLM calls (14 runs)LlmCallsLimitExceededExceptionafter 500-Xss256kStackOverflowErrorafter 42LlmCallsLimitExceededExceptionafter 500-Xss2m(the aarch64 default size)maxLlmCallsraised to 2,000,StackOverflowErrorafter 635–663 callsLlmCallsLimitExceededExceptionat the limit (500, or 2,000 when raised).subscribeOn(Schedulers.io())and.timeout(15, SECONDS)UndeliverableExceptiontoRxJavaPlugins, no signal until the timeout firesLlmCallsLimitExceededExceptionafter 500, in under 2 sRecording the
StackWalkerdepth at each model response shows 23 more frames per step onmain, and the same depth at every step with this change, also over 20,000 steps on a 256 KB stack.Checklist
core)Additional context
Rebased onto
mainat dbc973b without conflicts.mainalready importsAtomicBooleanand has thelegacyResumptioncheck, whichrunStepkeeps.