fix(google): don't start a generation from audio trailing a tool call - #7196
fix(google): don't start a generation from audio trailing a tool call#7196Darshak03 wants to merge 4 commits into
Conversation
A tool call ends the turn and finalizes the generation, but the server can keep streaming model_turn audio belonging to it. _is_new_generation() returned True for any model_turn, so a single trailing frame opened a second generation for a turn that was already over. That generation interrupts the one still playing, so the assistant message is committed truncated and marked interrupted while the plugin holds the full text, the segment synchronizer is cut off mid-turn, and the usage_metadata arriving with turn_complete is attributed to the empty generation instead of the one that produced the reply. Remember that a tool call ended the turn and ignore model_turn until the turn really ends. The trailing content itself was already dropped by the closed-stream guards in push_text and the audio branch, so suppressing the new generation is enough. Fixes livekit#7195
Clearing it inside generate_reply reopened the hole it was meant to close: the request is only queued there, so audio still arriving from the turn the tool call ended could open a generation, and _start_new_generation hands that generation to the pending generate_reply future. Suppression now lasts until the turn really ends, or until the tool response reaches the socket. The latter is the backstop for a model that ends such a turn without a turn_complete, which would otherwise suppress model_turn for good. Only the tool response clears it. LiveClientRealtimeInput carries every audio frame and LiveClientContent is also used for instruction and chat context updates that request no turn, so neither is a turn boundary.
There was a problem hiding this comment.
There was a problem hiding this comment.
I do not think this one should be changed, because the fix for it reopens the finding that was just resolved above.
The two are in direct tension. The earlier comment was that clearing before the request reaches Gemini lets trailing audio resolve the pending reply with stale content. Clearing when the request does reach the socket narrows that window but does not close it — audio from the finished turn can still arrive after our send. So adding a wrapper to clear on send trades a user-visible wrong reply for a timeout in a case that is already blocked for another reason.
On that case: an unanswered tool call blocks the model by design. From #6785, which fixed exactly this: "A realtime model holds each call it emitted open until it is answered, so Gemini Live stopped responding and later generate_reply() calls produced no generation (#6569)." So with a call outstanding, generate_reply yields no generation at the protocol level, whatever this flag does. The timeout is #6569's, not this flag's.
It also needs turn_complete to be missing. In the call this PR is based on, the tool call and turn_complete land within the same millisecond, and turn_complete clears the flag — so the window where the flag is still set and a reply is requested is about that wide, and any tool doing real work is far outside it.
Between the two, suppression that lasts slightly too long in a case the protocol already blocks seems better than suppression that ends slightly too early in a case that silently attaches the wrong audio to the caller's reply. The model that ends such a turn without a turn_complete is covered by the tool-response clear, so it cannot be suppressed indefinitely either.
…wers The send and receive tasks run concurrently, so a tool call can land while an earlier response is still going out. That response says nothing about the turn the newer call ended, but clearing unconditionally dropped its suppression and let the trailing audio open a generation again. Bump an epoch on every tool call and clear only when the response finishes sending against the same one.
Clearing it when the tool response reached the socket was a backstop for a model that ends such a turn without a turn_complete, and it never fired first in any observed flow: turn_complete arrives about a millisecond after the tool call, while the response follows the tool's own round trip. What it did do was race the receive task, since a call landing before the answer was dequeued left that answer clearing a turn it does not answer. Suppression now lasts from the tool call until the turn ends or the next one begins, both of which are server events, so there is no send path to race. A model that ends the turn without a turn_complete still recovers: a later tool call or any transcription text opens a generation and clears the flag.
A tool call ends the turn and
_handle_tool_callsfinalizes the generation, but the server can keep streamingmodel_turnaudio that belongs to it._is_new_generation()returned True for anymodel_turn, so a single trailing frame opened a second generation for a turn that was already over.From a live call:
Three consequences:
content: ['I completely'], interrupted: Truewhile the plugin holds the full sentence inoutput_text._SegmentSynchronizerImpl.playback_finished called before text/audio input is done— the synchronizer cut off mid-turn.usage_metadataarrives withturn_complete, by which point_current_generationis the empty one, so the turn's tokens are recorded against a generation that produced nothing.Fix: remember that a tool call ended the turn, and ignore
model_turnuntil the turn really ends. The flag is cleared wherever a new turn legitimately begins —_start_new_generation,turn_complete, andgenerate_reply, the last so a reply requested right after a tool call is not suppressed.The trailing content itself was already handled:
push_textguards ontext_ch.closedand the audio branch guards onaudio_ch.closed, both dropping with a warning. The only damage came from_start_new_generation()being called, so suppressing that is sufficient and nothing else changes._is_new_generation()already carried a comment about this class of event — it excludes empty transcriptions andgeneration_completefor the same reason —model_turnwas the gap.Tests: three, covering the trailing frame, that the next real turn still opens a generation, and that
generate_replyafter a tool call is not suppressed. All three fail onmain.Fixes #7195