-
Notifications
You must be signed in to change notification settings - Fork 3k
Description
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:
- Partial event yields function call with ID-A --> consumer captures ID-A
- Final event yields the same function call with ID-B --> ADK persists ID-B in session
- Consumer submits
FunctionResponsewith 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.idAlternative 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
- BadRequestError: tool_call_id not found when using Claude/OpenAI with session history containing tool calls #4348 --
tool_call_idstripping for non-Gemini models (different bug, partially fixed in v1.25.0) - Database UniqueViolation on events table during streaming with multi-part LLM responses (text + function call) #297 --
UniqueViolationduring streaming with multi-part responses (same root cause of Event identity) - [Bug]: Streaming HITL Tool Call ID Mismatch in ag-ui-adk ag-ui-protocol/ag-ui#1168
Downstream fix
ag-ui-protocol/ag-ui#1175 (workaround)