Skip to content

fix(google): reject tool calls when toolChoice is none in realtime#1848

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
1.5.0from
marts-yanks-enchant
Open

fix(google): reject tool calls when toolChoice is none in realtime#1848
rosetta-livekit-bot[bot] wants to merge 1 commit into
1.5.0from
marts-yanks-enchant

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Testing

  • pnpm prettier --check plugins/google/src/realtime/realtime_api.ts
  • pnpm lint --filter @livekit/agents-plugin-google (via pnpm --filter @livekit/agents-plugin-google lint; completed with existing no-explicit-any warnings)
  • pnpm build --filter @livekit/agents-plugin-google...
  • pnpm test plugins/google/src/realtime/realtime_api.test.ts

Ported from livekit/agents#6166

Original PR description

Closes #6002

The Google Realtime API has no per-response tool_choice. When core requests tool_choice="none" (e.g. generate_reply() inside a tool, or the final post-tool reply), Gemini may still emit a tool call. With the default blocking tool behavior the turn then stalls waiting for a tool response that core drops (received a tool call with tool_choice set to 'none', ignoring), so the model never speaks its follow-up.

This handles the case inside the plugin: the requested tool_choice is stored on the session, and when it is "none" any tool call the model emits during that turn is answered with an error response. That unblocks the session and lets it reply to the user directly, instead of hanging.

It also unifies FunctionResponse construction into a single create_function_response, used by both get_tool_results_for_realtime and the rejection path, and honors is_error so error tool outputs are sent as {"error": ...} instead of {"output": ...}.

@changeset-bot

changeset-bot Bot commented Jun 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 4c7349f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@rosetta-livekit-bot rosetta-livekit-bot Bot requested a review from longcw June 22, 2026 02:20

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

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

}

private startNewGeneration(): void {
this.rejectedToolCalls = 0;

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.

🚩 rejectedToolCalls counter not reset when toolChoice changes away from 'none'

The rejectedToolCalls counter is only reset in startNewGeneration() at line 1525. If toolChoice is changed from 'none' to 'auto' via updateOptions() mid-turn (e.g. after the agent_activity resets it at agents/src/voice/agent_activity.ts:3922-3923), and a subsequent server message arrives before a new generation starts, the stale rejectedToolCalls > 0 could cause handleServerContent or handleUsageMetadata to incorrectly suppress content. However, in practice, the agent_activity resets toolChoice in a finally block after the generation task completes, and a new generation would reset the counter. The window for this race is very narrow — it would require the model to send content between the toolChoice reset and the next generation start. This is worth noting but unlikely to be hit in practice.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1245 to +1250
if (response.toolCall && this.options.toolChoice === 'none') {
// Reject without opening a generation, so a pending generateReply stays bound to the
// model's eventual reply and tools stay suppressed for the whole turn.
this.rejectToolCalls(response.toolCall.functionCalls ?? []);
return;
}

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.

🚩 Early return in onReceiveMessage drops non-toolCall fields when toolChoice='none'

At line 1245-1250, when response.toolCall is present and toolChoice === 'none', the entire message is returned early after calling rejectToolCalls. This means any other fields on the same LiveServerMessage (like serverContent, sessionResumptionUpdate, usageMetadata, goAway, toolCallCancellation) are silently dropped. In the Gemini Live protocol, toolCall messages typically don't carry other significant fields alongside them, so this is unlikely to cause issues. However, sessionResumptionUpdate can theoretically accompany any message. If a session resumption handle is lost, it could affect reconnection reliability. This is a minor concern given the protocol's typical behavior.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

0 participants