• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

deepset-ai / haystack / 19114177728

05 Nov 2025 07:41PM UTC coverage: 92.248%. Remained the same
19114177728

Pull #9932

github

web-flow
Merge 3db96ab24 into 510d06361
Pull Request #9932: fix: prompt-builder - jinja2 template set vars still shows required

13531 of 14668 relevant lines covered (92.25%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.71
haystack/tools/toolset.py
1
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
#
3
# SPDX-License-Identifier: Apache-2.0
4

5
from dataclasses import dataclass, field
1✔
6
from typing import Any, Iterator, Union
1✔
7

8
from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
1✔
9
from haystack.tools.tool import Tool, _check_duplicate_tool_names
1✔
10

11

12
@dataclass
1✔
13
class Toolset:
1✔
14
    """
15
    A collection of related Tools that can be used and managed as a cohesive unit.
16

17
    Toolset serves two main purposes:
18

19
    1. Group related tools together:
20
       Toolset allows you to organize related tools into a single collection, making it easier
21
       to manage and use them as a unit in Haystack pipelines.
22

23
       Example:
24
       ```python
25
       from haystack.tools import Tool, Toolset
26
       from haystack.components.tools import ToolInvoker
27

28
       # Define math functions
29
       def add_numbers(a: int, b: int) -> int:
30
           return a + b
31

32
       def subtract_numbers(a: int, b: int) -> int:
33
           return a - b
34

35
       # Create tools with proper schemas
36
       add_tool = Tool(
37
           name="add",
38
           description="Add two numbers",
39
           parameters={
40
               "type": "object",
41
               "properties": {
42
                   "a": {"type": "integer"},
43
                   "b": {"type": "integer"}
44
               },
45
               "required": ["a", "b"]
46
           },
47
           function=add_numbers
48
       )
49

50
       subtract_tool = Tool(
51
           name="subtract",
52
           description="Subtract b from a",
53
           parameters={
54
               "type": "object",
55
               "properties": {
56
                   "a": {"type": "integer"},
57
                   "b": {"type": "integer"}
58
               },
59
               "required": ["a", "b"]
60
           },
61
           function=subtract_numbers
62
       )
63

64
       # Create a toolset with the math tools
65
       math_toolset = Toolset([add_tool, subtract_tool])
66

67
       # Use the toolset with a ToolInvoker or ChatGenerator component
68
       invoker = ToolInvoker(tools=math_toolset)
69
       ```
70

71
    2. Base class for dynamic tool loading:
72
       By subclassing Toolset, you can create implementations that dynamically load tools
73
       from external sources like OpenAPI URLs, MCP servers, or other resources.
74

75
       Example:
76
       ```python
77
       from haystack.core.serialization import generate_qualified_class_name
78
       from haystack.tools import Tool, Toolset
79
       from haystack.components.tools import ToolInvoker
80

81
       class CalculatorToolset(Toolset):
82
           '''A toolset for calculator operations.'''
83

84
           def __init__(self):
85
               tools = self._create_tools()
86
               super().__init__(tools)
87

88
           def _create_tools(self):
89
               # These Tool instances are obviously defined statically and for illustration purposes only.
90
               # In a real-world scenario, you would dynamically load tools from an external source here.
91
               tools = []
92
               add_tool = Tool(
93
                   name="add",
94
                   description="Add two numbers",
95
                   parameters={
96
                       "type": "object",
97
                       "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
98
                       "required": ["a", "b"],
99
                   },
100
                   function=lambda a, b: a + b,
101
               )
102

103
               multiply_tool = Tool(
104
                   name="multiply",
105
                   description="Multiply two numbers",
106
                   parameters={
107
                       "type": "object",
108
                       "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
109
                       "required": ["a", "b"],
110
                   },
111
                   function=lambda a, b: a * b,
112
               )
113

114
               tools.append(add_tool)
115
               tools.append(multiply_tool)
116

117
               return tools
118

119
           def to_dict(self):
120
               return {
121
                   "type": generate_qualified_class_name(type(self)),
122
                   "data": {},  # no data to serialize as we define the tools dynamically
123
               }
124

125
           @classmethod
126
           def from_dict(cls, data):
127
               return cls()  # Recreate the tools dynamically during deserialization
128

129
       # Create the dynamic toolset and use it with ToolInvoker
130
       calculator_toolset = CalculatorToolset()
131
       invoker = ToolInvoker(tools=calculator_toolset)
132
       ```
133

134
    Toolset implements the collection interface (__iter__, __contains__, __len__, __getitem__),
135
    making it behave like a list of Tools. This makes it compatible with components that expect
136
    iterable tools, such as ToolInvoker or Haystack chat generators.
137

138
    When implementing a custom Toolset subclass for dynamic tool loading:
139
    - Perform the dynamic loading in the __init__ method
140
    - Override to_dict() and from_dict() methods if your tools are defined dynamically
141
    - Serialize endpoint descriptors rather than tool instances if your tools
142
      are loaded from external sources
143
    """
144

145
    # Use field() with default_factory to initialize the list
146
    tools: list[Tool] = field(default_factory=list)
1✔
147

148
    def __post_init__(self):
1✔
149
        """
150
        Validate and set up the toolset after initialization.
151

152
        This handles the case when tools are provided during initialization.
153
        """
154
        # If initialization was done a single Tool, raise an error
155
        if isinstance(self.tools, Tool):
1✔
156
            raise TypeError("A single Tool cannot be directly passed to Toolset. Please use a list: Toolset([tool])")
×
157

158
        # Check for duplicate tool names in the initial set
159
        _check_duplicate_tool_names(self.tools)
1✔
160

161
    def __iter__(self) -> Iterator[Tool]:
1✔
162
        """
163
        Return an iterator over the Tools in this Toolset.
164

165
        This allows the Toolset to be used wherever a list of Tools is expected.
166

167
        :returns: An iterator yielding Tool instances
168
        """
169
        return iter(self.tools)
1✔
170

171
    def __contains__(self, item: Any) -> bool:
1✔
172
        """
173
        Check if a tool is in this Toolset.
174

175
        Supports checking by:
176
        - Tool instance: tool in toolset
177
        - Tool name: "tool_name" in toolset
178

179
        :param item: Tool instance or tool name string
180
        :returns: True if contained, False otherwise
181
        """
182
        if isinstance(item, str):
1✔
183
            return any(tool.name == item for tool in self.tools)
1✔
184
        if isinstance(item, Tool):
1✔
185
            return item in self.tools
1✔
186
        return False
×
187

188
    def warm_up(self) -> None:
1✔
189
        """
190
        Prepare the Toolset for use.
191

192
        By default, this method iterates through and warms up all tools in the Toolset.
193
        Subclasses can override this method to customize initialization behavior, such as:
194

195
        - Setting up shared resources (database connections, HTTP sessions) instead of
196
          warming individual tools
197
        - Implementing custom initialization logic for dynamically loaded tools
198
        - Controlling when and how tools are initialized
199

200
        For example, a Toolset that manages tools from an external service (like MCPToolset)
201
        might override this to initialize a shared connection rather than warming up
202
        individual tools:
203

204
        ```python
205
        class MCPToolset(Toolset):
206
            def warm_up(self) -> None:
207
                # Only warm up the shared MCP connection, not individual tools
208
                self.mcp_connection = establish_connection(self.server_url)
209
        ```
210

211
        This method should be idempotent, as it may be called multiple times.
212
        """
213
        for tool in self.tools:
1✔
214
            if hasattr(tool, "warm_up"):
1✔
215
                tool.warm_up()
1✔
216

217
    def add(self, tool: Union[Tool, "Toolset"]) -> None:
1✔
218
        """
219
        Add a new Tool or merge another Toolset.
220

221
        :param tool: A Tool instance or another Toolset to add
222
        :raises ValueError: If adding the tool would result in duplicate tool names
223
        :raises TypeError: If the provided object is not a Tool or Toolset
224
        """
225
        new_tools = []
1✔
226

227
        if isinstance(tool, Tool):
1✔
228
            new_tools = [tool]
1✔
229
        elif isinstance(tool, Toolset):
×
230
            new_tools = list(tool)
×
231
        else:
232
            raise TypeError(f"Expected Tool or Toolset, got {type(tool).__name__}")
×
233

234
        # Check for duplicates before adding
235
        combined_tools = self.tools + new_tools
1✔
236
        _check_duplicate_tool_names(combined_tools)
1✔
237

238
        self.tools.extend(new_tools)
1✔
239

240
    def to_dict(self) -> dict[str, Any]:
1✔
241
        """
242
        Serialize the Toolset to a dictionary.
243

244
        :returns: A dictionary representation of the Toolset
245

246
        Note for subclass implementers:
247
        The default implementation is ideal for scenarios where Tool resolution is static. However, if your subclass
248
        of Toolset dynamically resolves Tool instances from external sources—such as an MCP server, OpenAPI URL, or
249
        a local OpenAPI specification—you should consider serializing the endpoint descriptor instead of the Tool
250
        instances themselves. This strategy preserves the dynamic nature of your Toolset and minimizes the overhead
251
        associated with serializing potentially large collections of Tool objects. Moreover, by serializing the
252
        descriptor, you ensure that the deserialization process can accurately reconstruct the Tool instances, even
253
        if they have been modified or removed since the last serialization. Failing to serialize the descriptor may
254
        lead to issues where outdated or incorrect Tool configurations are loaded, potentially causing errors or
255
        unexpected behavior.
256
        """
257
        return {
1✔
258
            "type": generate_qualified_class_name(type(self)),
259
            "data": {"tools": [tool.to_dict() for tool in self.tools]},
260
        }
261

262
    @classmethod
1✔
263
    def from_dict(cls, data: dict[str, Any]) -> "Toolset":
1✔
264
        """
265
        Deserialize a Toolset from a dictionary.
266

267
        :param data: Dictionary representation of the Toolset
268
        :returns: A new Toolset instance
269
        """
270
        inner_data = data["data"]
1✔
271
        tools_data = inner_data.get("tools", [])
1✔
272

273
        tools = []
1✔
274
        for tool_data in tools_data:
1✔
275
            tool_class = import_class_by_name(tool_data["type"])
1✔
276
            if not issubclass(tool_class, Tool):
1✔
277
                raise TypeError(f"Class '{tool_class}' is not a subclass of Tool")
×
278
            tools.append(tool_class.from_dict(tool_data))
1✔
279

280
        return cls(tools=tools)
1✔
281

282
    def __add__(self, other: Union[Tool, "Toolset", list[Tool]]) -> "Toolset":
1✔
283
        """
284
        Concatenate this Toolset with another Tool, Toolset, or list of Tools.
285

286
        :param other: Another Tool, Toolset, or list of Tools to concatenate
287
        :returns: A new Toolset containing all tools
288
        :raises TypeError: If the other parameter is not a Tool, Toolset, or list of Tools
289
        :raises ValueError: If the combination would result in duplicate tool names
290
        """
291
        if isinstance(other, Tool):
1✔
292
            return Toolset(tools=self.tools + [other])
1✔
293
        if isinstance(other, Toolset):
1✔
294
            return _ToolsetWrapper([self, other])
1✔
295
        if isinstance(other, list) and all(isinstance(item, Tool) for item in other):
1✔
296
            return Toolset(tools=self.tools + other)
1✔
297
        raise TypeError(f"Cannot add {type(other).__name__} to Toolset")
1✔
298

299
    def __len__(self) -> int:
1✔
300
        """
301
        Return the number of Tools in this Toolset.
302

303
        :returns: Number of Tools
304
        """
305
        return len(self.tools)
1✔
306

307
    def __getitem__(self, index):
1✔
308
        """
309
        Get a Tool by index.
310

311
        :param index: Index of the Tool to get
312
        :returns: The Tool at the specified index
313
        """
314
        return self.tools[index]
1✔
315

316

317
class _ToolsetWrapper(Toolset):
1✔
318
    """
319
    A wrapper that holds multiple toolsets and provides a unified interface.
320

321
    This is used internally when combining different types of toolsets to preserve
322
    their individual configurations while still being usable with ToolInvoker.
323
    """
324

325
    def __init__(self, toolsets: list[Toolset]):
1✔
326
        super().__init__([tool for toolset in toolsets for tool in toolset])
1✔
327
        self.toolsets = toolsets
1✔
328

329
    def __iter__(self):
1✔
330
        """Iterate over all tools from all toolsets."""
331
        for toolset in self.toolsets:
1✔
332
            yield from toolset
1✔
333

334
    def __contains__(self, item):
1✔
335
        """Check if a tool is in any of the toolsets."""
336
        return any(item in toolset for toolset in self.toolsets)
1✔
337

338
    def warm_up(self):
1✔
339
        """Warm up all toolsets."""
340
        for toolset in self.toolsets:
1✔
341
            toolset.warm_up()
1✔
342

343
    def __len__(self):
1✔
344
        """Return total number of tools across all toolsets."""
345
        return sum(len(toolset) for toolset in self.toolsets)
1✔
346

347
    def __getitem__(self, index):
1✔
348
        """Get a tool by index across all toolsets."""
349
        # Leverage iteration instead of manual index tracking
350
        for i, tool in enumerate(self):
×
351
            if i == index:
×
352
                return tool
×
353
        raise IndexError("ToolsetWrapper index out of range")
×
354

355
    def __add__(self, other):
1✔
356
        """Add another toolset or tool to this wrapper."""
357
        if isinstance(other, Toolset):
1✔
358
            return _ToolsetWrapper(self.toolsets + [other])
1✔
359
        if isinstance(other, Tool):
1✔
360
            return _ToolsetWrapper(self.toolsets + [Toolset([other])])
1✔
361
        if isinstance(other, list) and all(isinstance(item, Tool) for item in other):
×
362
            return _ToolsetWrapper(self.toolsets + [Toolset(other)])
×
363
        raise TypeError(f"Cannot add {type(other).__name__} to ToolsetWrapper")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc