Skip to content

Commit d47e4c6

Browse files
committed
fix: preserve function-call IDs across partial and final SSE streaming events
When SSE streaming is active, _finalize_model_response_event is called once per LlmResponse chunk (partial=True for streaming chunks, partial=False for the final). Each call creates a fresh Event via model_validate, which means the function calls in the final event have empty IDs. When populate_client_function_call_id runs on the final event it generates a brand-new adk-{uuid}, different from the one assigned to the partial event. This breaks Human-in-the-Loop (HITL) workflows using LongRunningFunctionTool: 1. Partial event yields function call with ID-A → consumer captures ID-A 2. Final event yields same function call with ID-B → ADK persists ID-B 3. Consumer submits FunctionResponse with ID-A → ADK can't find it → error Fix: after populate_client_function_call_id assigns IDs to a partial event, write the content (including IDs) back to model_response_event. On the next call (final event), extract those IDs before the merge overwrites content, then restore them by position before populate_client_function_call_id runs. This ensures partial and final events for the same function call share the same adk-* ID. Fixes #4609
1 parent 8f54281 commit d47e4c6

File tree

2 files changed

+103
-1
lines changed

2 files changed

+103
-1
lines changed

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,26 @@ def _finalize_model_response_event(
9090
Args:
9191
llm_request: The original LLM request.
9292
llm_response: The LLM response from the model.
93-
model_response_event: The base event to populate.
93+
model_response_event: The base event to populate. When SSE streaming is
94+
active, this same object is passed for every partial *and* the final
95+
response. After the first partial event is finalized, the assigned
96+
``adk-*`` function-call IDs are written back into
97+
``model_response_event.content`` so that subsequent calls (including the
98+
final non-partial event) can reuse the same IDs instead of generating
99+
new ones.
94100
95101
Returns:
96102
The finalized Event with LLM response data merged in.
97103
"""
104+
# Collect any function-call IDs that were already assigned during a previous
105+
# partial-event finalization for this same logical LLM turn. We extract them
106+
# *before* the merge so they are not lost when llm_response overwrites content.
107+
prior_fc_ids: list[str | None] = []
108+
if model_response_event.content:
109+
prior_fc_ids = [
110+
fc.id for fc in (model_response_event.get_function_calls() or [])
111+
]
112+
98113
finalized_event = Event.model_validate({
99114
**model_response_event.model_dump(exclude_none=True),
100115
**llm_response.model_dump(exclude_none=True),
@@ -103,7 +118,19 @@ def _finalize_model_response_event(
103118
if finalized_event.content:
104119
function_calls = finalized_event.get_function_calls()
105120
if function_calls:
121+
# Restore previously-assigned IDs (by position) so that partial and
122+
# final SSE events for the same function call share the same ID.
123+
if prior_fc_ids:
124+
for idx, fc in enumerate(function_calls):
125+
if idx < len(prior_fc_ids) and prior_fc_ids[idx]:
126+
fc.id = prior_fc_ids[idx]
127+
106128
functions.populate_client_function_call_id(finalized_event)
129+
130+
# Persist the now-assigned IDs back into model_response_event so that
131+
# the next call (e.g. the final non-partial event) can reuse them.
132+
model_response_event.content = finalized_event.content
133+
107134
finalized_event.long_running_tool_ids = (
108135
functions.get_long_running_function_calls(
109136
function_calls, llm_request.tools_dict

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,3 +487,78 @@ async def call(self, **kwargs):
487487
assert result1.grounding_metadata == {'foo': 'bar'}
488488
assert result2.grounding_metadata == {'foo': 'bar'}
489489
assert result3.grounding_metadata == {'foo': 'bar'}
490+
491+
492+
# ---------------------------------------------------------------------------
493+
# Tests for _finalize_model_response_event function-call ID consistency
494+
# ---------------------------------------------------------------------------
495+
496+
from google.adk.flows.llm_flows.base_llm_flow import _finalize_model_response_event
497+
498+
499+
def _make_fc_response(fc_name: str, fc_id: str | None = None, partial: bool = False) -> LlmResponse:
500+
"""Helper: build an LlmResponse with a single function call."""
501+
return LlmResponse(
502+
content=types.Content(
503+
role='model',
504+
parts=[
505+
types.Part(
506+
function_call=types.FunctionCall(
507+
name=fc_name,
508+
args={'x': 1},
509+
id=fc_id,
510+
)
511+
)
512+
],
513+
),
514+
partial=partial,
515+
)
516+
517+
518+
def test_finalize_model_response_event_consistent_fc_id_across_partial_and_final():
519+
"""Function call IDs must be identical in partial and final SSE events.
520+
521+
Regression test for https://github.com/google/adk-python/issues/4609.
522+
When SSE streaming is active, _finalize_model_response_event is called
523+
once for the partial event and once for the final event, both sharing the
524+
same model_response_event object. The assigned adk-* ID must be the same
525+
in both calls.
526+
"""
527+
llm_request = LlmRequest()
528+
llm_request.tools_dict = {}
529+
base_event = Event(
530+
invocation_id='inv1',
531+
author='agent',
532+
)
533+
534+
# First call: partial streaming event (function call has no ID from LLM)
535+
partial_response = _make_fc_response('my_tool', fc_id=None, partial=True)
536+
partial_finalized = _finalize_model_response_event(
537+
llm_request, partial_response, base_event
538+
)
539+
partial_fc_id = partial_finalized.get_function_calls()[0].id
540+
assert partial_fc_id is not None
541+
assert partial_fc_id.startswith('adk-')
542+
543+
# Second call: final (non-partial) event for the same function call
544+
final_response = _make_fc_response('my_tool', fc_id=None, partial=False)
545+
final_finalized = _finalize_model_response_event(
546+
llm_request, final_response, base_event
547+
)
548+
final_fc_id = final_finalized.get_function_calls()[0].id
549+
550+
assert final_fc_id == partial_fc_id, (
551+
f'Function call ID changed between partial ({partial_fc_id!r}) and '
552+
f'final ({final_fc_id!r}) SSE events — HITL workflows will break.'
553+
)
554+
555+
556+
def test_finalize_model_response_event_preserves_llm_assigned_id():
557+
"""If the LLM already assigned an ID, it must be preserved as-is."""
558+
llm_request = LlmRequest()
559+
llm_request.tools_dict = {}
560+
base_event = Event(invocation_id='inv1', author='agent')
561+
562+
response = _make_fc_response('my_tool', fc_id='llm-assigned-id')
563+
finalized = _finalize_model_response_event(llm_request, response, base_event)
564+
assert finalized.get_function_calls()[0].id == 'llm-assigned-id'

0 commit comments

Comments
 (0)