Skip to content

fix(voice): preserve toolsets during LLM tool sync#1752

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
1.5.0from
tended-scrimped-swollen
Open

fix(voice): preserve toolsets during LLM tool sync#1752
rosetta-livekit-bot[bot] wants to merge 1 commit into
1.5.0from
tended-scrimped-swollen

Conversation

@rosetta-livekit-bot

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

Copy link
Copy Markdown
Contributor

Summary

Testing

  • pnpm build:agents
  • pnpm test -- agents/src/llm/tool_context.test.ts agents/src/voice/generation_tools.test.ts

Notes

  • Did not add tests, matching the upstream patch scope.
  • Repo-wide pnpm api:check is blocked by existing missing plugin dist declarations for phonic/tavus; targeted @livekit/agents api:check is blocked by the existing API Extractor parser error for export * as syntax in agents/dist/index.d.ts.

Ported from livekit/agents#6049

Original PR description

Two related fixes for async toolset (AsyncToolset) routing/teardown around agent handoff.

1. Session-scoped AsyncToolset cancelled at handoff (primary)

A session-scoped AsyncToolset (AgentSession(tools=[...])) should survive an agent handoff: a long-running ctx.update() tool started under agent A keeps running and delivers under agent B. In the pipeline path it didn't — the in-flight tool was cancelled at handoff and its deferred result lost.

Root cause: the tool->executor routing map was built from tool_ctx.toolsets. But the LLM inference step shares the same tool_ctx and runs first; to hand the model a flat function list it flattens the toolsets and writes the flat list back into tool_ctx in place (_llm_inference_task: tools = tool_ctx.flatten() then tool_ctx.update_tools(tools)). After that tool_ctx.toolsets is empty, so a session-scoped tool was routed to the activity executor and cancelled when the activity drained on handoff.

Fix: build the routing map from the registered toolsets directly (session.tools + the current agent's tools + MCP), which still carry the AsyncToolset grouping, instead of the post-inference tool_ctx.

2. Agent-scoped AsyncToolset hard-cancelled at handoff

Exposed by fix #(1) (and flagged in review). Agent-scoped AsyncToolsets (Agent(tools=[...])) run on their own executors, but aclose() only drain()ed the activity executor; the agent toolset executors were aclose()d (cancel-all). So a non-cancellable agent-scoped async tool that had returned from its first ctx.update() was hard-cancelled at handoff instead of being awaited, losing its result.

Fix: drain the agent-scoped AsyncToolset executors alongside the activity executor (cancel cancellable, await non-cancellable) before the aclose() sweep. Session-scoped toolsets are owned by the session and intentionally not drained here.

Verification

Reproduced and verified end-to-end with agents-cli against real inference (text + voice):

  • Session-scoped async tool started under A survives a mid-flight handoff and delivers under B; an activity-scoped tool correctly drains at handoff.
  • Agent-scoped non-cancellable async tool: before fix #(2) it logged CANCELLED at handoff; after, it logs COMPLETED (awaited to completion).

This matches the JS implementation, which keeps toolCtx.toolsets intact and routes off it. A deeper follow-up could stop the inference step from destructively flattening the shared tool_ctx in place; these fixes address routing/teardown at the consumer with minimal blast radius.

@changeset-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c3880bf

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

This PR includes changesets to release 34 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-assemblyai 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-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld 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-phonic 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 June 11, 2026 03:43

@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 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +500 to +512
_syncFlattened(
tools: readonly Tool[],
structuredTools: readonly ToolContextEntry<UserData>[] = this._tools,
): void {
const current = new ToolContext<UserData>(structuredTools).flatten();
const currentTools = new Set(current);
const nextTools = new Set(tools);
const added = tools.filter((tool) => !currentTools.has(tool));
const removed = current.filter((tool) => !nextTools.has(tool));

const structured = structuredTools.filter((tool) => !removed.some((r) => tool === r));
this._updateTools([...structured, ...(added as ToolContextEntry<UserData>[])], removed);
}

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.

🚩 _tools inconsistency with _functionToolsMap after exclusion-based sync

After _syncFlattened excludes Toolset members (the removal scenario), _tools still contains Toolsets whose .tools arrays include the excluded members, but _functionToolsMap does not. This means new ToolContext(toolCtx.tools) would re-include excluded tools and new ToolContext(toolCtx.tools).equals(toolCtx) would return false. This inconsistency is explicitly documented in the _syncFlattened docstring ("removed Toolset members stay owned by their toolset for lifecycle purposes, but are excluded from this context's callable/provider lookups"). However, it could interact with onToolsetToolsChanged at agents/src/voice/agent_activity.ts:4210 — that method creates new ToolContext(current.tools) and checks .equals(current), so it would detect a difference and call updateTools(current.tools), potentially re-adding tools that were intentionally excluded. This is only triggered when a dynamic Toolset pushes new tools, which is unlikely to happen during active LLM inference, but worth being aware of.

Open in Devin Review

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

Comment on lines +500 to +512
_syncFlattened(
tools: readonly Tool[],
structuredTools: readonly ToolContextEntry<UserData>[] = this._tools,
): void {
const current = new ToolContext<UserData>(structuredTools).flatten();
const currentTools = new Set(current);
const nextTools = new Set(tools);
const added = tools.filter((tool) => !currentTools.has(tool));
const removed = current.filter((tool) => !nextTools.has(tool));

const structured = structuredTools.filter((tool) => !removed.some((r) => tool === r));
this._updateTools([...structured, ...(added as ToolContextEntry<UserData>[])], removed);
}

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.

🚩 No test coverage for _syncFlattened

The new _syncFlattened method has no unit tests in tool_context.test.ts. The method handles non-trivial logic (identity-based diff, Toolset preservation, exclusion mechanism) and would benefit from test cases covering: (1) no-op when tools unchanged, (2) tool added by LLM node, (3) tool removed from a Toolset, (4) top-level tool removed, (5) tool replaced with different instance same name. The existing test suite thoroughly covers ToolContext and Toolset behavior but doesn't exercise _syncFlattened or the exclude parameter of _updateTools.

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