Summary
When a generation runs with toolChoice: 'none' and the model emits a tool call anyway, executeToolsTask logs an error and drops the call without producing any tool output. No functionResponse is ever sent for that call id.
On Gemini Live this is unrecoverable: the google plugin gates microphone input on the set of unanswered tool call ids, so a single dropped call silently discards every subsequent audio frame. The session does not error or close — the agent simply goes deaf until the participant disconnects.
This matters more than it looks, because Gemini Live ignores toolChoice entirely, so the model is never actually constrained and the branch is reachable in normal operation.
The asymmetry
dist/voice/generation.js:784 (and src/voice/generation.ts:1153):
if (toolChoice === "none") {
logger.error({ function: toolCall.name, speech_id: speechHandle.id },
"received a tool call with toolChoice set to 'none', ignoring");
continue; // <-- no toolCompleted(), no output
}
const tool = toolCtx.getFunctionTool(toolCall.name);
if (!tool) {
const message = `Unknown function: ${toolCall.name} - available tools: ${availableTools}`;
logger.warn(...);
toolCompleted(createToolOutput({ toolCall, exception: new ToolError(message) }));
continue; // <-- unknown tool DOES answer back
}
The unknown-tool branch ten lines below answers with an error output. The toolChoice === 'none' branch answers with nothing.
Why that wedges the mic on Gemini Live
In @livekit/agents-plugin-google:
handleToolCall() adds every incoming call id to pendingToolCallIds.
- Only a
functionResponse removes it (clearPendingToolCallIdsForResponses).
realtime_api.js:428:
pushAudio(frame) {
if (this.shouldBlockRealtimeInputForPendingTools()) {
return; // frame dropped, never sent upstream
}
...
}
shouldBlockRealtimeInputForPendingTools() {
return this.pendingToolCallIds.size > 0 && !this.isNonBlockingToolBehavior();
}
So one dropped call leaves pendingToolCallIds non-empty forever and every mic frame is discarded client-side from that point on. No inputTranscription, no onInputSpeechStarted, no further generations.
How it is reached in practice
ToolExecutor.deliverReply() delivers every finished async-tool (ctx.update()) result with:
session.generateReply({ instructions, toolChoice: 'none', chatCtx });
toolChoice: 'none' is presumably meant to keep that turn to speech. But the google plugin logs
toolChoice is not supported by the Google Realtime API.
and drops it, so the model is free to call a tool — and the default REPLY_INSTRUCTIONS_AT_TAIL ("Summarize the results naturally") does not tell it not to. In our case a background tool result came back, the model chained an unrelated tool call with a hallucinated id, that call hit the toolChoice === 'none' branch, and the session was deaf from that moment.
Abridged log:
11:36:08.261 (client) -> tool_response { get_brand_brief: {...} } # async result delivered
11:36:08.263 WARN toolChoice is not supported by the Google Realtime API.
11:36:08.263 (client) -> content "New results arrived from background tool calls ... Summarize the results naturally."
11:36:08.576 (server) <- toolCall { name: "get_deal", args: {...}, id: "ed63dca4-..." }
11:36:08.579 ERROR received a tool call with toolChoice set to 'none', ignoring function: "get_deal"
# no functionResponse for ed63dca4-... is ever sent
11:36:12.731 onInputSpeechStopped
# user keeps talking for ~25s — zero inputTranscription, zero onInputSpeechStarted
11:36:37.288 closing agent session due to participant disconnect
Impact
- Any Gemini Live agent using async tools (
ctx.update()) can be permanently muted by one stray tool call on the delivery turn.
- Also reachable from the max-steps path, which forces
toolChoice: 'none' too.
- Fails silently — no thrown error, no closed session, nothing in the logs after the initial
ERROR line. It presents as "the agent stopped listening."
Proposed fix
Make the branch symmetric with the unknown-tool branch — emit an error output so a functionResponse is always produced:
if (toolChoice === 'none') {
logger.error({ function: toolCall.name, speech_id: speechHandle.id },
"received a tool call with toolChoice set to 'none', ignoring");
toolCompleted(createToolOutput({
toolCall,
exception: new ToolError(
`Tool calls are not allowed on this turn (toolChoice is 'none'). ${toolCall.name} was not executed.`,
),
}));
continue;
}
This keeps the "don't execute it" semantics, drains pendingToolCallIds, keeps the mic alive, and tells the model its call was rejected rather than leaving it waiting on a response that never arrives.
Two things possibly worth considering alongside it:
deliverReply() relies on toolChoice: 'none' being honored. For providers that ignore it, the reply-instruction templates may be the only real guard — they currently say nothing about not calling tools.
- More generally, it may be worth guaranteeing that every consumed tool call produces exactly one output, since the realtime protocols treat an unanswered call as outstanding.
Versions
@livekit/agents 1.6.1 — the same code is present unchanged in 1.6.2 (dist/voice/generation.js:790)
@livekit/agents-plugin-google 1.6.1
- Model:
gemini-live-2.5-flash-native-audio (Vertex), Node 24
Summary
When a generation runs with
toolChoice: 'none'and the model emits a tool call anyway,executeToolsTasklogs an error and drops the call without producing any tool output. NofunctionResponseis ever sent for that call id.On Gemini Live this is unrecoverable: the google plugin gates microphone input on the set of unanswered tool call ids, so a single dropped call silently discards every subsequent audio frame. The session does not error or close — the agent simply goes deaf until the participant disconnects.
This matters more than it looks, because Gemini Live ignores
toolChoiceentirely, so the model is never actually constrained and the branch is reachable in normal operation.The asymmetry
dist/voice/generation.js:784(andsrc/voice/generation.ts:1153):The unknown-tool branch ten lines below answers with an error output. The
toolChoice === 'none'branch answers with nothing.Why that wedges the mic on Gemini Live
In
@livekit/agents-plugin-google:handleToolCall()adds every incoming call id topendingToolCallIds.functionResponseremoves it (clearPendingToolCallIdsForResponses).realtime_api.js:428:So one dropped call leaves
pendingToolCallIdsnon-empty forever and every mic frame is discarded client-side from that point on. NoinputTranscription, noonInputSpeechStarted, no further generations.How it is reached in practice
ToolExecutor.deliverReply()delivers every finished async-tool (ctx.update()) result with:toolChoice: 'none'is presumably meant to keep that turn to speech. But the google plugin logsand drops it, so the model is free to call a tool — and the default
REPLY_INSTRUCTIONS_AT_TAIL("Summarize the results naturally") does not tell it not to. In our case a background tool result came back, the model chained an unrelated tool call with a hallucinated id, that call hit thetoolChoice === 'none'branch, and the session was deaf from that moment.Abridged log:
Impact
ctx.update()) can be permanently muted by one stray tool call on the delivery turn.toolChoice: 'none'too.ERRORline. It presents as "the agent stopped listening."Proposed fix
Make the branch symmetric with the unknown-tool branch — emit an error output so a
functionResponseis always produced:This keeps the "don't execute it" semantics, drains
pendingToolCallIds, keeps the mic alive, and tells the model its call was rejected rather than leaving it waiting on a response that never arrives.Two things possibly worth considering alongside it:
deliverReply()relies ontoolChoice: 'none'being honored. For providers that ignore it, the reply-instruction templates may be the only real guard — they currently say nothing about not calling tools.Versions
@livekit/agents1.6.1 — the same code is present unchanged in 1.6.2 (dist/voice/generation.js:790)@livekit/agents-plugin-google1.6.1gemini-live-2.5-flash-native-audio(Vertex), Node 24