Skip to content

fix(google): finalize the realtime generation once per message so a combined turn_complete + tool_call isn't lost - #8376

Open
himanshumehta wants to merge 3 commits into
livekit:mainfrom
himanshumehta:fix/5742-gemini-combined-tool-call
Open

himanshumehta wants to merge 3 commits into
livekit:mainfrom
himanshumehta:fix/5742-gemini-combined-tool-call

Conversation

@himanshumehta

@himanshumehta himanshumehta commented Nov 4, 2026 •

Copy link
Copy Markdown

Fixes the receive-task crash from #7151, which @davidzhao linked as the root cause of #5742.

Gemini Live can send one LiveServerMessage that carries both a terminal server_content (turn_complete=True) and a tool_call. _recv_task handles server_content first. On turn_complete it calls _mark_current_generation_done(), which closes function_ch. _handle_tool_calls then calls send_nowait on the closed channel, which raises ChanClosed. That ends the receive task, so the session reconnects and the tool call is lost. For an end_call tool, that means the call is never ended.

This takes the shape @longcw suggested in his review of #7164:

  • the per-message dispatch moves out of _recv_task into _handle_live_server_message(response);
  • the two handlers no longer finalize the generation themselves;
  • the loop finalizes once, after both handlers, if turn_complete or tool_call is present.
if response.server_content:
    self._handle_server_content(response.server_content)
if response.tool_call:
    self._handle_tool_calls(response.tool_call)
if ((sc := response.server_content) and sc.turn_complete) or response.tool_call:
    self._mark_current_generation_done()

A tool call on its own still ends the generation, as on main. That part has to stay. AgentActivity sends tool outputs only after the generation's streams close (L4671, then L4942). BLOCKING is the server default on every model except Gemini 3.8. If Gemini waits for the tool output before it ends the turn, which #6285's timed-drain design suggests, then holding the generation open until turn_complete would stall every tool call. I haven't confirmed that against live Gemini, so this PR keeps main's behaviour for a lone tool call. test_lone_tool_call_finalizes_generation pins this.

Credit and prior work:

If either author would rather continue their PR, I'm happy to close this and offer the tests there.

Proof of concept: the real receive loop on main vs this PR

PoC and mutation runs

poc_combined_message.py builds a real RealtimeSession and calls its real _recv_task with a fake Gemini stream that sends two messages:

  1. one audio chunk;
  2. one message carrying turn_complete=True and tool_call(end_call).
Version Tool calls reaching function_stream Receive task Session
main c2b2f5cbd none: end_call is lost raises ChanClosed reconnect forced
this PR ['end_call'] no error no reconnect

Test results

  • tests/test_plugin_google_realtime.py: 77 passed. There are 3 new tests:
    • test_combined_turn_complete_and_tool_call_finalizes_once: the call is delivered, the generation finalizes exactly once, and nothing raises;
    • test_lone_tool_call_finalizes_generation: the guard against the stall;
    • test_tool_call_is_recorded_after_the_turn_it_ends: chat_ctx keeps main's order of user, assistant, then function_call. Devin caught this regression in the first commit.
  • _handle_tool_calls now returns its calls, and the loop inserts them into chat_ctx after finalizing.
  • Existing tests that called _handle_tool_calls / _handle_server_content directly and expected them to finalize now go through _handle_live_server_message. Their assertions are unchanged.
  • Mutation checks:
Mutation Result
Drop or response.tool_call from the loop test_lone_tool_call_finalizes_generation fails
Put _mark_current_generation_done() back in _handle_server_content test_combined_turn_complete_and_tool_call_finalizes_once fails
Insert the tool calls into chat_ctx before finalizing test_tool_call_is_recorded_after_the_turn_it_ends fails
  • ruff format --check, ruff check and scripts/check_types.py (748 files) are clean.
  • Full --unit --audio_eot run: 2 failures in tests/test_room.py (test_joins_after, test_published_after). These are FFI connect timeouts in a file this PR doesn't touch, and both pass when re-run.
Repro (about 15 s)

From a checkout of this branch, run PYTHON="uv run python" bash poc.sh, using poc.sh. It:

  • runs the PoC against main's realtime_api.py and against this PR's;
  • runs the test file;
  • applies each mutation in turn.

It restores the file when it exits.

main c2b2f5cbd generations=1  tool calls delivered=[]  receive-task error=ChanClosed  reconnect=yes
this PR        generations=1  tool calls delivered=['end_call']  receive-task error=None  reconnect=no

Diagrams

flowchart TD
    A[LiveServerMessage] --> B{response.server_content present?}
    B -->|yes| C[_handle_server_content - no self-finalize]
    B -->|no| D
    C --> D{response.tool_call present?}
    D -->|yes| E[_handle_tool_calls - no self-finalize]
    D -->|no| F
    E --> F{server_content.turn_complete OR tool_call present?}
    F -->|yes| G[_mark_current_generation_done - exactly once]
    F -->|no| H[generation stays open]
    G --> I[function_ch / message_ch / text_ch / audio_ch closed]
Loading
sequenceDiagram
    participant Gemini
    participant RecvTask as _recv_task
    participant SC as _handle_server_content
    participant TC as _handle_tool_calls
    participant Gen as generation channels
    participant AA as AgentActivity

    Gemini->>RecvTask: LiveServerMessage(server_content(turn_complete=True), tool_call)
    alt base c2b2f5cbd6 (bug #7151)
        RecvTask->>SC: handle server_content
        SC->>Gen: _mark_current_generation_done() closes function_ch
        RecvTask->>TC: handle tool_call
        TC->>Gen: function_ch.send_nowait(call)
        Gen-->>TC: ChanClosed - recv task crashes, call lost, reconnect forced
    else R1 fix (this commit)
        RecvTask->>SC: handle server_content, no finalize
        RecvTask->>TC: handle tool_call, no finalize
        TC->>Gen: function_ch.send_nowait(call) - ok, channel still open
        RecvTask->>Gen: _mark_current_generation_done() once, after both handlers
        Gen->>AA: all 4 channels close cleanly; call delivered; AgentActivity awaits exe_task, sends tool output via update_chat_ctx
    end
Loading

Scope

cc @davidzhao @longcw

… loop

Extract the per-message dispatch out of _recv_task into
_handle_live_server_message, and stop _handle_server_content and
_handle_tool_calls from finalizing the generation themselves. The loop
now finalizes exactly once after both handlers run, when turn_complete
or tool_call is present.

On base, server_content was dispatched before tool_call, so a combined
message carrying both (turn_complete=True and a tool_call) finalized
the generation in _handle_server_content before _handle_tool_calls
could deliver the call, crashing the receive task with ChanClosed
(livekit#7151). Centralizing finalization in the loop, after both handlers
run, fixes the race while a lone tool_call still finalizes immediately,
avoiding a stall with BLOCKING tools on every model but Gemini 3.8
(livekit#5742).

Audio arriving in a message after a tool_call, in a later generation,
is a known remaining gap (R2) and is out of scope here; it needs a live
Gemini capture to drive a fix.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +1286 to +1287
def _handle_live_server_message(self, response: types.LiveServerMessage) -> None:
if lk_google_debug:

@devin-ai-integration devin-ai-integration Bot Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New message handler lacks required documentation

_handle_live_server_message has no docstring, despite the repository's requirement to document every new method.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm leaving this one as it is. AGENTS.md asks for Google-style docstrings where a docstring is written, and the private per-message handlers in this file don't have one: _handle_server_content, _handle_tool_calls, _handle_usage_metadata and _handle_go_away. _handle_live_server_message is the same kind of method, so it follows that convention. Its behaviour is pinned by test_combined_turn_complete_and_tool_call_finalizes_once, test_lone_tool_call_finalizes_generation and test_tool_call_is_recorded_after_the_turn_it_ends.

With finalization moved into the receive loop, _handle_tool_calls inserted
its FunctionCall items before _mark_current_generation_done added the
turn's user and assistant messages, so chat_ctx (and anything replayed from
it on update or reconnect) had the call ahead of the turn that produced it.
_handle_tool_calls now returns the calls and the loop inserts them after
finalizing, which restores main's order: user, assistant, function_call.

test_tool_call_is_recorded_after_the_turn_it_ends fails on 884212f and
passes here; moving the insert back before finalizing fails it again.
Tests that called _handle_tool_calls directly now go through
_handle_live_server_message with unchanged assertions. 77 passed, ruff
and check_types clean.

@longcw longcw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm. something nit:

if response.server_content:
self._handle_server_content(response.server_content)
if response.tool_call:
calls = self._handle_tool_calls(response.tool_call)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: return the list of llm.FunctionCall instead of tuple of str?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b1f2501. _handle_tool_calls now returns list[llm.FunctionCall].

One thing came up while doing it. ChatContext.insert orders items by created_at, and a FunctionCall built inside _handle_tool_calls is created before _mark_current_generation_done adds the assistant message. Inserting it as-is put the call ahead of the message, and test_tool_call_is_recorded_after_the_turn_it_ends went red:

FAILED tests/test_plugin_google_realtime.py::test_tool_call_is_recorded_after_the_turn_it_ends
1 failed, 77 passed

So the loop inserts each call with the insertion time:

for call in calls:
    self._chat_ctx.insert(call.model_copy(update={"created_at": time.time()}))

The call sent on function_ch is still a separate instance, as on main.

Tests:

  • New test_handle_tool_calls_returns_the_function_calls. It failed on 776dbb6 (assert all(isinstance(call, llm.FunctionCall) ...)) and passes now.
  • uv run pytest tests/test_plugin_google_realtime.py tests/test_realtime_*.py: 255 passed.
  • ruff check, ruff format --check and mypy -p livekit.plugins.google: clean.

Review nit from longcw. The calls are stamped with the insertion time when
they go into chat_ctx, because ChatContext.insert orders by created_at and a
call built before the turn is finalized would land ahead of the assistant
message (test_tool_call_is_recorded_after_the_turn_it_ends catches that).
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

This branch has not been deployed

No deployments
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.

3 participants