Skip to content

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

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 IDsfor the same function call across partial and final streaming events.Requirements: pip install google-adk>=1.22 export GOOGLE_API_KEY=your-key-hereRun: python repro_streaming_id_mismatch.py"""importasynciofromgoogle.adk.agentsimportLlmAgentfromgoogle.adk.toolsimportLongRunningFunctionToolfromgoogle.adkimportRunnerfromgoogle.adk.sessionsimportInMemorySessionServicefromgoogle.adk.agents.run_configimportRunConfig, StreamingModefromgoogle.genaiimporttypes# A trivial long-running tool (simulates a HITL tool)defget_user_approval(action: str) ->dict:
"""Ask the user to approve an action."""return {"approved": True}
asyncdefmain():
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=awaitsession_service.create_session(
app_name="repro_app", user_id="user1"
)
# Track function call IDs across partial and final eventspartial_fc_ids= {} # name -> id from partial eventsfinal_fc_ids= {} # name -> id from final eventsconfig=RunConfig(streaming_mode=StreamingMode.SSE)
asyncforeventinrunner.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)
ifevent.contentandhasattr(event.content, 'parts'):
forpartinevent.content.parts:
fc=getattr(part, 'function_call', None)
iffcandfc.id:
ifis_partial:
partial_fc_ids[fc.name] =fc.idprint(f"PARTIAL event: {fc.name} -> {fc.id}")
else:
final_fc_ids[fc.name] =fc.idprint(f"FINAL event: {fc.name} -> {fc.id}")
# Check for mismatchesprint("\n--- Results ---")
fornameinpartial_fc_ids:
ifnameinfinal_fc_ids:
partial_id=partial_fc_ids[name]
final_id=final_fc_ids[name]
match="MATCH"ifpartial_id==final_idelse"MISMATCH"print(f"{name}: partial={partial_id}, final={final_id} -> {match}")
ifpartial_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] = {}
defpopulate_client_function_call_id(model_response_event: Event) ->None:
invocation_id=getattr(model_response_event, 'invocation_id', None)
fori, function_callinenumerate(model_response_event.get_function_calls()):
ifnotfunction_call.id:
cache_key= (invocation_id, i) ifinvocation_idelseNoneifcache_keyandcache_keyin_function_call_id_cache:
function_call.id=_function_call_id_cache[cache_key]
else:
function_call.id=f'adk-{uuid.uuid4()}'ifcache_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