Skip to content

History rebuild assumes model-supplied function-call ids are unique; Gemini reuses them, pairing a call with another tool's response (400 INVALID_ARGUMENT) #6761

Description

@demoshane

Summary

_rearrange_events_for_async_function_responses_in_history pairs function responses to calls by id alone. Model-supplied ids are not guaranteed unique — Vertex Gemini mints call_<n> ids from a space small enough to repeat within a single session. When one repeats, the assembled history pairs a function_call for one tool with a function_response for another, and the next generateContent fails with a bare 400 INVALID_ARGUMENT.

Intermittent and content-dependent, so it presents as a flaky 400 rather than a reproducible one. Measured rate below: 1 in ~153 requests.

Environment

  • google-adk 2.6.3
  • Vertex AI, gemini-3.5-flash, EU multi-region endpoint (location="eu")
  • Tools declared as FunctionTool; one gated with require_confirmation=True
  • InMemoryRunner, non-streaming (StreamingMode.NONE)

What the rejected request contains

Captured by wrapping Gemini.generate_content_async and dumping llm_request.contents on ClientError:

 1  model  function_call     id=call_807  name=site_security_posture
 2  user   function_response id=call_807  name=fleet_security_summary   <-- wrong tool
 ...
11  model  function_call     id=call_807  name=fleet_security_summary   <-- id reused
12  user   function_response id=call_807  name=fleet_security_summary

Content 2 is the response to the call at content 1, but it has been paired with — and renamed after — the later call that reused the same id.

google.genai.errors.ClientError: 400 INVALID_ARGUMENT.
{'error': {'code': 400, 'message': 'Request contains an invalid argument.', 'status': 'INVALID_ARGUMENT'}}

Mechanism

google/adk/flows/llm_flows/contents.py:145 (reached from :832 while building request contents):

function_call_id_to_response_events_index: dict[str, int] = {}
for i, event in enumerate(events):
    function_responses = event.get_function_responses()
    if function_responses:
        for function_response in function_responses:
            function_call_id = function_response.id
            function_call_id_to_response_events_index[function_call_id] = i

The index is built in forward order, so a repeated id retains only the newest response. The second pass (:165-178) then walks the events and appends that response directly after the oldest call carrying the same id — producing the mismatched pair above.

ADK keeps model-supplied ids: remove_client_function_call_id (functions.py:263) strips only ids carrying the adk- prefix (AF_FUNCTION_CALL_ID_PREFIX, :59), and populate_client_function_call_id (:255) assigns one only when not function_call.id. So a repeated model id survives into the session and reaches this rebuild.

How often

Instrumenting every request in one run: 69 distinct ids across 153 requests, all of the form call_<n> in the range call_187call_1127, with one collision — and that collision ended the run. Longer sessions with more tool calls raise the rate.

Reproducing

Any session long enough for Gemini to repeat an id. Wrapping Gemini.generate_content_async to log each request's (id, name) pairs makes it visible directly — the collision is the first request in which one id maps to two different call names.

Suggested fixes

Either would resolve it:

  1. Do not trust model-supplied ids — always mint an ADK id in populate_client_function_call_id. They are stripped before the request anyway, so the model never sees them and nothing on the wire changes. This also makes find_event_by_function_call_id (functions.py:1401, used on the response-resume path) safe, since it carries the same uniqueness assumption.
  2. Pair on id and name in the history rebuild, so a duplicate id cannot resolve across tools.

(1) seems preferable: it fixes the assumption once rather than at each site that relies on it.

Workaround, and a wrinkle worth flagging

Subclassing the model to clear part.function_call.id on every yielded response, which routes every call through populate_client_function_call_id. Zero model-supplied ids then reach the wire and the 400 stops.

One consequence others attempting this should know: _finalize_model_response_event runs on every yielded response including partial=True ones (base_llm_flow.py:1152; execution is skipped afterwards at :1163), so a fresh uuid is minted per partial and the partial's id differs from the final's. Since only the final event is persisted, anything reading a call id off a partial event gets a handle that resolves to nothing.

That is an argument for fix (1) landing upstream, where the id can be assigned once per call rather than once per yielded response.

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementationrequest clarification[Status] The maintainer need clarification or more information from the author

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions