Skip to content

fix: preserve function call IDs across SSE streaming partial/final events#4611

Closed
stakeswky wants to merge 2 commits intogoogle:mainfrom
stakeswky:fix/streaming-function-call-id-mismatch
Closed

fix: preserve function call IDs across SSE streaming partial/final events#4611
stakeswky wants to merge 2 commits intogoogle:mainfrom
stakeswky:fix/streaming-function-call-id-mismatch

Conversation

@stakeswky
Copy link

@stakeswky stakeswky commented Feb 24, 2026

Summary

Fixes #4609

When SSE streaming is enabled (default since ADK 1.22+), _finalize_model_response_event creates a fresh Event object for the final (non-partial) response. This caused populate_client_function_call_id to generate a new UUID for the same logical function call, breaking HITL workflows with LongRunningFunctionTool.

Root Cause

The if not function_call.id guard in populate_client_function_call_id only prevents re-assignment on the same Event object. It does not prevent assigning a different ID to the same logical function call across different Event objects (partial vs final).

Fix

Introduces a function_call_ids dict that tracks assigned IDs across partial and final events in the same streaming sequence:

  1. Before generating new IDs, the finalizer restores any previously assigned IDs from the dict
  2. After populate_client_function_call_id runs, newly generated IDs are persisted back into the dict for reuse

The dict is keyed by (function_name, index) to handle multi-function-call responses correctly.

Changes

  • base_llm_flow.py: Added function_call_ids parameter threading through _finalize_model_response_event, _postprocess_after_model_call, and the streaming loop
  • test_streaming_function_call_id.py: 4 regression tests

Testing Plan

  1. Unit tests: 4 new tests in test_streaming_function_call_id.py:

    • test_partial_and_final_share_same_id: Core regression test
    • test_without_function_call_ids_dict_generates_different_ids: Demonstrates old buggy behavior
    • test_multiple_function_calls_preserve_ids: Multi-call response stability
    • test_server_provided_id_is_preserved: Server IDs not overwritten
  2. Regression testing: All 352 existing tests in tests/unittests/flows/llm_flows/ pass

  3. Test command: python -m pytest tests/unittests/flows/llm_flows/ -v — 356 passed (352 existing + 4 new)

…ents

In SSE streaming mode, _finalize_model_response_event creates a brand-new
Event object for every LlmResponse chunk (partial and final). Because each
new Event's function calls start with an empty .id, populate_client_function_call_id
generates a fresh adk-{uuid} every time. This means the partial event and the
final event for the same function call end up with different IDs, breaking
LongRunningFunctionTool / HITL workflows that match responses by ID.

Fix: add an optional function_call_ids dict parameter to
_finalize_model_response_event. The dict maps (function_name, index) to the
ID that was assigned the first time that function call was seen. Before
populate_client_function_call_id runs, any previously stored ID is restored
onto the function call so the guard 'if not function_call.id' keeps it.
After population, newly generated IDs are written back into the dict.

_run_one_step_async creates one such dict per LLM call and threads it
through _postprocess_async for the lifetime of the streaming sequence, so
all partial and final events share the same stable IDs.

Fixes google#4609
@google-cla
Copy link

google-cla bot commented Feb 24, 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.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @stakeswky, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug where function call IDs were not consistently maintained between partial and final events in SSE streaming. Previously, each streaming chunk generated a new Event object, leading to fresh, distinct IDs for the same function call, which broke workflows relying on ID matching. The fix introduces a mutable dictionary to track and reuse client-generated function call IDs across the entire streaming sequence, ensuring that partial and final events for a given function call share the same identifier. This change enhances the reliability of LongRunningFunctionTool and HITL workflows.

Highlights

  • Function Call ID Consistency: Introduced a mechanism to preserve function call IDs across partial and final events during Server-Sent Events (SSE) streaming, resolving an issue where new IDs were generated for each chunk.
  • API Changes: Modified the _finalize_model_response_event function to accept an optional function_call_ids dictionary, which is used to store and retrieve client-generated function call IDs.
  • Streaming Flow Integration: Updated _run_one_step_async to initialize the function_call_ids dictionary and _postprocess_async to pass it through the streaming sequence, ensuring ID continuity.
  • Comprehensive Testing: Added new regression tests to verify that partial and final events share the same function call IDs, that multiple function calls maintain stable IDs, and that server-provided IDs are not overwritten.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/google/adk/flows/llm_flows/base_llm_flow.py
    • Added function_call_ids: Optional[dict[tuple[str, int], str]] parameter to _finalize_model_response_event and BaseLlmFlow._finalize_model_response_event.
    • Implemented logic within _finalize_model_response_event to restore existing function call IDs from function_call_ids before populate_client_function_call_id runs, and to persist newly generated IDs back into the dictionary afterward.
    • Modified _run_one_step_async to initialize an empty function_call_ids dictionary and pass it to _postprocess_async and _finalize_model_response_event within the streaming loop.
    • Updated _postprocess_async to accept and forward the function_call_ids parameter.
  • tests/unittests/flows/llm_flows/test_streaming_function_call_id.py
    • Added a new test file containing TestStreamingFunctionCallIdConsistency class.
    • Included test_partial_and_final_share_same_id to confirm consistent IDs.
    • Added test_without_function_call_ids_dict_generates_different_ids to demonstrate the previous buggy behavior.
    • Implemented test_multiple_function_calls_preserve_ids to ensure stability for multiple function calls.
    • Added test_server_provided_id_is_preserved to verify that server-assigned IDs are not overwritten.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@adk-bot adk-bot added the live [Component] This issue is related to live, voice and video chat label Feb 24, 2026
@adk-bot
Copy link
Collaborator

adk-bot commented Feb 24, 2026

Response from ADK Triaging Agent

Hello @stakeswky, thank you for your contribution!

To help reviewers evaluate your PR, could you please add a testing plan section to your PR description? This section should describe how you tested your changes.

Also, it looks like the Contributor License Agreement (CLA) check is failing. Please make sure you have signed the CLA to proceed.

Thank you!

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses an issue where function call IDs were not preserved across partial and final events during SSE streaming, which is a critical fix for workflows relying on stable IDs. The solution, which involves passing a dictionary to track and persist these IDs throughout the streaming sequence, is well-implemented and effectively solves the problem. The addition of comprehensive regression tests is also a great inclusion, ensuring the fix is robust and prevents future regressions. I have one minor suggestion to refactor a small part of the logic for improved conciseness using a more idiomatic Python dictionary method.

Comment on lines 123 to 127
if function_call_ids is not None:
for i, fc in enumerate(function_calls):
key = (fc.name, i)
if fc.id and key not in function_call_ids:
function_call_ids[key] = fc.id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For conciseness, you can use dict.setdefault() to persist the newly generated IDs. This method is more idiomatic for this pattern and achieves the same result as the current if condition, but in a more compact way.

Suggested change
if function_call_ids is not None:
for i, fc in enumerate(function_calls):
key = (fc.name, i)
if fc.id and key not in function_call_ids:
function_call_ids[key] = fc.id
if function_call_ids is not None:
for i, fc in enumerate(function_calls):
key = (fc.name, i)
if fc.id:
function_call_ids.setdefault(key, fc.id)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion! Applied in cae3567 — switched to dict.setdefault() for cleaner ID persistence.

@stakeswky
Copy link
Author

The PR description already includes a Testing Plan section with 4 unit tests and the test command. I'll sign the CLA to unblock the review.

@stakeswky
Copy link
Author

I have signed the Google CLA. Please re-check.

@stakeswky
Copy link
Author

@googlebot I signed it!

@stakeswky stakeswky force-pushed the fix/streaming-function-call-id-mismatch branch from bc6aed5 to 94ba594 Compare February 25, 2026 00:46
@stakeswky
Copy link
Author

Closing in favor of #4619 which has a cleaner implementation.

@stakeswky stakeswky closed this Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live [Component] This issue is related to live, voice and video chat

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

2 participants