|
28 | 28 |
|
29 | 29 | from __future__ import annotations |
30 | 30 |
|
| 31 | +from collections.abc import AsyncIterator |
31 | 32 | from typing import Any |
32 | 33 | from typing import AsyncGenerator |
33 | 34 |
|
34 | 35 | from google.adk.agents.context import Context |
35 | 36 | from google.adk.agents.llm_agent import LlmAgent |
| 37 | +from google.adk.agents.run_config import RunConfig |
| 38 | +from google.adk.agents.run_config import StreamingMode |
36 | 39 | from google.adk.apps.app import App |
37 | 40 | from google.adk.apps.app import ResumabilityConfig |
38 | 41 | from google.adk.events.event import Event |
39 | 42 | from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME |
| 43 | +from google.adk.models.base_llm import BaseLlm |
| 44 | +from google.adk.models.llm_response import LlmResponse |
40 | 45 | from google.adk.tools.function_tool import FunctionTool |
41 | 46 | from google.adk.tools.long_running_tool import LongRunningFunctionTool |
42 | 47 | from google.adk.tools.tool_context import ToolContext |
@@ -829,3 +834,145 @@ def my_long_run(value: str) -> None: |
829 | 834 | ] |
830 | 835 | assert len(model_events) == 1 |
831 | 836 | assert "fc-lro-001" in model_events[0].long_running_tool_ids |
| 837 | + |
| 838 | + |
| 839 | +# --------------------------------------------------------------------------- |
| 840 | +# Progressive SSE: do not dispatch task FCs from partial=True chunks (#6583) |
| 841 | +# --------------------------------------------------------------------------- |
| 842 | + |
| 843 | + |
| 844 | +class _ProgressiveSseDispatchCoordinatorLlm(BaseLlm): |
| 845 | + """Emits the progressive-SSE task-FC shape: partial chunk, then aggregate.""" |
| 846 | + |
| 847 | + model: str = "progressive_sse_dispatch_stub" |
| 848 | + calls: int = 0 |
| 849 | + specialist_name: str = "specialist" |
| 850 | + full_request: str = "Top landing pages for June 2026" |
| 851 | + |
| 852 | + @classmethod |
| 853 | + def supported_models(cls) -> list[str]: |
| 854 | + return ["progressive_sse_dispatch_stub"] |
| 855 | + |
| 856 | + async def generate_content_async( # type: ignore[override] |
| 857 | + self, llm_request: Any, stream: bool = False |
| 858 | + ) -> AsyncIterator[LlmResponse]: |
| 859 | + del llm_request, stream |
| 860 | + self.calls += 1 |
| 861 | + if self.calls == 1: |
| 862 | + # Intermediate progressive-SSE chunk: incomplete args, partial=True. |
| 863 | + yield LlmResponse( |
| 864 | + content=types.Content( |
| 865 | + role="model", |
| 866 | + parts=[ |
| 867 | + types.Part( |
| 868 | + function_call=types.FunctionCall( |
| 869 | + name=self.specialist_name, |
| 870 | + args={"request": ""}, |
| 871 | + id="fc-dispatch-1", |
| 872 | + ) |
| 873 | + ) |
| 874 | + ], |
| 875 | + ), |
| 876 | + partial=True, |
| 877 | + ) |
| 878 | + # Non-partial aggregate: complete args; Runner persists this event. |
| 879 | + yield LlmResponse( |
| 880 | + content=types.Content( |
| 881 | + role="model", |
| 882 | + parts=[ |
| 883 | + types.Part( |
| 884 | + function_call=types.FunctionCall( |
| 885 | + name=self.specialist_name, |
| 886 | + args={"request": self.full_request}, |
| 887 | + id="fc-dispatch-1", |
| 888 | + ) |
| 889 | + ) |
| 890 | + ], |
| 891 | + ), |
| 892 | + partial=False, |
| 893 | + turn_complete=True, |
| 894 | + ) |
| 895 | + return |
| 896 | + |
| 897 | + yield LlmResponse( |
| 898 | + content=types.Content( |
| 899 | + role="model", |
| 900 | + parts=[ |
| 901 | + types.Part.from_text(text="Here are your top landing pages.") |
| 902 | + ], |
| 903 | + ), |
| 904 | + partial=False, |
| 905 | + turn_complete=True, |
| 906 | + ) |
| 907 | + |
| 908 | + |
| 909 | +@pytest.mark.asyncio |
| 910 | +async def test_chat_root_dispatches_task_fc_only_from_non_partial_sse_chunk( |
| 911 | + request: pytest.FixtureRequest, |
| 912 | +): |
| 913 | + """Task dispatch must wait for the non-partial progressive-SSE aggregate. |
| 914 | +
|
| 915 | + Regression for #6583: extracting from ``partial=True`` closes the generator |
| 916 | + before the persisted FC event is yielded, leaving an orphaned task FR. |
| 917 | + """ |
| 918 | + child = _make_task_agent( |
| 919 | + name="specialist", |
| 920 | + responses=[_finish_part({"result": "1. /pricing 2. /blog 3. /home"})], |
| 921 | + ) |
| 922 | + coordinator_llm = _ProgressiveSseDispatchCoordinatorLlm() |
| 923 | + root = LlmAgent( |
| 924 | + name="coordinator", |
| 925 | + model=coordinator_llm, |
| 926 | + mode="chat", |
| 927 | + sub_agents=[child], |
| 928 | + ) |
| 929 | + |
| 930 | + app = App(name=request.function.__name__, root_agent=root) |
| 931 | + runner = testing_utils.InMemoryRunner(app=app) |
| 932 | + # Partial chunks are only yielded under SSE streaming; without this the |
| 933 | + # regression path never reaches the chat wrapper. |
| 934 | + run_config = RunConfig(streaming_mode=StreamingMode.SSE) |
| 935 | + |
| 936 | + events = [] |
| 937 | + async for event in runner.runner.run_async( |
| 938 | + user_id=runner.session.user_id, |
| 939 | + session_id=runner.session.id, |
| 940 | + new_message=testing_utils.get_user_content( |
| 941 | + "Top landing pages June 2026?" |
| 942 | + ), |
| 943 | + run_config=run_config, |
| 944 | + ): |
| 945 | + events.append(event) |
| 946 | + |
| 947 | + assert _collect_finish_outputs(events) == [ |
| 948 | + {"result": "1. /pricing 2. /blog 3. /home"} |
| 949 | + ] |
| 950 | + assert coordinator_llm.calls == 2 |
| 951 | + |
| 952 | + persisted = list(runner.session.events) |
| 953 | + task_fc_ids = [ |
| 954 | + fc.id |
| 955 | + for e in persisted |
| 956 | + for fc in e.get_function_calls() |
| 957 | + if fc.name == "specialist" |
| 958 | + ] |
| 959 | + task_fr_ids = [ |
| 960 | + fr.id |
| 961 | + for e in persisted |
| 962 | + for fr in e.get_function_responses() |
| 963 | + if fr.name == "specialist" |
| 964 | + ] |
| 965 | + assert task_fc_ids == ["fc-dispatch-1"] |
| 966 | + assert task_fr_ids == ["fc-dispatch-1"] |
| 967 | + |
| 968 | + task_fc_args = [ |
| 969 | + dict(fc.args or {}) |
| 970 | + for e in persisted |
| 971 | + for fc in e.get_function_calls() |
| 972 | + if fc.name == "specialist" |
| 973 | + ] |
| 974 | + assert task_fc_args == [{"request": "Top landing pages for June 2026"}] |
| 975 | + assert any( |
| 976 | + "Here are your top landing pages." in t |
| 977 | + for t in _get_text_responses(events) |
| 978 | + ) |
0 commit comments