Skip to content

fix(google): don't start a generation from audio trailing a tool call - #7196

Open
Darshak03 wants to merge 4 commits into
livekit:mainfrom
Darshak03:fix/gemini-realtime-trailing-model-turn
Open

fix(google): don't start a generation from audio trailing a tool call#7196
Darshak03 wants to merge 4 commits into
livekit:mainfrom
Darshak03:fix/gemini-realtime-trailing-model-turn

Conversation

@Darshak03

Copy link
Copy Markdown

A tool call ends the turn and _handle_tool_calls finalizes the generation, but the server can keep streaming model_turn audio that belongs 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.

From a live call:

input_transcription(finished, "My Monster Jam device is not working.")
  -> new generation started: GR_e618ac79ab58
  ... model_turn audio + output_transcription stream the full reply ...
tool_call  Get_Products
  -> generation done: GR_e618ac79ab58
     output_text='I completely understand how frustrating that must be.
                  Please give me just a moment while I pull up your details.'
output_transcription(finished=True)          # no .text, correctly ignored
model_turn(inline_data=audio/pcm)            # trailing frame from the finished turn
  -> new generation started: GR_ffb4da1884e7
generation_complete=True
turn_complete=True, usage_metadata(total_token_count=1230)
  -> generation done: GR_ffb4da1884e7  output_text=''   (lived 0.5 ms)

Three consequences:

  1. The spurious generation interrupts the one still playing, so the assistant message is committed as content: ['I completely'], interrupted: True while the plugin holds the full sentence in output_text.
  2. _SegmentSynchronizerImpl.playback_finished called before text/audio input is done — the synchronizer cut off mid-turn.
  3. usage_metadata arrives with turn_complete, by which point _current_generation is 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_turn until the turn really ends. The flag is cleared wherever a new turn legitimately begins — _start_new_generation, turn_complete, and generate_reply, the last so a reply requested right after a tool call is not suppressed.

The trailing content itself was already handled: push_text guards on text_ch.closed and the audio branch guards on audio_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 and generation_complete for the same reason — model_turn was the gap.

Tests: three, covering the trailing frame, that the next real turn still opens a generation, and that generate_reply after a tool call is not suppressed. All three fail on main.

Fixes #7195

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
@Darshak03
Darshak03 requested a review from a team as a code owner September 9, 2026 20:01
devin-ai-integration[bot]

This comment was marked as resolved.

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.

@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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

@devin-ai-integration devin-ai-integration Bot Sep 9, 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.

🟡 Requested replies remain suppressed

When generate_reply follows an unanswered tool call, its request reaches Gemini while _turn_ended_by_tool_call remains set. The model response cannot open a generation, so the returned future times out.

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 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.
devin-ai-integration[bot]

This comment was marked as resolved.

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

Gemini realtime: audio trailing a tool call starts an empty generation and truncates the reply

1 participant