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_187–call_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:
- 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.
- 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.
Summary
_rearrange_events_for_async_function_responses_in_historypairs function responses to calls by id alone. Model-supplied ids are not guaranteed unique — Vertex Gemini mintscall_<n>ids from a space small enough to repeat within a single session. When one repeats, the assembled history pairs afunction_callfor one tool with afunction_responsefor another, and the nextgenerateContentfails with a bare400 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-adk2.6.3gemini-3.5-flash, EU multi-region endpoint (location="eu")FunctionTool; one gated withrequire_confirmation=TrueInMemoryRunner, non-streaming (StreamingMode.NONE)What the rejected request contains
Captured by wrapping
Gemini.generate_content_asyncand dumpingllm_request.contentsonClientError: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.
Mechanism
google/adk/flows/llm_flows/contents.py:145(reached from:832while building request contents):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 theadk-prefix (AF_FUNCTION_CALL_ID_PREFIX,:59), andpopulate_client_function_call_id(:255) assigns one only whennot 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 rangecall_187–call_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_asyncto 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:
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 makesfind_event_by_function_call_id(functions.py:1401, used on the response-resume path) safe, since it carries the same uniqueness assumption.(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.idon every yielded response, which routes every call throughpopulate_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_eventruns on every yielded response includingpartial=Trueones (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.