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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions