Repository navigation
fix(google): finalize the realtime generation once per message so a combined turn_complete + tool_call isn't lost - #8376
Conversation
… 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.
| def _handle_live_server_message(self, response: types.LiveServerMessage) -> None: | ||
| if lk_google_debug: |
There was a problem hiding this comment.
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.
| if response.server_content: | ||
| self._handle_server_content(response.server_content) | ||
| if response.tool_call: | ||
| calls = self._handle_tool_calls(response.tool_call) |
There was a problem hiding this comment.
nit: return the list of llm.FunctionCall instead of tuple of str?
There was a problem hiding this comment.
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 --checkandmypy -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).
|
|
Fixes the receive-task crash from #7151, which @davidzhao linked as the root cause of #5742.
Gemini Live can send one
LiveServerMessagethat carries both a terminalserver_content(turn_complete=True) and atool_call._recv_taskhandlesserver_contentfirst. Onturn_completeit calls_mark_current_generation_done(), which closesfunction_ch._handle_tool_callsthen callssend_nowaiton the closed channel, which raisesChanClosed. That ends the receive task, so the session reconnects and the tool call is lost. For anend_calltool, that means the call is never ended.This takes the shape @longcw suggested in his review of #7164:
_recv_taskinto_handle_live_server_message(response);turn_completeortool_callis present.A tool call on its own still ends the generation, as on main. That part has to stay.
AgentActivitysends tool outputs only after the generation's streams close (L4671, then L4942).BLOCKINGis 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 untilturn_completewould 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_generationpins this.Credit and prior work:
defer_completionflag. This PR implements longcw's review suggestion on it. fix(google): deliver tool calls before combined turn completion #7164 has had no activity since 2026-09-09, and its CLA is unsigned.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_combined_message.pybuilds a realRealtimeSessionand calls its real_recv_taskwith a fake Gemini stream that sends two messages:turn_complete=Trueandtool_call(end_call).function_streamc2b2f5cbdend_callis lostChanClosed['end_call']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_ctxkeeps main's order of user, assistant, thenfunction_call. Devin caught this regression in the first commit._handle_tool_callsnow returns its calls, and the loop inserts them intochat_ctxafter finalizing._handle_tool_calls/_handle_server_contentdirectly and expected them to finalize now go through_handle_live_server_message. Their assertions are unchanged.or response.tool_callfrom the looptest_lone_tool_call_finalizes_generationfails_mark_current_generation_done()back in_handle_server_contenttest_combined_turn_complete_and_tool_call_finalizes_oncefailschat_ctxbefore finalizingtest_tool_call_is_recorded_after_the_turn_it_endsfailsruff format --check,ruff checkandscripts/check_types.py(748 files) are clean.--unit --audio_eotrun: 2 failures intests/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:realtime_api.pyand against this PR's;It restores the file when it exits.
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]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 endScope
server_contentand atool_callkills the receive task #7151). That is the root cause davidzhao pointed to for Gemini realtime: trailing audio truncated when a tool call ends the turn #5742.BLOCKINGtools, as described above. It needs a capture from a live Gemini session to settle.cc @davidzhao @longcw