Skip to content

toolChoice:'none' drops tool calls without emitting a functionResponse — permanently wedges mic input on Gemini Live #2249

Description

@AdityaSharma2168

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions