Skip to content

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

@contextablemark

Description

@contextablemark

Summary

When SSE streaming is enabled (the default since ADK 1.22+), populate_client_function_call_id() in src/google/adk/flows/llm_flows/functions.py generates different UUIDs for the same logical function call across the partial=True (streaming) and partial=False (finalized) events. This breaks any consumer that captures the function call ID from a partial event and later tries to submit a FunctionResponse using that ID -- ADK's session lookup fails with:

No function call event found for function responses ids: {ID-from-partial-event}

Root Cause

During SSE streaming, _finalize_model_response_event in base_llm_flow.py creates a fresh Event object for the final (non-partial) response. When populate_client_function_call_id runs on this new Event, the function call's .id field is empty (it's a new object), so it generates a brand-new adk-{uuid} -- different from the one assigned to the same function call in the earlier partial event.

The if not function_call.id guard 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).

Impact

This is a blocker for Human-in-the-Loop (HITL) workflows using LongRunningFunctionTool with SSE streaming:

  1. Partial event yields function call with ID-A --> consumer captures ID-A
  2. Final event yields the 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

Since StreamingMode.SSE is the default, this affects all HITL workflows unless streaming is explicitly disabled.

Minimal Reproduction

Python script (ADK directly)

"""
Minimal reproduction: populate_client_function_call_id generates different IDs
for the same function call across partial and final streaming events.

Requirements:
  pip install google-adk>=1.22
  export GOOGLE_API_KEY=your-key-here

Run:
  python repro_streaming_id_mismatch.py
"""

import asyncio
from google.adk.agents import LlmAgent
from google.adk.tools import LongRunningFunctionTool
from google.adk import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.agents.run_config import RunConfig, StreamingMode
from google.genai import types


# A trivial long-running tool (simulates a HITL tool)
def get_user_approval(action: str) -> dict:
    """Ask the user to approve an action."""
    return {"approved": True}


async def main():
    session_service = InMemorySessionService()

    agent = LlmAgent(
        name="approval_agent",
        model="gemini-2.5-flash",
        instruction="Always use the get_user_approval tool when asked to do anything.",
        tools=[LongRunningFunctionTool(func=get_user_approval)],
    )

    runner = Runner(
        agent=agent,
        app_name="repro_app",
        session_service=session_service,
    )

    session = await session_service.create_session(
        app_name="repro_app", user_id="user1"
    )

    # Track function call IDs across partial and final events
    partial_fc_ids = {}   # name -> id from partial events
    final_fc_ids = {}     # name -> id from final events

    config = RunConfig(streaming_mode=StreamingMode.SSE)

    async for event in runner.run_async(
        user_id="user1",
        session_id=session.id,
        new_message=types.Content(
            role="user",
            parts=[types.Part(text="Please approve the deployment")]
        ),
        run_config=config,
    ):
        is_partial = getattr(event, 'partial', False)
        if event.content and hasattr(event.content, 'parts'):
            for part in event.content.parts:
                fc = getattr(part, 'function_call', None)
                if fc and fc.id:
                    if is_partial:
                        partial_fc_ids[fc.name] = fc.id
                        print(f"PARTIAL event: {fc.name} -> {fc.id}")
                    else:
                        final_fc_ids[fc.name] = fc.id
                        print(f"FINAL   event: {fc.name} -> {fc.id}")

    # Check for mismatches
    print("\n--- Results ---")
    for name in partial_fc_ids:
        if name in final_fc_ids:
            partial_id = partial_fc_ids[name]
            final_id = final_fc_ids[name]
            match = "MATCH" if partial_id == final_id else "MISMATCH"
            print(f"{name}: partial={partial_id}, final={final_id} -> {match}")

            if partial_id != final_id:
                print(f"\n*** BUG CONFIRMED ***")
                print(f"If a consumer captured '{partial_id}' from the partial event")
                print(f"and tries to submit a FunctionResponse with that ID,")
                print(f"ADK will fail because it persisted '{final_id}' instead.")


asyncio.run(main())

Expected output (demonstrating the bug):

PARTIAL event: get_user_approval -> adk-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
FINAL   event: get_user_approval -> adk-yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
--- Results ---
get_user_approval: partial=adk-xxx..., final=adk-yyy... -> MISMATCH
*** BUG CONFIRMED ***

Suggested Fix

Cache generated IDs by (invocation_id, function_call_index) so the same logical function call always gets the same ID:

# In functions.py

_function_call_id_cache: Dict[Tuple[str, int], str] = {}

def populate_client_function_call_id(model_response_event: Event) -> None:
    invocation_id = getattr(model_response_event, 'invocation_id', None)
    for i, function_call in enumerate(model_response_event.get_function_calls()):
        if not function_call.id:
            cache_key = (invocation_id, i) if invocation_id else None
            if cache_key and cache_key in _function_call_id_cache:
                function_call.id = _function_call_id_cache[cache_key]
            else:
                function_call.id = f'adk-{uuid.uuid4()}'
                if cache_key:
                    _function_call_id_cache[cache_key] = function_call.id

Alternative approach: Preserve the function call ID from the partial Event when constructing the final Event in _finalize_model_response_event.

Environment

  • google-adk: 1.22+ (any version with SSE streaming as default)
  • google-genai: any
  • Python: 3.10+
  • Affects: All models (Gemini, Claude via Vertex AI, OpenAI via LiteLLM)
  • Streaming mode: SSE (the default)

Related Issues

Downstream fix

ag-ui-protocol/ag-ui#1175 (workaround)

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementation

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions