diff --git a/.changeset/steady-toolsets-handoff.md b/.changeset/steady-toolsets-handoff.md new file mode 100644 index 000000000..aff82537d --- /dev/null +++ b/.changeset/steady-toolsets-handoff.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Preserve toolset grouping when syncing LLM-mutated tools during voice generation. diff --git a/agents/src/llm/tool_context.ts b/agents/src/llm/tool_context.ts index 50c4220f2..49979c5ea 100644 --- a/agents/src/llm/tool_context.ts +++ b/agents/src/llm/tool_context.ts @@ -439,13 +439,23 @@ export class ToolContext { } updateTools(tools: readonly ToolContextEntry[]): void { + this._updateTools(tools); + } + + private _updateTools( + tools: readonly ToolContextEntry[], + 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); @@ -479,6 +489,28 @@ export class ToolContext { } } + /** + * 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[] = this._tools, + ): void { + const current = new ToolContext(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[])], removed); + } + copy(): ToolContext { return new ToolContext([...this._tools]); } diff --git a/agents/src/voice/generation.ts b/agents/src/voice/generation.ts index 8cd1e84bd..97f679617 100644 --- a/agents/src/voice/generation.ts +++ b/agents/src/voice/generation.ts @@ -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; @@ -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;