Skip to content

Commit d217c29

Browse files
sangkyoonnamhemasekhar-p
authored andcommitted
fix(flows): propagate downstream cancellation to the model stream
1 parent 9f5d5ce commit d217c29

2 files changed

Lines changed: 318 additions & 1 deletion

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
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.subjects.CompletableSubject;
5859
import java.util.ArrayList;
5960
import java.util.List;
6061
import java.util.Optional;
@@ -527,7 +528,8 @@ public Flowable<Event> run(InvocationContext invocationContext) {
527528

528529
private Flowable<Event> run(
529530
Context spanContext, InvocationContext invocationContext, int stepsCompleted) {
530-
Flowable<Event> currentStepEvents = runOneStep(spanContext, invocationContext).cache();
531+
Flowable<Event> currentStepEvents =
532+
cancellableCache(runOneStep(spanContext, invocationContext));
531533
if (stepsCompleted + 1 >= maxSteps) {
532534
logger.debug("Ending flow execution because max steps reached.");
533535
return currentStepEvents;
@@ -561,6 +563,19 @@ private Flowable<Event> run(
561563
}));
562564
}
563565

566+
/**
567+
* Caches {@code step} like {@code cache()} does, so the live subscriber and the later {@code
568+
* toList()} subscriber in {@link #run} share one model call, but also cancels the step when a
569+
* subscriber cancels. {@code cache()} alone never cancels upstream, so a consumer that
570+
* unsubscribed mid-step (a client disconnect, for example) left the model stream and the step's
571+
* pending tool calls running. The cancel completes the cached stream, so a subscriber that
572+
* arrives afterwards still gets the buffered events.
573+
*/
574+
private static Flowable<Event> cancellableCache(Flowable<Event> step) {
575+
CompletableSubject cancelled = CompletableSubject.create();
576+
return step.takeUntil(cancelled.toFlowable()).cache().doOnCancel(cancelled::onComplete);
577+
}
578+
564579
/**
565580
* Executes the LLM flow in streaming mode.
566581
*
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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.flows.llmflows;
18+
19+
import static com.google.adk.testing.TestUtils.createInvocationContext;
20+
import static com.google.adk.testing.TestUtils.createLlmResponse;
21+
import static com.google.adk.testing.TestUtils.createTestAgentBuilder;
22+
import static com.google.adk.testing.TestUtils.createTestLlm;
23+
import static com.google.common.truth.Truth.assertThat;
24+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
25+
import static java.util.concurrent.TimeUnit.SECONDS;
26+
27+
import com.google.adk.agents.BaseAgent;
28+
import com.google.adk.agents.InvocationContext;
29+
import com.google.adk.agents.LlmAgent;
30+
import com.google.adk.agents.ParallelAgent;
31+
import com.google.adk.events.Event;
32+
import com.google.adk.events.EventActions;
33+
import com.google.adk.models.LlmResponse;
34+
import com.google.adk.runner.InMemoryRunner;
35+
import com.google.adk.sessions.Session;
36+
import com.google.adk.testing.TestLlm;
37+
import com.google.adk.tools.BaseTool;
38+
import com.google.adk.tools.ToolContext;
39+
import com.google.common.collect.ImmutableList;
40+
import com.google.common.collect.ImmutableMap;
41+
import com.google.genai.types.Content;
42+
import com.google.genai.types.FunctionDeclaration;
43+
import com.google.genai.types.Part;
44+
import io.reactivex.rxjava3.core.Flowable;
45+
import io.reactivex.rxjava3.core.Single;
46+
import io.reactivex.rxjava3.disposables.Disposable;
47+
import io.reactivex.rxjava3.processors.PublishProcessor;
48+
import io.reactivex.rxjava3.schedulers.TestScheduler;
49+
import io.reactivex.rxjava3.subscribers.DisposableSubscriber;
50+
import io.reactivex.rxjava3.subscribers.TestSubscriber;
51+
import java.util.List;
52+
import java.util.Map;
53+
import java.util.Optional;
54+
import java.util.concurrent.CountDownLatch;
55+
import org.junit.Test;
56+
import org.junit.runner.RunWith;
57+
import org.junit.runners.JUnit4;
58+
59+
/** Tests that cancelling a {@link BaseLlmFlow#run} subscription reaches the model stream. */
60+
@RunWith(JUnit4.class)
61+
public final class BaseLlmFlowCancelTest {
62+
63+
private static final Content FUNCTION_CALL =
64+
Content.fromParts(Part.fromFunctionCall("my_function", ImmutableMap.of("arg1", "value1")));
65+
private static final Content TEXT = Content.fromParts(Part.fromText("LLM response"));
66+
67+
@Test
68+
public void run_disposedWhileModelStreams_cancelsModelStream() throws Exception {
69+
NeverEndingModel model = new NeverEndingModel();
70+
InvocationContext invocationContext =
71+
createInvocationContext(createTestAgentBuilder(model.llm).build());
72+
73+
Disposable subscription =
74+
createBaseLlmFlow().run(invocationContext).subscribe(event -> {}, error -> {});
75+
assertThat(model.subscribed.await(5, SECONDS)).isTrue();
76+
subscription.dispose();
77+
78+
assertThat(model.cancelled.await(2, SECONDS)).isTrue();
79+
}
80+
81+
@Test
82+
public void runner_disposedWhileModelStreams_cancelsModelStream() throws Exception {
83+
NeverEndingModel model = new NeverEndingModel();
84+
LlmAgent agent = createTestAgentBuilder(model.llm).build();
85+
InMemoryRunner runner = new InMemoryRunner(agent);
86+
Session session = runner.sessionService().createSession(runner.appName(), "user").blockingGet();
87+
88+
Disposable subscription =
89+
runner
90+
.runAsync("user", session.id(), Content.fromParts(Part.fromText("hi")))
91+
.subscribe(event -> {}, error -> {});
92+
assertThat(model.subscribed.await(5, SECONDS)).isTrue();
93+
subscription.dispose();
94+
95+
assertThat(model.cancelled.await(2, SECONDS)).isTrue();
96+
}
97+
98+
@Test
99+
public void run_disposedBeforeFunctionCallArrives_doesNotRunTool() throws Exception {
100+
PublishProcessor<LlmResponse> responses = PublishProcessor.create();
101+
CountDownLatch cancelled = new CountDownLatch(1);
102+
TestLlm testLlm = createTestLlm(() -> responses.doOnCancel(cancelled::countDown));
103+
RecordingTool tool = new RecordingTool();
104+
InvocationContext invocationContext =
105+
createInvocationContext(
106+
createTestAgentBuilder(testLlm).tools(ImmutableList.of(tool)).build());
107+
108+
Disposable subscription =
109+
createBaseLlmFlow().run(invocationContext).subscribe(event -> {}, error -> {});
110+
assertThat(responses.hasSubscribers()).isTrue();
111+
subscription.dispose();
112+
assertThat(cancelled.await(2, SECONDS)).isTrue();
113+
responses.onNext(createLlmResponse(FUNCTION_CALL));
114+
responses.onComplete();
115+
116+
assertThat(tool.ran.await(200, MILLISECONDS)).isFalse();
117+
assertThat(testLlm.getRequests()).hasSize(1);
118+
}
119+
120+
@Test
121+
public void run_consumedToCompletion_runsToolAndEmitsEveryEvent() throws Exception {
122+
TestLlm testLlm =
123+
createTestLlm(
124+
Flowable.just(createLlmResponse(FUNCTION_CALL)),
125+
Flowable.just(createLlmResponse(TEXT)));
126+
RecordingTool tool = new RecordingTool();
127+
InvocationContext invocationContext =
128+
createInvocationContext(
129+
createTestAgentBuilder(testLlm).tools(ImmutableList.of(tool)).build());
130+
131+
List<Event> events = createBaseLlmFlow().run(invocationContext).toList().blockingGet();
132+
133+
assertThat(tool.ran.getCount()).isEqualTo(0);
134+
assertThat(events).hasSize(3);
135+
assertThat(events.get(2).content()).hasValue(TEXT);
136+
assertThat(testLlm.getRequests()).hasSize(2);
137+
}
138+
139+
@Test
140+
public void run_boundedDemand_deliversFinalEventAndCompletion() {
141+
TestLlm testLlm = createTestLlm(createLlmResponse(TEXT));
142+
InvocationContext invocationContext =
143+
createInvocationContext(createTestAgentBuilder(testLlm).build());
144+
145+
TestSubscriber<Event> subscriber = createBaseLlmFlow().run(invocationContext).test(1);
146+
147+
subscriber.assertValueCount(1).assertComplete();
148+
assertThat(testLlm.getRequests()).hasSize(1);
149+
}
150+
151+
@Test
152+
public void run_boundedDemand_deliversModelErrorAfterBufferedEvent() {
153+
TestLlm testLlm =
154+
createTestLlm(
155+
() ->
156+
Flowable.concat(
157+
Flowable.just(createLlmResponse(TEXT)),
158+
Flowable.error(new IllegalStateException("model failed"))));
159+
InvocationContext invocationContext =
160+
createInvocationContext(createTestAgentBuilder(testLlm).build());
161+
162+
TestSubscriber<Event> subscriber = createBaseLlmFlow().run(invocationContext).test(1);
163+
164+
subscriber.assertValueCount(1).assertError(IllegalStateException.class);
165+
}
166+
167+
@Test
168+
public void run_cancelledInOnSubscribe_doesNotLeaveModelStreaming() throws Exception {
169+
NeverEndingModel model = new NeverEndingModel();
170+
InvocationContext invocationContext =
171+
createInvocationContext(createTestAgentBuilder(model.llm).build());
172+
// A single step subscribes to the cached step directly, with no concatWith in front of it.
173+
BaseLlmFlow singleStepFlow =
174+
new BaseLlmFlow(ImmutableList.of(), ImmutableList.of(), Optional.of(1)) {};
175+
176+
singleStepFlow
177+
.run(invocationContext)
178+
.subscribe(
179+
new DisposableSubscriber<Event>() {
180+
@Override
181+
protected void onStart() {
182+
cancel();
183+
}
184+
185+
@Override
186+
public void onNext(Event event) {}
187+
188+
@Override
189+
public void onError(Throwable error) {}
190+
191+
@Override
192+
public void onComplete() {}
193+
});
194+
195+
boolean neverSubscribed = model.subscribed.getCount() == 1;
196+
assertThat(neverSubscribed || model.cancelled.await(2, SECONDS)).isTrue();
197+
}
198+
199+
@Test
200+
public void run_subscribedAgainAfterCancel_replaysBufferedEventsAndCompletes() throws Exception {
201+
PublishProcessor<LlmResponse> responses = PublishProcessor.create();
202+
CountDownLatch cancelled = new CountDownLatch(1);
203+
TestLlm testLlm = createTestLlm(() -> responses.doOnCancel(cancelled::countDown));
204+
InvocationContext invocationContext =
205+
createInvocationContext(createTestAgentBuilder(testLlm).build());
206+
Flowable<Event> events = createBaseLlmFlow().run(invocationContext);
207+
208+
TestSubscriber<Event> first = events.test();
209+
responses.onNext(createLlmResponse(TEXT));
210+
first.assertValueCount(1).assertNotComplete();
211+
first.cancel();
212+
assertThat(cancelled.await(2, SECONDS)).isTrue();
213+
214+
TestSubscriber<Event> second = events.test();
215+
216+
second.assertValueCount(1).assertComplete();
217+
assertThat(second.values().get(0).content()).hasValue(TEXT);
218+
assertThat(testLlm.getRequests()).hasSize(1);
219+
}
220+
221+
@Test
222+
public void parallelAgent_subAgentEscalates_cancelsSiblingModelStream() throws Exception {
223+
NeverEndingModel model = new NeverEndingModel();
224+
LlmAgent streamingAgent = createTestAgentBuilder(model.llm).name("streaming_agent").build();
225+
TestScheduler scheduler = new TestScheduler();
226+
ParallelAgent parallelAgent =
227+
ParallelAgent.builder()
228+
.name("parallel_agent")
229+
.subAgents(streamingAgent, new EscalatingAgent())
230+
.scheduler(scheduler)
231+
.build();
232+
InvocationContext invocationContext = createInvocationContext(parallelAgent);
233+
234+
TestSubscriber<Event> subscriber = parallelAgent.runAsync(invocationContext).test();
235+
// Subscribes the branches in order: the model starts streaming, then the sibling escalates.
236+
scheduler.triggerActions();
237+
238+
assertThat(model.subscribed.getCount()).isEqualTo(0);
239+
subscriber.assertValueCount(1).assertComplete();
240+
assertThat(subscriber.values().get(0).actions().escalate()).hasValue(true);
241+
assertThat(model.cancelled.await(2, SECONDS)).isTrue();
242+
}
243+
244+
private static BaseLlmFlow createBaseLlmFlow() {
245+
return new BaseLlmFlow(ImmutableList.of(), ImmutableList.of(), Optional.empty()) {};
246+
}
247+
248+
/** A model whose stream never completes and reports when it is subscribed to and cancelled. */
249+
private static final class NeverEndingModel {
250+
final CountDownLatch subscribed = new CountDownLatch(1);
251+
final CountDownLatch cancelled = new CountDownLatch(1);
252+
final TestLlm llm =
253+
createTestLlm(
254+
() ->
255+
Flowable.<LlmResponse>never()
256+
.doOnSubscribe(subscription -> subscribed.countDown())
257+
.doOnCancel(cancelled::countDown));
258+
}
259+
260+
/** A sub-agent that escalates with its first event. */
261+
private static final class EscalatingAgent extends BaseAgent {
262+
EscalatingAgent() {
263+
super("escalating_agent", "escalates immediately", ImmutableList.of(), null, null);
264+
}
265+
266+
@Override
267+
protected Flowable<Event> runAsyncImpl(InvocationContext invocationContext) {
268+
return Flowable.just(
269+
Event.builder()
270+
.author(name())
271+
.invocationId(invocationContext.invocationId())
272+
.branch(invocationContext.branch().orElse(null))
273+
.content(Content.fromParts(Part.fromText("escalating")))
274+
.actions(EventActions.builder().escalate(true).build())
275+
.build());
276+
}
277+
278+
@Override
279+
protected Flowable<Event> runLiveImpl(InvocationContext invocationContext) {
280+
throw new UnsupportedOperationException("Not implemented");
281+
}
282+
}
283+
284+
private static final class RecordingTool extends BaseTool {
285+
final CountDownLatch ran = new CountDownLatch(1);
286+
287+
RecordingTool() {
288+
super("my_function", "tool description for my_function");
289+
}
290+
291+
@Override
292+
public Optional<FunctionDeclaration> declaration() {
293+
return Optional.of(FunctionDeclaration.builder().name(name()).build());
294+
}
295+
296+
@Override
297+
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
298+
ran.countDown();
299+
return Single.just(ImmutableMap.of("response", "response for my_function"));
300+
}
301+
}
302+
}

0 commit comments

Comments
 (0)