Skip to content

fix: preserve function-call IDs across partial and final SSE streaming events#4619

Open
stakeswky wants to merge 1 commit intogoogle:mainfrom
stakeswky:fix/sse-function-call-id-consistency
Open

fix: preserve function-call IDs across partial and final SSE streaming events#4619
stakeswky wants to merge 1 commit intogoogle:mainfrom
stakeswky:fix/sse-function-call-id-consistency

Conversation

@stakeswky
Copy link

Summary

Fixes #4609

When SSE streaming is active (the default since ADK 1.22+), _finalize_model_response_event is called once per LlmResponse chunk — once with partial=True for each streaming chunk, and once with partial=False for the final response. 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.

Root Cause

# _finalize_model_response_event (before fix)
finalized_event = Event.model_validate({
    **model_response_event.model_dump(exclude_none=True),
    **llm_response.model_dump(exclude_none=True),  # overwrites content, losing IDs
})
functions.populate_client_function_call_id(finalized_event)  # assigns NEW uuid!

This breaks HITL workflows:

  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 in session
  3. Consumer submits FunctionResponse with ID-A → ADK can't find it → hard 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.

# Extract prior IDs before merge overwrites content
prior_fc_ids = [fc.id for fc in (model_response_event.get_function_calls() or [])]

finalized_event = Event.model_validate({...})  # merge

# Restore previously-assigned IDs by position
if prior_fc_ids:
    for idx, fc in enumerate(function_calls):
        if idx < len(prior_fc_ids) and prior_fc_ids[idx]:
            fc.id = prior_fc_ids[idx]

populate_client_function_call_id(finalized_event)  # only assigns for still-empty IDs

# Persist IDs back for next call
model_response_event.content = finalized_event.content

Testing

Added two unit tests:

  • test_finalize_model_response_event_consistent_fc_id_across_partial_and_final: verifies partial and final events share the same ID
  • test_finalize_model_response_event_preserves_llm_assigned_id: verifies LLM-assigned IDs are not overwritten

All 350 existing tests/unittests/flows/llm_flows/ tests pass.

@gemini-code-assist
Copy link
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@google-cla
Copy link

google-cla bot commented Feb 25, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@adk-bot adk-bot added the core [Component] This issue is related to the core interface and implementation label Feb 25, 2026
@adk-bot
Copy link
Collaborator

adk-bot commented Feb 25, 2026

Hello @stakeswky, thank you for your contribution!

Before we can merge this PR, we need you to sign the Contributor License Agreement (CLA). You can find more information and sign the CLA at https://cla.developers.google.com/.

Response from ADK Triaging Agent

@stakeswky stakeswky force-pushed the fix/sse-function-call-id-consistency branch 2 times, most recently from a063bd1 to d47e4c6 Compare February 25, 2026 04:16
@ryanaiagent ryanaiagent self-assigned this Feb 26, 2026
@ryanaiagent
Copy link
Collaborator

Hi @stakeswky , Thank you for your contribution! We appreciate you taking the time to submit this pull request. Please fix formatting errors by running autoformat.sh

@ryanaiagent ryanaiagent added the request clarification [Status] The maintainer need clarification or more information from the author label Feb 27, 2026
@stakeswky
Copy link
Author

Thanks! I ran and pushed the formatting fix in commit , and re-ran the targeted unit tests ().\n\nI still need to complete CLA on my side before merge can proceed.

@stakeswky
Copy link
Author

Correction to my previous comment (shell escaped text got mangled): I ran ./autoformat.sh via uv, pushed formatting updates in commit fe469b4, and re-ran the targeted unit test file (20 passed). CLA is still pending on my side before merge can proceed.

@stakeswky stakeswky force-pushed the fix/sse-function-call-id-consistency branch from fe469b4 to 86288b4 Compare February 28, 2026 01:51
@stakeswky
Copy link
Author

Updated this branch to fix CLA/email issues:\n- removed merge commit authored with non-matching email\n- replaced autoformat commit authored as user@example.com\n- all commits now use stakeswky@gmail.com\n\nPlease re-run CLA checks.

…g 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 google#4609

Signed-off-by: stakeswky <stakeswky@gmail.com>
@stakeswky stakeswky force-pushed the fix/sse-function-call-id-consistency branch from 86288b4 to 15225be Compare February 28, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core [Component] This issue is related to the core interface and implementation request clarification [Status] The maintainer need clarification or more information from the author

Projects

None yet

Development

Successfully merging this pull request may close these issues.

populate_client_function_call_id generates different UUIDs for the same function call across partial and final SSE streaming events

3 participants