Skip to content

Commit 44c5913

Browse files
committed
fix(workflow): Skip task dispatch on progressive-SSE partial chunks
Under PROGRESSIVE_SSE_STREAMING the chat wrapper was extracting task- delegation FCs from partial=True chunks, breaking before the Runner- persisted aggregate was yielded. That left an orphaned task FR and could dispatch with truncated streamed args. Gate extraction like process_llm_agent_output already does for partial model events. Fixes #6583
1 parent 93dff41 commit 44c5913

3 files changed

Lines changed: 191 additions & 0 deletions

File tree

src/google/adk/workflow/_llm_agent_wrapper.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,19 @@ def _extract_task_delegation_fcs(
6666
"""Return task-delegation FCs from this event.
6767
6868
A task-delegation FC is one whose tool is a ``_TaskAgentTool`` instance.
69+
70+
Partial progressive-SSE chunks are ignored. Under
71+
``PROGRESSIVE_SSE_STREAMING``, intermediate chunks that carry a task FC are
72+
marked ``partial=True``; the Runner only persists non-partial events. If we
73+
dispatch and ``break`` on a partial chunk, the non-partial aggregate that
74+
carries the FC is never yielded, so the session keeps a synthesized task FR
75+
with no matching FC. Dispatch must wait for the final aggregate, which also
76+
carries complete streamed arguments.
6977
"""
78+
# Mirror process_llm_agent_output: never act on partial model chunks.
79+
if event.partial:
80+
return []
81+
7082
from ..tools.agent_tool import _TaskAgentTool
7183

7284
return [

tests/unittests/workflow/test_llm_agent_as_node.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,38 @@ async def _gen():
15451545
assert drained[1].get_function_responses()[0].name == 'echo'
15461546

15471547

1548+
def test_extract_task_delegation_fcs_skips_partial_events():
1549+
"""Progressive-SSE partial chunks must not trigger task dispatch (#6583)."""
1550+
1551+
def _fc(name: str, call_id: str) -> types.Part:
1552+
return types.Part(
1553+
function_call=types.FunctionCall(
1554+
name=name, args={'request': 'x'}, id=call_id
1555+
)
1556+
)
1557+
1558+
task_agent = LlmAgent(name='specialist', mode='task', model='unused')
1559+
tools_dict = {'specialist': _TaskAgentTool(task_agent)}
1560+
partial = Event(
1561+
author='coordinator',
1562+
content=types.Content(role='model', parts=[_fc('specialist', '1')]),
1563+
partial=True,
1564+
)
1565+
final = Event(
1566+
author='coordinator',
1567+
content=types.Content(role='model', parts=[_fc('specialist', '1')]),
1568+
partial=False,
1569+
)
1570+
1571+
assert not agent_wrapper._extract_task_delegation_fcs( # pylint: disable=protected-access
1572+
partial, tools_dict
1573+
)
1574+
extracted = agent_wrapper._extract_task_delegation_fcs( # pylint: disable=protected-access
1575+
final, tools_dict
1576+
)
1577+
assert [fc.id for fc in extracted] == ['1']
1578+
1579+
15481580
# --- process_llm_agent_output ---
15491581

15501582

tests/unittests/workflow/test_task_api_e2e.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@
2828

2929
from __future__ import annotations
3030

31+
from collections.abc import AsyncIterator
3132
from typing import Any
3233
from typing import AsyncGenerator
3334

3435
from google.adk.agents.context import Context
3536
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
3639
from google.adk.apps.app import App
3740
from google.adk.apps.app import ResumabilityConfig
3841
from google.adk.events.event import Event
3942
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
4045
from google.adk.tools.function_tool import FunctionTool
4146
from google.adk.tools.long_running_tool import LongRunningFunctionTool
4247
from google.adk.tools.tool_context import ToolContext
@@ -829,3 +834,145 @@ def my_long_run(value: str) -> None:
829834
]
830835
assert len(model_events) == 1
831836
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

Comments
 (0)