Skip to content
Merged
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
1 change: 1 addition & 0 deletions livekit-agents/livekit/agents/llm/async_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,5 @@ def _attach_activity(self, *, activity: AgentActivity | None, session: AgentSess

async def aclose(self) -> None:
await super().aclose()
await self._executor.drain()
await self._executor.aclose()
28 changes: 28 additions & 0 deletions livekit-agents/livekit/agents/llm/tool_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,12 +552,20 @@ def __eq__(self, other: object) -> bool:
return True

def update_tools(self, tools: Sequence[Tool | Toolset]) -> None:
self._update_tools(tools)

def _update_tools(
self, tools: Sequence[Tool | Toolset], *, exclude: Sequence[Tool] = ()
) -> None:
self._tools = list(tools)
self._fnc_tools_map: dict[str, FunctionTool | RawFunctionTool] = {}
self._provider_tools: list[ProviderTool] = []
self._tool_sets: list[Toolset] = []

def add_tool(tool: Tool | Toolset) -> None:
if any(tool is e for e in exclude):
return

if isinstance(tool, ProviderTool):
self._provider_tools.append(tool)

Expand Down Expand Up @@ -589,6 +597,26 @@ def add_tool(tool: Tool | Toolset) -> None:
for tool in itertools.chain(tools, find_function_tools(self)):
add_tool(tool)

def _sync_flattened(self, tools: Sequence[Tool]) -> None:
"""Apply in-place edits of a ``flatten()`` list, preserving Toolset grouping.

Added tools become top-level entries; removed tools are dropped from the
flat lookup. A removed Toolset member stays in its toolset (membership and
lifecycle remain the toolset's) — it just stops being callable.
"""
current = self.flatten()
current_ids = {id(t) for t in current}
tool_ids = {id(t) for t in tools}
if current_ids == tool_ids:
return

added = [t for t in tools if id(t) not in current_ids]
removed_ids = current_ids - tool_ids
removed = [c for c in current if id(c) in removed_ids]

structured = [t for t in self._tools if not any(t is r for r in removed)]
self._update_tools([*structured, *added], exclude=removed)

def copy(self) -> ToolContext:
return ToolContext(self._tools.copy())

Expand Down
12 changes: 7 additions & 5 deletions livekit-agents/livekit/agents/voice/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ async def _llm_inference_task(

# store any updated tools, to ensure subsequent tool calls in the same turn (nested calls)
# are using the newer tools.
# tool_ctx here is ephemeral for this turn, and we allow manipulations
tool_ctx.update_tools(tools)
# tool_ctx here is ephemeral for this turn, and we allow manipulations.
# _sync_flattened writes back flat edits while preserving Toolset grouping
# (e.g. tool_ctx.toolsets stays intact for executor routing on handoff).
tool_ctx._sync_flattened(tools)
tools_snapshot = tools.copy()

if isinstance(llm_node, str):
Expand Down Expand Up @@ -163,7 +165,7 @@ async def _llm_inference_task(
tool_ctx.get_function_tool(tool.name) is None
and tools != tools_snapshot
):
tool_ctx.update_tools(tools)
tool_ctx._sync_flattened(tools)
tools_snapshot = tools.copy()

fnc_call = llm.FunctionCall(
Expand Down Expand Up @@ -583,8 +585,8 @@ def _tool_completed(out: ToolExecutionOutput) -> None:
)
return

# AsyncToolset members route to their own executor for per-toolset
# update/reply coalescing; the rest fall back to the activity executor
# Route AsyncToolset members to their own executor so session-scoped async
# tools survive handoff; everything else falls back to the activity executor.
executor_by_name = _build_executor_map(
toolsets=tool_ctx.toolsets, default=activity._tool_executor
)
Expand Down