Skip to content

Commit 0ee0d4b

Browse files
Merge pull request #1565 from innoprej:fix/llm-flow-step-loop
PiperOrigin-RevId: 1000117200
2 parents bad4f4c + 31ac2df commit 0ee0d4b

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

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

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import java.util.Optional;
6161
import java.util.Set;
6262
import java.util.concurrent.atomic.AtomicBoolean;
63+
import java.util.concurrent.atomic.AtomicInteger;
6364
import java.util.concurrent.atomic.AtomicReference;
6465
import java.util.function.BiFunction;
6566
import org.slf4j.Logger;
@@ -515,11 +516,32 @@ private Flowable<Event> runOneStep(Context spanContext, InvocationContext contex
515516
*/
516517
@Override
517518
public Flowable<Event> run(InvocationContext invocationContext) {
518-
return run(Context.current(), invocationContext, 0);
519+
Context spanContext = Context.current();
520+
return Flowable.defer(
521+
() -> {
522+
AtomicInteger stepsCompleted = new AtomicInteger(0);
523+
AtomicBoolean continueFlow = new AtomicBoolean(false);
524+
// Resubscribe without growing the stack when steps complete synchronously.
525+
return Flowable.defer(
526+
() ->
527+
runStepAndCheckContinue(
528+
spanContext,
529+
invocationContext,
530+
stepsCompleted.getAndIncrement(),
531+
continueFlow))
532+
.repeatUntil(() -> !continueFlow.getAndSet(false));
533+
});
519534
}
520535

521-
private Flowable<Event> run(
522-
Context spanContext, InvocationContext invocationContext, int stepsCompleted) {
536+
/**
537+
* Runs {@link #runOneStep} once. If the flow should run another step, sets {@code continueFlow}
538+
* and waits until the Runner has persisted this step's events.
539+
*/
540+
private Flowable<Event> runStepAndCheckContinue(
541+
Context spanContext,
542+
InvocationContext invocationContext,
543+
int stepsCompleted,
544+
AtomicBoolean continueFlow) {
523545
Flowable<Event> currentStepEvents = runOneStep(spanContext, invocationContext).cache();
524546
if (stepsCompleted + 1 >= maxSteps) {
525547
logger.debug("Ending flow execution because max steps reached.");
@@ -546,10 +568,10 @@ private Flowable<Event> run(
546568
return Flowable.empty();
547569
} else {
548570
logger.debug("Continuing to next step of the flow.");
571+
continueFlow.set(true);
549572
// Wait until the Runner has persisted this step's events so the next step's
550573
// request is not built from a stale session (see PersistBarrier).
551-
return PersistBarrier.awaitPersisted(invocationContext, eventList)
552-
.andThen(run(spanContext, invocationContext, stepsCompleted + 1));
574+
return PersistBarrier.awaitPersisted(invocationContext, eventList).toFlowable();
553575
}
554576
}));
555577
}

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,18 @@
3636
import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult;
3737
import com.google.adk.flows.llmflows.ResponseProcessor.ResponseProcessingResult;
3838
import com.google.adk.models.CacheMetadata;
39+
import com.google.adk.models.LlmCallsLimitExceededException;
3940
import com.google.adk.models.LlmRequest;
4041
import com.google.adk.models.LlmResponse;
42+
import com.google.adk.runner.InMemoryRunner;
43+
import com.google.adk.sessions.Session;
4144
import com.google.adk.testing.TestLlm;
4245
import com.google.adk.tools.BaseTool;
4346
import com.google.adk.tools.BaseToolset;
4447
import com.google.adk.tools.ToolContext;
4548
import com.google.common.collect.ImmutableList;
4649
import com.google.common.collect.ImmutableMap;
50+
import com.google.common.collect.ImmutableSet;
4751
import com.google.genai.types.Content;
4852
import com.google.genai.types.FinishReason;
4953
import com.google.genai.types.FunctionCall;
@@ -60,12 +64,15 @@
6064
import io.reactivex.rxjava3.core.Maybe;
6165
import io.reactivex.rxjava3.core.Single;
6266
import io.reactivex.rxjava3.schedulers.Schedulers;
67+
import io.reactivex.rxjava3.subscribers.TestSubscriber;
6368
import java.time.Instant;
69+
import java.util.ArrayList;
6470
import java.util.List;
6571
import java.util.Map;
6672
import java.util.Optional;
6773
import java.util.concurrent.atomic.AtomicInteger;
6874
import java.util.function.Function;
75+
import java.util.stream.Stream;
6976
import org.junit.Test;
7077
import org.junit.runner.RunWith;
7178
import org.junit.runners.JUnit4;
@@ -218,6 +225,47 @@ public void run_withFunctionCallsAndMaxSteps_stopsAfterMaxSteps() {
218225
Content.fromParts(Part.fromFunctionResponse("my_function", testResponse)));
219226
}
220227

228+
@Test
229+
public void run_modelRespondingOnSubscribingThread_reachesMaxLlmCallsWithoutStackOverflow() {
230+
int maxLlmCalls = 2_000;
231+
List<Long> stackDepths = new ArrayList<>();
232+
TestLlm testLlm = createTestLlmAlwaysCallingMyFunction(stackDepths);
233+
InvocationContext invocationContext =
234+
createInvocationContext(
235+
createTestAgentWithMyFunction(testLlm),
236+
RunConfig.builder().maxLlmCalls(maxLlmCalls).build());
237+
BaseLlmFlow baseLlmFlow = createBaseLlmFlowWithoutProcessors();
238+
239+
TestSubscriber<Event> subscriber = baseLlmFlow.run(invocationContext).test();
240+
241+
subscriber.assertError(LlmCallsLimitExceededException.class);
242+
assertThat(testLlm.getRequests()).hasSize(maxLlmCalls);
243+
assertThat(ImmutableSet.copyOf(stackDepths)).hasSize(1);
244+
}
245+
246+
@Test
247+
public void run_throughInMemoryRunner_reachesMaxLlmCallsWithoutStackOverflow() {
248+
// Unlike the test above, each step also waits for the Runner to persist its events.
249+
int maxLlmCalls = 500;
250+
List<Long> stackDepths = new ArrayList<>();
251+
TestLlm testLlm = createTestLlmAlwaysCallingMyFunction(stackDepths);
252+
InMemoryRunner runner = new InMemoryRunner(createTestAgentWithMyFunction(testLlm));
253+
Session session = runner.sessionService().createSession(runner.appName(), "user").blockingGet();
254+
255+
TestSubscriber<Event> subscriber =
256+
runner
257+
.runAsync(
258+
"user",
259+
session.id(),
260+
Content.builder().role("user").parts(Part.fromText("call my_function")).build(),
261+
RunConfig.builder().maxLlmCalls(maxLlmCalls).build())
262+
.test();
263+
264+
subscriber.assertError(LlmCallsLimitExceededException.class);
265+
assertThat(testLlm.getRequests()).hasSize(maxLlmCalls);
266+
assertThat(ImmutableSet.copyOf(stackDepths)).hasSize(1);
267+
}
268+
221269
@Test
222270
public void run_withLongRunningFunctionCall_returnsCorrectEventsWithLongRunningToolIds() {
223271
Content firstContent =
@@ -671,6 +719,26 @@ private static ResponseProcessor createResponseProcessor(
671719
ResponseProcessingResult.create(responseUpdater.apply(response), ImmutableList.of()));
672720
}
673721

722+
/** Returns a model that always calls {@code my_function} and records its stack depth per call. */
723+
private static TestLlm createTestLlmAlwaysCallingMyFunction(List<Long> stackDepths) {
724+
Content contentWithFunctionCall =
725+
Content.fromParts(Part.fromFunctionCall("my_function", ImmutableMap.of("arg1", "value1")));
726+
return createTestLlm(
727+
() -> {
728+
stackDepths.add(StackWalker.getInstance().walk(Stream::count));
729+
// Flowable.just answers on the subscribing thread, like the built-in models.
730+
return Flowable.just(createLlmResponse(contentWithFunctionCall));
731+
});
732+
}
733+
734+
private static LlmAgent createTestAgentWithMyFunction(TestLlm testLlm) {
735+
ImmutableMap<String, Object> testResponse =
736+
ImmutableMap.<String, Object>of("response", "response for my_function");
737+
return createTestAgentBuilder(testLlm)
738+
.tools(ImmutableList.of(new TestTool("my_function", testResponse)))
739+
.build();
740+
}
741+
674742
private static class TestTool extends BaseTool {
675743
private final Map<String, Object> response;
676744

0 commit comments

Comments
 (0)