22#
33# SPDX-License-Identifier: Apache-2.0
44
5+ import copy
56from collections .abc import Iterator
67from dataclasses import dataclass , field
78from typing import Any
@@ -47,8 +48,8 @@ def subtract(a: Annotated[int, "first number"], b: Annotated[int, "second number
4748 ```
4849
4950 2. Base class for dynamic tool loading:
50- By subclassing Toolset, you can create implementations that dynamically load tools
51- from external sources like OpenAPI URLs, MCP servers, or other resources.
51+ By subclassing Toolset, you can create implementations that dynamically load tools from external sources like
52+ OpenAPI URLs, MCP servers, or other resources.
5253
5354 Example:
5455 ```python
@@ -94,15 +95,14 @@ def from_dict(cls, data):
9495 agent = Agent(chat_generator=OpenAIChatGenerator(), tools=calculator_toolset)
9596 ```
9697
97- Toolset implements the collection interface (__iter__, __contains__, __len__, __getitem__),
98- making it behave like a list of Tools. This makes it compatible with components that expect
99- iterable tools, such as Agent or Haystack chat generators.
98+ Toolset implements the collection interface (__iter__, __contains__, __len__, __getitem__), making it behave like
99+ a list of Tools. This makes it compatible with components that expect iterable tools, such as Agent or Haystack
100+ chat generators.
100101
101102 When implementing a custom Toolset subclass for dynamic tool loading:
102103 - Perform the dynamic loading in the __init__ method
103104 - Override to_dict() and from_dict() methods if your tools are defined dynamically
104- - Serialize endpoint descriptors rather than tool instances if your tools
105- are loaded from external sources
105+ - Serialize endpoint descriptors rather than tool instances if your tools are loaded from external sources
106106 """
107107
108108 # Use field() with default_factory to initialize the list
@@ -124,15 +124,56 @@ def __post_init__(self) -> None:
124124 # Tracks whether warm_up() has already run so subsequent calls become a no-op.
125125 self ._is_warmed_up = False
126126
127+ # Optional per-run name filter. When set, iteration only yields tools whose name is in this set.
128+ # None means no filtering. Set on a per-run spawn(), so it never leaks across runs.
129+ self ._selected_tool_names : set [str ] | None = None
130+
127131 def __iter__ (self ) -> Iterator [Tool ]:
128132 """
129133 Return an iterator over the Tools in this Toolset.
130134
131- This allows the Toolset to be used wherever a list of Tools is expected.
135+ This allows the Toolset to be used wherever a list of Tools is expected. If a name filter is active,
136+ only the tools whose names are in it are yielded.
132137
133138 :returns: An iterator yielding Tool instances
134139 """
135- return iter (self .tools )
140+ for tool in self .tools :
141+ if self ._selected_tool_names is None or tool .name in self ._selected_tool_names :
142+ yield tool
143+
144+ def get_selectable_tools (self ) -> list [Tool ]:
145+ """
146+ Return the full set of tools that can be selected by name, ignoring any active name filter.
147+
148+ This differs from iteration, which yields only the tools currently exposed (and respects the name filter).
149+ Override this when a Toolset's iteration does not surface every selectable tool, so name-based selection
150+ can still target the full set.
151+
152+ Warms up the Toolset first if needed, so lazily loaded tools (those a Toolset fetches in `warm_up()`)
153+ are available for selection.
154+
155+ :returns: The list of tools available for name-based selection.
156+ """
157+ if not self ._is_warmed_up :
158+ self .warm_up ()
159+ return list (self .tools )
160+
161+ def spawn (self ) -> "Toolset" :
162+ """
163+ Return an isolated copy of this Toolset for a single run.
164+
165+ The copy shares this Toolset's read-only state (its tools and any warmed-up resources) but gets fresh
166+ run-scoped state, so concurrent runs that share the same configured Toolset don't corrupt each other (for
167+ example, one run's name selection leaking into another). Warms up first if needed so the copy shares the
168+ warmed state. Subclasses with additional run-scoped state should override this.
169+
170+ :returns: A run-scoped copy of this Toolset.
171+ """
172+ if not self ._is_warmed_up :
173+ self .warm_up ()
174+ new = copy .copy (self )
175+ new ._selected_tool_names = None
176+ return new
136177
137178 def __contains__ (self , item : str | Tool ) -> bool :
138179 """
@@ -146,9 +187,9 @@ def __contains__(self, item: str | Tool) -> bool:
146187 :returns: True if contained, False otherwise
147188 """
148189 if isinstance (item , str ):
149- return any (tool .name == item for tool in self . tools )
190+ return any (tool .name == item for tool in self )
150191 if isinstance (item , Tool ):
151- return item in self . tools
192+ return any ( tool is item or tool == item for tool in self )
152193 return False
153194
154195 def warm_up (self ) -> None :
@@ -281,20 +322,20 @@ def __add__(self, other: "Tool | Toolset | list[Tool]") -> "Toolset":
281322
282323 def __len__ (self ) -> int :
283324 """
284- Return the number of Tools in this Toolset.
325+ Return the number of Tools in this Toolset (respecting any active name filter) .
285326
286327 :returns: Number of Tools
287328 """
288- return len ( self . tools )
329+ return sum ( 1 for _ in self )
289330
290331 def __getitem__ (self , index : int ) -> Tool :
291332 """
292- Get a Tool by index.
333+ Get a Tool by index (respecting any active name filter) .
293334
294335 :param index: Index of the Tool to get
295336 :returns: The Tool at the specified index
296337 """
297- return self . tools [index ]
338+ return list ( self ) [index ]
298339
299340
300341class _ToolsetWrapper (Toolset ):
@@ -312,9 +353,19 @@ def __init__(self, toolsets: list[Toolset]) -> None:
312353 self ._is_warmed_up = False
313354
314355 def __iter__ (self ) -> Iterator [Tool ]:
315- """Iterate over all tools from all toolsets."""
356+ """Iterate over all tools from all toolsets, honoring any active name filter ."""
316357 for toolset in self .toolsets :
317- yield from toolset
358+ for tool in toolset :
359+ if self ._selected_tool_names is None or tool .name in self ._selected_tool_names :
360+ yield tool
361+
362+ def get_selectable_tools (self ) -> list [Tool ]:
363+ """Return every selectable tool across all wrapped toolsets, ignoring any active filter."""
364+ return [tool for toolset in self .toolsets for tool in toolset .get_selectable_tools ()]
365+
366+ def spawn (self ) -> "_ToolsetWrapper" :
367+ """Return an isolated copy with each wrapped toolset spawned."""
368+ return _ToolsetWrapper ([toolset .spawn () for toolset in self .toolsets ])
318369
319370 def __contains__ (self , item : Any ) -> bool :
320371 """Check if a tool is in any of the toolsets."""
@@ -371,8 +422,8 @@ def from_dict(cls, data: dict[str, Any]) -> "_ToolsetWrapper":
371422 return cls (toolsets = toolsets )
372423
373424 def __len__ (self ) -> int :
374- """Return total number of tools across all toolsets."""
375- return sum (len ( toolset ) for toolset in self . toolsets )
425+ """Return total number of tools across all toolsets (respecting any active name filter) ."""
426+ return sum (1 for _ in self )
376427
377428 def __getitem__ (self , index : int ) -> Tool :
378429 """Get a tool by index across all toolsets."""
0 commit comments