Skip to content

Fix: live connection drops all but the last parallel function call#1358

Closed
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:fix/f02-live-parallel-toolcall-drop
Closed

Fix: live connection drops all but the last parallel function call#1358
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:fix/f02-live-parallel-toolcall-drop

Conversation

@svetanis

Copy link
Copy Markdown
Contributor

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 toolCall message (parallel tool calling), only the last call is
executed. GeminiLlmConnection.createToolCallResponse rebuilds the response
Content inside the loop over the calls, so each iteration overwrites the
previous content and only the final FunctionCall survives. The earlier calls are
dropped before the Event is built, never receive a functionResponse, and the
constructed 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 runAsync path is unaffected.

Solution:
Collect all function calls from the toolCall message into a single
role = "model" Content (one Part per call) instead of overwriting the content
on each iteration:

// GeminiLlmConnection (after)
private static LlmResponse createToolCallResponse(LiveServerToolCall toolCall) {
  LlmResponse.Builder builder = LlmResponse.builder();
  toolCall
      .functionCalls()
      .filter(Predicate.not(List::isEmpty))
      .map(GeminiLlmConnection::toModelContent)
      .ifPresent(builder::content);
  return builder.partial(false).turnComplete(false).build();
}

private static Content toModelContent(List<FunctionCall> functionCalls) {
  ImmutableList<Part> parts =
      functionCalls.stream()
          .map(call -> Part.builder().functionCall(call).build())
          .collect(toImmutableList());
  return Content.builder().role("model").parts(parts).build();
}

This preserves every call in the message (different tools or the same tool repeated),
so handleFunctionCallsLive can execute all of them and emit a functionResponse for
each, and sets the required role — matching the non-live runAsync path. The
filter keeps the original behaviour for an empty functionCalls list (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:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

Added tests to GeminiLlmConnectionTest covering the full range of what a single
toolCall message can carry:

  • convertToServerResponse_withMultipleParallelToolCalls_keepsAllFunctionCalls
    two calls to different tools; both survive, in order, with role == "model".
  • convertToServerResponse_withParallelCallsToSameTool_keepsAllFunctionCalls — two
    calls to the same tool with different args; both survive.
  • convertToServerResponse_withEmptyToolCall_emitsResponseWithoutContent — an empty
    functionCalls list produces no content (preserving the original behaviour).

The existing single-call test
(convertToServerResponse_withToolCall_mapsContentWithFunctionCall) continues to
pass (now also asserting role == "model"). Before the fix, the two multi-call tests
fail (parts has size 1, not 2).

JUnit version 4.13.2
..............
Time: 0.416
OK (14 tests)

Manual End-to-End (E2E) Tests:

Verified with a live agent that has two independent tools (getWeather, getTime),
driven via runLive and prompted to call both in one turn (Gemini Developer API key,
no Vertex; a bidiGenerateContent model such as gemini-3.1-flash-live-preview).
With DEBUG logging on GeminiLlmConnection, the raw Live server message carries
both calls:

Received server message: {"toolCall":{"functionCalls":[
  {"args":{"city":"Paris"},"name":"getWeather"},
  {"args":{"timezone":"Europe/London"},"name":"getTime"}]}}
  • Before the fix: the Event ADK builds from that message contains only the last
    call (getTime); getWeather receives no functionResponse, so the turn never
    completes — the model waits for the missing response and the session hangs
    (java.util.concurrent.TimeoutException).
  • After the fix: both calls appear in the model-role Event, both tools run,
    both functionResponses are produced, and the turn completes normally.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

The drop happens at the connection layer, before any ADK Event is built, so it is
invisible from the Event stream alone — the surviving last call looks like a normal
single tool call, and the resulting hang surfaces only as a TimeoutException.
Diagnosing it therefore means inspecting the raw Live server toolCall message
(logged at DEBUG), which shows both calls. Multiple calls per toolCall message is
by design: LiveServerToolCall.functionCalls() is a List, and
handleFunctionCallsLive already fans out over all calls and merges the results —
only createToolCallResponse failed to keep them.

@hemasekhar-p

Copy link
Copy Markdown
Contributor

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.

@kvmilos

kvmilos commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Thanks @svetanis — this got fixed on main via an internal change that mirrors your fix (all parallel calls preserved, role="model"). Your report and PR nailed it. I'll close this as fixed; wanted to credit you directly first. Thanks for the careful write-up.

@svetanis

Copy link
Copy Markdown
Contributor Author

Thanks, @kvmilos!

I really appreciate you crediting me directly and confirming the diagnosis
before closing. Glad the fix landed on main: preserving all parallel function
calls with role="model" is exactly the behavior that matters.

No concerns about routing it through your internal change —
the outcome is what counts, and it's a good one.

Thanks for the careful review; I'll keep an eye out for anything similar.
Cheers!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Live (BIDI) connection drops all but the last parallel function call

3 participants