|
| 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