Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/steady-toolsets-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Preserve toolset grouping when syncing LLM-mutated tools during voice generation.
32 changes: 32 additions & 0 deletions agents/src/llm/tool_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,13 +439,23 @@ export class ToolContext<UserData = UnknownUserData> {
}

updateTools(tools: readonly ToolContextEntry<UserData>[]): void {
this._updateTools(tools);
}

private _updateTools(
tools: readonly ToolContextEntry<UserData>[],
exclude: readonly Tool[] = [],
): void {
this._tools = [...tools];
this._functionToolsMap = new Map();
this._providerTools = [];
this._toolsets = [];
const excludedTools = new Set(exclude);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any tool shape
const addTool = (tool: any): void => {
if (excludedTools.has(tool)) return;

if (isToolset(tool)) {
for (const inner of tool.tools) {
addTool(inner);
Expand Down Expand Up @@ -479,6 +489,28 @@ export class ToolContext<UserData = UnknownUserData> {
}
}

/**
* Apply in-place edits of a flattened tool list while preserving Toolset grouping.
*
* Added tools become top-level entries; removed Toolset members stay owned by their toolset
* for lifecycle purposes, but are excluded from this context's callable/provider lookups.
*
* @internal
*/
_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);
}
Comment on lines +500 to +512

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

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.


copy(): ToolContext<UserData> {
return new ToolContext<UserData>([...this._tools]);
}
Expand Down
3 changes: 3 additions & 0 deletions agents/src/voice/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,9 @@ export function performLLMInference(
let firstTokenReceived = false;

try {
const structuredTools = toolCtx.tools;
llmStream = await node(chatCtx, toolCtx, modelSettings);
toolCtx._syncFlattened(toolCtx.flatten(), structuredTools);
if (llmStream === null) {
await textWriter.close();
return;
Expand Down Expand Up @@ -540,6 +542,7 @@ export function performLLMInference(
}

if (chunk.delta.toolCalls) {
toolCtx._syncFlattened(toolCtx.flatten(), structuredTools);
for (const tool of chunk.delta.toolCalls) {
if (tool.type !== 'function_call') continue;

Expand Down