Fix: live connection drops all but the last parallel function call#1358
Fix: live connection drops all but the last parallel function call#1358svetanis wants to merge 1 commit into
Conversation
|
Hi @svetanis, thank you for your contribution! We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you. |
|
Thanks @svetanis — this got fixed on |
|
Thanks, @kvmilos! I really appreciate you crediting me directly and confirming the diagnosis No concerns about routing it through your internal change — Thanks for the careful review; I'll keep an eye out for anything similar. |
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Problem:
On the live/BIDI path, when the Gemini Live API returns multiple function calls in
a single
toolCallmessage (parallel tool calling), only the last call isexecuted.
GeminiLlmConnection.createToolCallResponserebuilds the responseContentinside the loop over the calls, so each iteration overwrites theprevious content and only the final
FunctionCallsurvives. The earlier calls aredropped before the
Eventis built, never receive afunctionResponse, and theconstructed content also lacks a
role(so it can later be discarded as empty).The result is a mismatched tool exchange and a live turn that stalls waiting for a
response that is never produced. The non-live
runAsyncpath is unaffected.Solution:
Collect all function calls from the
toolCallmessage into a singlerole = "model"Content(onePartper call) instead of overwriting the contenton each iteration:
This preserves every call in the message (different tools or the same tool repeated),
so
handleFunctionCallsLivecan execute all of them and emit afunctionResponseforeach, and sets the required
role— matching the non-liverunAsyncpath. Thefilterkeeps the original behaviour for an emptyfunctionCallslist (no content).(Single-call turns were already fine — one loop iteration has nothing to overwrite —
which is why the bug stayed latent.)
Testing Plan
Unit Tests:
Added tests to
GeminiLlmConnectionTestcovering the full range of what a singletoolCallmessage can carry:convertToServerResponse_withMultipleParallelToolCalls_keepsAllFunctionCalls—two calls to different tools; both survive, in order, with
role == "model".convertToServerResponse_withParallelCallsToSameTool_keepsAllFunctionCalls— twocalls to the same tool with different args; both survive.
convertToServerResponse_withEmptyToolCall_emitsResponseWithoutContent— an emptyfunctionCallslist produces no content (preserving the original behaviour).The existing single-call test
(
convertToServerResponse_withToolCall_mapsContentWithFunctionCall) continues topass (now also asserting
role == "model"). Before the fix, the two multi-call testsfail (
partshas size 1, not 2).Manual End-to-End (E2E) Tests:
Verified with a live agent that has two independent tools (
getWeather,getTime),driven via
runLiveand prompted to call both in one turn (Gemini Developer API key,no Vertex; a
bidiGenerateContentmodel such asgemini-3.1-flash-live-preview).With
DEBUGlogging onGeminiLlmConnection, the raw Live server message carriesboth calls:
EventADK builds from that message contains only the lastcall (
getTime);getWeatherreceives nofunctionResponse, so the turn nevercompletes — the model waits for the missing response and the session hangs
(
java.util.concurrent.TimeoutException).Event, both tools run,both
functionResponses are produced, and the turn completes normally.Checklist
Additional context
The drop happens at the connection layer, before any ADK
Eventis built, so it isinvisible from the
Eventstream alone — the surviving last call looks like a normalsingle tool call, and the resulting hang surfaces only as a
TimeoutException.Diagnosing it therefore means inspecting the raw Live server
toolCallmessage(logged at
DEBUG), which shows both calls. Multiple calls pertoolCallmessage isby design:
LiveServerToolCall.functionCalls()is aList, andhandleFunctionCallsLivealready fans out over all calls and merges the results —only
createToolCallResponsefailed to keep them.