Skip to content

fix(voice): answer every tool call - #2289

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port/answer-every-tool-call
Open

fix(voice): answer every tool call#2289
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
port/answer-every-tool-call

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Ports livekit/agents#6785 to the Node.js voice pipeline.

Every completed tool call now produces exactly one output. Interrupted realtime results are committed locally and synced to the provider, interrupted handoffs are recorded as silent errors, and StopResponse/invalid outputs no longer leave calls unanswered. Realtime providers honor replyRequired where possible.

Verification

  • pnpm test agents (2125 passed, 5 skipped)
  • pnpm test plugins/google (42 passed, 2 skipped)
  • pnpm exec vitest run plugins/phonic --passWithNoTests (no test files)
  • pnpm build
  • Package typechecks for agents, Google, and Phonic
  • Changed-file ESLint passed with only existing warnings; the host's Node 20/pnpm 9 combination installed Prettier 3.9 instead of the lockfile's 3.2, so the full lint command also reported existing formatter-version differences in untouched files
  • cue-cli voice validation (sid_43f154e34713): interrupted assistant item, lookupOrder tool output, then a fresh assistant response
Source diff coverage
Source file Coverage
livekit-agents/livekit/agents/llm/chat_context.py Ported to agents/src/llm/chat_context.ts: added FunctionCallOutput.replyRequired, defaulting to true, including serialization.
livekit-agents/livekit/agents/llm/llm.py Not applicable: this only updates a Python docstring example after outputs become non-null; JS already returns Promise<FunctionCallOutput> and has no nullable equivalent example.
livekit-agents/livekit/agents/llm/utils.py Adapted in agents/src/voice/generation.ts, where JS owns StopResponse and invalid-output sanitization. Every completed call now gets an output.
livekit-agents/livekit/agents/voice/agent_activity.py Ported to agents/src/voice/agent_activity.ts: preserve and emit all completed interrupted outputs, sync interrupted realtime outputs, and derive reply behavior from each output.
livekit-agents/livekit/agents/voice/events.py Adapted in agents/src/voice/events.ts: JS already used non-null parallel arrays; added cancelToolReply() and hasToolReply backed by output flags.
livekit-agents/livekit/agents/voice/generation.py Ported to agents/src/voice/generation.ts: non-optional outputs, output-level reply flags, invalid-output errors, multiple-handoff errors, and silent failed interrupted handoffs.
livekit-agents/livekit/agents/voice/remote_session.py Ported to agents/src/voice/remote_session.ts: removed the obsolete null-output filter.
livekit-plugins/livekit-plugins-aws/livekit/plugins/aws/experimental/realtime/realtime_model.py Not applicable: agents-js has no AWS/Nova Sonic plugin.
livekit-plugins/livekit-plugins-google/livekit/plugins/google/realtime/realtime_api.py Ported to plugins/google/src/realtime/realtime_api.ts: SILENT scheduling where supported and explicit Gemini/Vertex warnings otherwise.
livekit-plugins/livekit-plugins-google/livekit/plugins/google/utils.py Adapted in plugins/google/src/realtime/realtime_api.ts, because JS keeps function-response construction in the realtime session rather than utils.ts.
livekit-plugins/livekit-plugins-phonic/livekit/plugins/phonic/realtime/realtime_model.py Ported to plugins/phonic/src/realtime/realtime_model.ts: silent outputs do not open a speaking turn.
livekit-plugins/livekit-plugins-ultravox/livekit/plugins/ultravox/realtime/realtime_model.py Not applicable: agents-js has no Ultravox plugin.
tests/fake_realtime.py Adapted into the existing fake realtime harness in agents/src/voice/realtime_tool_output_commit.test.ts.
tests/test_plugin_google_realtime.py Ported to plugins/google/src/realtime/realtime_api.test.ts, adapted to JS's private response-builder harness and SDK object shape.
tests/test_realtime_cancel_tool_reply.py Ported to agents/src/voice/realtime_tool_output_commit.test.ts.
tests/test_tool_output_per_call.py Ported to agents/src/voice/generation_tools.test.ts, where JS directly tests tool sanitization.
tests/test_tool_results_preserved_on_interruption.py Ported across agents/src/voice/agent_activity.test.ts and agents/src/voice/realtime_tool_output_commit.test.ts, matching JS's activity/realtime test split.

Source PR: livekit/agents#6785


Ported from livekit/agents#6785

Original PR description

Problem: A tool call could end with no output at all — dropped when the user interrupted, or never produced when a tool raised StopResponse or returned an invalid value. 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). A pipeline LLM re-issues the call instead and runs its side effects a second time.

Fix: Every call now gets exactly one output. An interrupted turn commits the results of tools that finished and delivers them to the realtime session; a handoff answers as an error, since the interruption left it unapplied.

Behavior: FunctionCallOutput.reply_required says when no reply is wanted, and each realtime plugin honours it as its provider allows — Gemini with SILENT scheduling, Ultravox with agent_reaction="listens", Phonic by not opening a turn — or warns that it cannot. That replaces the blanket warning about models which generate tool replies on their own.

Breaking: FunctionToolsExecutedEvent.function_call_outputs is list[FunctionCallOutput], no longer optional. A tool raising StopResponse now leaves a call and an empty output in history where it previously left nothing.

Replaces #6600. Fixes #6569

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner August 14, 2026 07:23
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 160d9e3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

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

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from longcw August 14, 2026 07:23

@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 3 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +445 to +453
const otherOutputs = finalOutput.filter((item) => !isAgentHandoff(item));
agentTask = handoffs[0]?.agent;
toolOutput = agentTask
? otherOutputs.length === 0
? undefined
: otherOutputs.length === 1
? otherOutputs[0]
: otherOutputs
: otherOutputs;

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.

🟡 A tool that returns a transfer together with a value inside a list silently loses the value

The value attached to an agent transfer is dropped (handoffs[0]?.agent at agents/src/voice/generation.ts:446) when the transfer is returned inside a list, so the model never sees the result the tool meant to report.
Impact: A tool that hands off and also returns data in a list answers the model with an empty result instead of the data.

Array branch ignores `AgentHandoff.returns` while the scalar branch honours it

In createToolOutput, the new array branch (agents/src/voice/generation.ts:426-454) extracts only handoffs[0]?.agent and builds toolOutput from the other array entries. For return [handoff({ agent, returns: 'ok' })], otherOutputs is empty, so toolOutput becomes undefined — the returns: 'ok' payload is discarded and replyRequired is set to false at agents/src/voice/generation.ts:487.

The non-array branch (agents/src/voice/generation.ts:454-457) does the opposite: it uses finalOutput.returns as the tool output. So the same handoff behaves differently depending on whether it is wrapped in an array.

A fix would merge any returns value of the matched handoff into otherOutputs before collapsing.

Prompt for agents
In createToolOutput (agents/src/voice/generation.ts), the newly added array-handling branch picks the handoff's agent but never uses that handoff's `returns` field, whereas the existing scalar branch does (`toolOutput = finalOutput.returns`). As a result, `return [handoff({ agent, returns: 'ok' })]` produces an empty tool output with replyRequired=false, silently dropping 'ok'. Consider folding `handoffs[0].returns` (when not undefined) into the collected `otherOutputs` before collapsing to a single value/array, so both shapes behave consistently.
Open in Devin Review

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

Comment on lines +209 to +214
get hasToolReply() {
return functionCallOutputs.some((output) => output.replyRequired);
},
cancelToolReply() {
for (const output of functionCallOutputs) output.replyRequired = false;
},

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.

🟡 Session report now contains an extra field and a non-serializable callback for tool-execution events

The tool-execution event is copied field-by-field into the uploaded report ({...event} at agents/src/voice/report.ts:160), which now also picks up the two newly added members, so the report carries a field that does not exist in the reference wire format plus a function value.
Impact: Consumers of the session report see an unexpected extra field for tool-execution events, and the report object holds a value that cannot be serialized.

New getter/method on FunctionToolsExecutedEvent leak into the wire format

createFunctionToolsExecutedEvent (agents/src/voice/events.ts:202-215) now returns an object with an own enumerable getter hasToolReply and an own method cancelToolReply. eventToJSON spreads the event (agents/src/voice/report.ts:160) and then runs toSnakeCaseDeep (agents/src/voice/report.ts:109-127), which copies every own enumerable entry. The result gains has_tool_reply: boolean and cancel_tool_reply: <function> for every function_tools_executed event pushed at agents/src/voice/report.ts:250. The Python model exposes these as a property/method, so they are absent from its model_dump().

Defining the two members as non-enumerable, or excluding them in eventToJSON's switch (like speechHandle is deleted for speech_created), would keep the wire shape aligned.

Prompt for agents
FunctionToolsExecutedEvent gained an own enumerable getter (hasToolReply) and an own method (cancelToolReply) in agents/src/voice/events.ts. agents/src/voice/report.ts eventToJSON spreads every event object and toSnakeCaseDeep copies all own enumerable entries, so serialized session reports now contain has_tool_reply and a cancel_tool_reply function value for function_tools_executed events — neither exists in the Python wire format. Consider making these members non-enumerable (Object.defineProperty) or explicitly stripping them in eventToJSON's per-type switch, similar to how speechHandle is deleted for speech_created.
Open in Devin Review

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

Comment on lines +191 to +192
readonly hasToolReply: boolean;
cancelToolReply(): void;

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.

🟡 Newly added public event members and helper function are undocumented

The two new members added to the tool-execution event and the new interrupted-output helper carry no documentation comments (hasToolReply/cancelToolReply at agents/src/voice/events.ts:191-192), which the repository's contribution rules require for every new method or interface member.
Impact: The public API documentation generated for the framework is missing entries for the newly exposed members.

Repository rule

CONTRIBUTING.md states: "If writing new methods/interfaces/enums/classes, document them. This project uses TypeDoc for automatic API documentation generation, and every new addition has to be properly documented."

Undocumented additions in this PR:

  • FunctionToolsExecutedEvent.hasToolReply and FunctionToolsExecutedEvent.cancelToolReply() (agents/src/voice/events.ts:191-192)
  • the exported interruptedToolOutput() helper (agents/src/voice/generation.ts:495)

FunctionCallOutput.replyRequired (agents/src/llm/chat_context.ts:560-561) does have a doc comment and is fine.

Suggested change
readonly hasToolReply: boolean;
cancelToolReply(): void;
/** Whether any completed tool result still expects the model to reply. */
readonly hasToolReply: boolean;
/** Suppress the model reply for every tool result in this batch. */
cancelToolReply(): void;
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