|
68 | 68 | import com.google.adk.testing.TestUtils; |
69 | 69 | import com.google.adk.testing.TestUtils.EchoTool; |
70 | 70 | import com.google.adk.testing.TestUtils.FailingEchoTool; |
| 71 | +import com.google.adk.tools.BaseTool; |
71 | 72 | import com.google.adk.tools.FunctionTool; |
| 73 | +import com.google.adk.tools.ToolContext; |
72 | 74 | import com.google.common.collect.ImmutableList; |
73 | 75 | import com.google.common.collect.ImmutableMap; |
74 | 76 | import com.google.common.collect.Iterables; |
75 | 77 | import com.google.genai.types.Content; |
76 | 78 | import com.google.genai.types.FunctionCall; |
| 79 | +import com.google.genai.types.FunctionDeclaration; |
77 | 80 | import com.google.genai.types.FunctionResponse; |
78 | 81 | import com.google.genai.types.Part; |
79 | 82 | import io.opentelemetry.api.trace.Tracer; |
|
92 | 95 | import java.util.ArrayList; |
93 | 96 | import java.util.Collections; |
94 | 97 | import java.util.List; |
| 98 | +import java.util.Map; |
95 | 99 | import java.util.Objects; |
96 | 100 | import java.util.Optional; |
97 | 101 | import java.util.UUID; |
@@ -572,6 +576,136 @@ public void onToolErrorCallback_success() { |
572 | 576 | verify(plugin).onToolErrorCallback(any(), any(), any(), any()); |
573 | 577 | } |
574 | 578 |
|
| 579 | + /** |
| 580 | + * Reproduces the real Vertex streaming (SSE) behavior for parallel function calls: the model |
| 581 | + * emits one partial event per tool as each call streams in, then a single final aggregated event |
| 582 | + * that carries all of the calls (reusing the same function-call IDs). Each tool must execute |
| 583 | + * exactly once -- the partial events are surfaced to consumers but skipped for execution (see the |
| 584 | + * {@code partial()} guard in {@code BaseLlmFlow}). |
| 585 | + */ |
| 586 | + @Test |
| 587 | + public void runAsync_streamingPartialParallelFunctionCalls_executesEachToolExactlyOnce() { |
| 588 | + Part temperaturePart = |
| 589 | + Part.builder() |
| 590 | + .functionCall( |
| 591 | + FunctionCall.builder() |
| 592 | + .id("adk-temperature-id") |
| 593 | + .name("getTemperature") |
| 594 | + .args(ImmutableMap.of("city", "London")) |
| 595 | + .build()) |
| 596 | + .build(); |
| 597 | + Part conditionPart = |
| 598 | + Part.builder() |
| 599 | + .functionCall( |
| 600 | + FunctionCall.builder() |
| 601 | + .id("adk-condition-id") |
| 602 | + .name("getCondition") |
| 603 | + .args(ImmutableMap.of("city", "London")) |
| 604 | + .build()) |
| 605 | + .build(); |
| 606 | + |
| 607 | + // Turn 1 mirrors the real Vertex stream: a partial event for getTemperature, a partial event |
| 608 | + // for getCondition, then one aggregated (non-partial) event carrying both calls. Turn 2 is the |
| 609 | + // final text produced after both tools run. |
| 610 | + LlmResponse partialTemperature = |
| 611 | + LlmResponse.builder() |
| 612 | + .content(Content.builder().role("model").parts(temperaturePart).build()) |
| 613 | + .partial(true) |
| 614 | + .build(); |
| 615 | + LlmResponse partialCondition = |
| 616 | + LlmResponse.builder() |
| 617 | + .content(Content.builder().role("model").parts(conditionPart).build()) |
| 618 | + .partial(true) |
| 619 | + .build(); |
| 620 | + LlmResponse aggregated = |
| 621 | + LlmResponse.builder() |
| 622 | + .content(Content.builder().role("model").parts(temperaturePart, conditionPart).build()) |
| 623 | + .partial(false) |
| 624 | + .build(); |
| 625 | + TestLlm streamingTestLlm = |
| 626 | + createTestLlm( |
| 627 | + Flowable.just(partialTemperature, partialCondition, aggregated), |
| 628 | + Flowable.just(createTextLlmResponse("done"))); |
| 629 | + |
| 630 | + CountingTool temperatureTool = new CountingTool("getTemperature"); |
| 631 | + CountingTool conditionTool = new CountingTool("getCondition"); |
| 632 | + LlmAgent agent = |
| 633 | + createTestAgentBuilder(streamingTestLlm) |
| 634 | + .tools(ImmutableList.of(temperatureTool, conditionTool)) |
| 635 | + .build(); |
| 636 | + Runner runner = |
| 637 | + Runner.builder().app(App.builder().name("test").rootAgent(agent).build()).build(); |
| 638 | + Session session = runner.sessionService().createSession("test", "user").blockingGet(); |
| 639 | + |
| 640 | + List<Event> events = |
| 641 | + runner |
| 642 | + .runAsync( |
| 643 | + "user", |
| 644 | + session.id(), |
| 645 | + createContent("weather in London?"), |
| 646 | + RunConfig.builder().setStreamingMode(RunConfig.StreamingMode.SSE).build()) |
| 647 | + .toList() |
| 648 | + .blockingGet(); |
| 649 | + |
| 650 | + long partialFunctionCallEvents = |
| 651 | + events.stream() |
| 652 | + .filter(e -> !e.functionCalls().isEmpty() && e.partial().orElse(false)) |
| 653 | + .count(); |
| 654 | + long partialFunctionCalls = |
| 655 | + events.stream() |
| 656 | + .filter(e -> e.partial().orElse(false)) |
| 657 | + .mapToLong(e -> e.functionCalls().size()) |
| 658 | + .sum(); |
| 659 | + long aggregatedFunctionCallEvents = |
| 660 | + events.stream() |
| 661 | + .filter(e -> !e.functionCalls().isEmpty() && !e.partial().orElse(false)) |
| 662 | + .count(); |
| 663 | + Event aggregatedEvent = |
| 664 | + events.stream() |
| 665 | + .filter(e -> !e.functionCalls().isEmpty() && !e.partial().orElse(false)) |
| 666 | + .findFirst() |
| 667 | + .orElseThrow(); |
| 668 | + List<String> aggregatedCallIds = new ArrayList<>(); |
| 669 | + for (FunctionCall fc : aggregatedEvent.functionCalls()) { |
| 670 | + aggregatedCallIds.add(fc.id().orElseThrow()); |
| 671 | + } |
| 672 | + long functionResponses = events.stream().mapToLong(e -> e.functionResponses().size()).sum(); |
| 673 | + |
| 674 | + // Two partial function-call events (one per tool) are surfaced to consumers ... |
| 675 | + assertThat(partialFunctionCallEvents).isEqualTo(2); |
| 676 | + assertThat(partialFunctionCalls).isEqualTo(2); |
| 677 | + // ... followed by exactly one final aggregated event that carries BOTH calls ... |
| 678 | + assertThat(aggregatedFunctionCallEvents).isEqualTo(1); |
| 679 | + assertThat(aggregatedEvent.functionCalls()).hasSize(2); |
| 680 | + // ... whose function-call IDs match the ones streamed in the partial events ... |
| 681 | + assertThat(aggregatedCallIds).containsExactly("adk-temperature-id", "adk-condition-id"); |
| 682 | + // ... but each tool is executed exactly once (partial events are skipped) ... |
| 683 | + assertThat(temperatureTool.callCount.get()).isEqualTo(1); |
| 684 | + assertThat(conditionTool.callCount.get()).isEqualTo(1); |
| 685 | + // ... producing exactly two function responses (one per call). |
| 686 | + assertThat(functionResponses).isEqualTo(2); |
| 687 | + } |
| 688 | + |
| 689 | + /** A tool that records how many times it is actually executed. */ |
| 690 | + private static final class CountingTool extends BaseTool { |
| 691 | + final AtomicInteger callCount = new AtomicInteger(0); |
| 692 | + |
| 693 | + CountingTool(String name) { |
| 694 | + super(name, "counts invocations"); |
| 695 | + } |
| 696 | + |
| 697 | + @Override |
| 698 | + public Optional<FunctionDeclaration> declaration() { |
| 699 | + return Optional.of(FunctionDeclaration.builder().name(name()).build()); |
| 700 | + } |
| 701 | + |
| 702 | + @Override |
| 703 | + public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) { |
| 704 | + callCount.incrementAndGet(); |
| 705 | + return Single.just(ImmutableMap.of("forecast", "sunny")); |
| 706 | + } |
| 707 | + } |
| 708 | + |
575 | 709 | @Test |
576 | 710 | public void onToolErrorCallback_error() { |
577 | 711 | when(plugin.onToolErrorCallback(any(), any(), any(), any())).thenReturn(Maybe.empty()); |
|
0 commit comments