Coverage for haystack/tools/component_tool.py: 95%
100 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5from collections.abc import Callable
6from typing import Any, get_args, get_origin
8from pydantic import Field, TypeAdapter, create_model
10from haystack import logging
11from haystack.components.agents.state.state import State
12from haystack.core.component import Component
13from haystack.core.serialization import component_to_dict, generate_qualified_class_name
14from haystack.tools import Tool
15from haystack.tools.errors import SchemaGenerationError
16from haystack.tools.from_function import _remove_title_from_schema
17from haystack.tools.parameters_schema_utils import (
18 _contains_callable_type,
19 _get_component_param_descriptions,
20 _resolve_type,
21 _unwrap_optional,
22)
23from haystack.tools.tool import (
24 _deserialize_outputs_to_state,
25 _deserialize_outputs_to_string,
26 _serialize_outputs_to_state,
27 _serialize_outputs_to_string,
28)
29from haystack.utils.deserialization import deserialize_component_inplace
30from haystack.utils.type_serialization import _is_union_type
32logger = logging.getLogger(__name__)
35class ComponentTool(Tool):
36 """
37 A Tool that wraps Haystack components, allowing them to be used as tools by LLMs.
39 ComponentTool automatically generates LLM-compatible tool schemas from component input sockets,
40 which are derived from the component's `run` method signature and type hints.
43 Key features:
44 - Automatic LLM tool calling schema generation from component input sockets
45 - Type conversion and validation for component inputs
46 - Support for types:
47 - Dataclasses
48 - Lists of dataclasses
49 - Basic types (str, int, float, bool, dict)
50 - Lists of basic types
51 - Automatic name generation from component class name
52 - Description extraction from component docstrings
54 To use ComponentTool, you first need a Haystack component - either an existing one or a new one you create.
55 You can create a ComponentTool from the component by passing the component to the ComponentTool constructor.
56 Below is an example of creating a ComponentTool from an existing SerperDevWebSearch component
57 from the `serperdev-haystack` integration package (`pip install serperdev-haystack`).
59 ## Usage Example:
60 <!-- test-ignore -->
61 ```python
62 from haystack import component
63 from haystack.tools import ComponentTool
64 from haystack.utils import Secret
65 from haystack.components.agents import Agent
66 from haystack.components.generators.chat import OpenAIChatGenerator
67 from haystack.dataclasses import ChatMessage
68 from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
70 # Create a SerperDev search component
71 search = SerperDevWebSearch(api_key=Secret.from_env_var("SERPERDEV_API_KEY"), top_k=3)
73 # Create a tool from the component
74 tool = ComponentTool(
75 component=search,
76 name="web_search", # Optional: defaults to "serper_dev_web_search"
77 description="Search the web for current information on any topic" # Optional: defaults to component docstring
78 )
80 # Create an Agent with an OpenAIChatGenerator and the tool
81 agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[tool])
83 message = ChatMessage.from_user("Use the web search tool to find information about Nikola Tesla")
85 # Run the Agent
86 result = agent.run(messages=[message])
88 print(result)
89 ```
91 """
93 def __init__(
94 self,
95 component: Component,
96 name: str | None = None,
97 description: str | None = None,
98 parameters: dict[str, Any] | None = None,
99 *,
100 outputs_to_string: dict[str, str | Callable[[Any], str]] | None = None,
101 inputs_from_state: dict[str, str] | None = None,
102 outputs_to_state: dict[str, dict[str, str | Callable]] | None = None,
103 ) -> None:
104 """
105 Create a Tool instance from a Haystack component.
107 :param component: The Haystack component to wrap as a tool.
108 :param name: Optional name for the tool (defaults to snake_case of component class name).
109 :param description: Optional description (defaults to component's docstring).
110 :param parameters:
111 A JSON schema defining the parameters expected by the Tool.
112 Will fall back to the parameters defined in the component's run method signature if not provided.
113 :param outputs_to_string:
114 Optional dictionary defining how tool outputs should be converted into string(s) or results.
115 If not provided, the tool result is converted to a string using a default handler.
117 `outputs_to_string` supports two formats:
119 1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
120 ```python
121 {
122 "source": "docs", "handler": format_documents, "raw_result": False
123 }
124 ```
125 - `source`: If provided, only the specified output key is sent to the handler.
126 - `handler`: A function that takes the tool output (or the extracted source value) and returns the
127 final result.
128 - `raw_result`: If `True`, the result is returned raw without string conversion, but applying the
129 `handler` if provided. This is intended for tools that return images. In this mode, the Tool
130 function or the `handler` function must return a list of `TextContent`/`ImageContent` objects to
131 ensure compatibility with Chat Generators.
133 2. Multiple output format - map keys to individual configurations:
134 ```python
135 {
136 "formatted_docs": {"source": "docs", "handler": format_documents},
137 "summary": {"source": "summary_text", "handler": str.upper}
138 }
139 ```
140 Each key maps to a dictionary that can contain "source" and/or "handler".
141 Note that `raw_result` is not supported in the multiple output format.
142 :param inputs_from_state:
143 Optional dictionary mapping state keys to tool parameter names.
144 Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
145 :param outputs_to_state:
146 Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
147 If the source is provided only the specified output key is sent to the handler.
148 Example:
149 ```python
150 {
151 "documents": {"source": "docs", "handler": custom_handler}
152 }
153 ```
154 If the source is omitted the whole tool result is sent to the handler.
155 Example:
156 ```python
157 {
158 "documents": {"handler": custom_handler}
159 }
160 ```
161 :raises TypeError: If the object passed is not a Haystack Component instance.
162 :raises ValueError: If the component has already been added to a pipeline, or if schema generation fails.
163 """
164 if not isinstance(component, Component):
165 message = (
166 f"Object {component!r} is not a Haystack component. "
167 "Use ComponentTool only with Haystack component instances."
168 )
169 raise TypeError(message)
171 if getattr(component, "__haystack_added_to_pipeline__", None):
172 msg = (
173 "Component has been added to a pipeline and can't be used to create a ComponentTool. "
174 "Create ComponentTool from a non-pipeline component instead."
175 )
176 raise ValueError(msg)
178 self._unresolved_parameters = parameters
179 # Create the tools schema from the component run method parameters
180 tool_schema = parameters or self._create_tool_parameters_schema(component, inputs_from_state or {})
182 def component_invoker(**kwargs: Any) -> dict[str, Any]:
183 """
184 Invokes the component using keyword arguments provided by the LLM function calling/tool-generated response.
186 :param kwargs: The keyword arguments to invoke the component with.
187 :returns: The result of the component invocation.
188 """
189 input_sockets = component.__haystack_input__._sockets_dict # type: ignore[attr-defined]
190 converted_kwargs = {
191 param_name: self._convert_param(param_value, input_sockets[param_name].type)
192 for param_name, param_value in kwargs.items()
193 }
194 logger.debug(
195 "Invoking component {component_type} with kwargs: {converted_kwargs}",
196 component_type=type(component),
197 converted_kwargs=converted_kwargs,
198 )
199 return dict(component.run(**converted_kwargs))
201 async def async_component_invoker(**kwargs: Any) -> dict[str, Any]:
202 """
203 Asynchronous counterpart of `component_invoker`. Awaits the component's `run_async`.
205 :param kwargs: The keyword arguments to invoke the component with.
206 :returns: The result of the component invocation.
207 """
208 input_sockets = component.__haystack_input__._sockets_dict # type: ignore[attr-defined]
209 converted_kwargs = {
210 param_name: self._convert_param(param_value, input_sockets[param_name].type)
211 for param_name, param_value in kwargs.items()
212 }
213 logger.debug(
214 "Invoking component {component_type} asynchronously with kwargs: {converted_kwargs}",
215 component_type=type(component),
216 converted_kwargs=converted_kwargs,
217 )
218 # We know run_async exists at this point b/c we only pass the async invoker if the component has
219 # __haystack_supports_async__ = True
220 return dict(await component.run_async(**converted_kwargs)) # type: ignore[attr-defined]
222 component_supports_async = getattr(component, "__haystack_supports_async__", False)
224 # Generate a name for the tool if not provided
225 if not name:
226 class_name = component.__class__.__name__
227 # Convert camelCase/PascalCase to snake_case
228 name = "".join(
229 [
230 "_" + c.lower() if c.isupper() and i > 0 and not class_name[i - 1].isupper() else c.lower()
231 for i, c in enumerate(class_name)
232 ]
233 ).lstrip("_")
235 description = description or component.__doc__ or name
237 # Store component before calling super().__init__() so _get_valid_outputs() can access it
238 self._component = component
239 self._is_warmed_up = False
241 # Create the Tool instance with the component invoker as the function to be called and the schema.
242 # When the wrapped component exposes a `run_async`, also pass the async invoker.
243 super().__init__(
244 name=name,
245 description=description,
246 parameters=tool_schema,
247 function=component_invoker,
248 async_function=async_component_invoker if component_supports_async else None,
249 inputs_from_state=inputs_from_state,
250 outputs_to_state=outputs_to_state,
251 outputs_to_string=outputs_to_string,
252 )
254 def _get_valid_inputs(self) -> set[str]:
255 """
256 Return valid input parameter names from the component's input sockets.
258 Used to validate `inputs_from_state` against the component's actual inputs.
259 This ensures users don't reference non-existent component inputs.
261 :returns: Set of component input socket names.
262 """
263 return set(self._component.__haystack_input__._sockets_dict.keys()) # type: ignore[attr-defined]
265 def _get_valid_outputs(self) -> set[str]:
266 """
267 Return valid output names from the component's output sockets.
269 Used to validate `outputs_to_state` against the component's actual outputs.
270 This ensures users don't reference non-existent component outputs.
272 :returns: Set of component output socket names.
273 """
274 return set(self._component.__haystack_output__._sockets_dict.keys()) # type: ignore[attr-defined]
276 def warm_up(self) -> None:
277 """
278 Prepare the ComponentTool for use.
279 """
280 if not self._is_warmed_up:
281 if hasattr(self._component, "warm_up"):
282 self._component.warm_up()
283 self._is_warmed_up = True
285 def to_dict(self) -> dict[str, Any]:
286 """
287 Serializes the ComponentTool to a dictionary.
288 """
289 serialized: dict[str, Any] = {
290 "component": component_to_dict(obj=self._component, name=self.name),
291 "name": self.name,
292 "description": self.description,
293 "parameters": self._unresolved_parameters,
294 "inputs_from_state": self.inputs_from_state,
295 "outputs_to_state": _serialize_outputs_to_state(self.outputs_to_state) if self.outputs_to_state else None,
296 "outputs_to_string": _serialize_outputs_to_string(self.outputs_to_string)
297 if self.outputs_to_string
298 else None,
299 }
301 return {"type": generate_qualified_class_name(type(self)), "data": serialized}
303 @classmethod
304 def from_dict(cls, data: dict[str, Any]) -> "ComponentTool":
305 """
306 Deserializes the ComponentTool from a dictionary.
307 """
308 inner_data = data["data"]
309 deserialize_component_inplace(data=inner_data, key="component")
311 if "outputs_to_state" in inner_data and inner_data["outputs_to_state"]:
312 inner_data["outputs_to_state"] = _deserialize_outputs_to_state(inner_data["outputs_to_state"])
314 if inner_data.get("outputs_to_string") is not None:
315 inner_data["outputs_to_string"] = _deserialize_outputs_to_string(inner_data["outputs_to_string"])
317 return cls(
318 component=inner_data["component"],
319 name=inner_data["name"],
320 description=inner_data["description"],
321 parameters=inner_data.get("parameters", None),
322 outputs_to_string=inner_data.get("outputs_to_string", None),
323 inputs_from_state=inner_data.get("inputs_from_state", None),
324 outputs_to_state=inner_data.get("outputs_to_state", None),
325 )
327 def _create_tool_parameters_schema(self, component: Component, inputs_from_state: dict[str, Any]) -> dict[str, Any]:
328 """
329 Creates an OpenAI tools schema from a component's run method parameters.
331 :param component: The component to create the schema from.
332 :raises SchemaGenerationError: If schema generation fails
333 :returns: OpenAI tools schema for the component's run method parameters.
334 """
335 param_descriptions = _get_component_param_descriptions(component)
337 # collect fields (types and defaults) and descriptions from function parameters
338 fields: dict[str, Any] = {}
340 for input_name, socket in component.__haystack_input__._sockets_dict.items(): # type: ignore[attr-defined]
341 if inputs_from_state is not None and input_name in list(inputs_from_state.values()):
342 continue
343 input_type = socket.type
345 # Skip Callable types since Pydantic cannot generate JSON schemas for them
346 if _contains_callable_type(input_type):
347 continue
349 # Skip State-typed parameters - Agent tool execution injects them at runtime
350 if _unwrap_optional(input_type) is State:
351 continue
353 description = param_descriptions.get(input_name, f"Input '{input_name}' for the component.")
355 # if the parameter has not a default value, Pydantic requires an Ellipsis (...)
356 # to explicitly indicate that the parameter is required
357 default = ... if socket.is_mandatory else socket.default_value
358 resolved_type = _resolve_type(input_type)
359 fields[input_name] = (resolved_type, Field(default=default, description=description))
361 parameters_schema: dict[str, Any] = {}
362 try:
363 # No `__doc__`: it would surface as a top-level `description` on the parameters schema,
364 # which LLM providers ignore. The component description feeds the tool-level description.
365 model = create_model(component.run.__name__, **fields)
366 parameters_schema = model.model_json_schema()
367 except Exception as e:
368 raise SchemaGenerationError(
369 f"Failed to create JSON schema for the run method of Component '{component.__class__.__name__}'"
370 ) from e
372 # we don't want to include title keywords in the schema, as they contain redundant information
373 # there is no programmatic way to prevent Pydantic from adding them, so we remove them later
374 # see https://github.com/pydantic/pydantic/discussions/8504
375 _remove_title_from_schema(parameters_schema)
377 return parameters_schema
379 def _convert_param(self, param_value: Any, param_type: type) -> Any:
380 """
381 Converts a single parameter value to the expected type.
383 :param param_value: The value to convert.
384 :param param_type: The expected type of the parameter.
386 :returns:
387 The converted parameter value.
388 """
389 # We unwrap optional types so we can support types like messages: list[ChatMessage] | None
390 unwrapped_param_type = _unwrap_optional(param_type)
392 # We handle union types (e.g. list[ChatMessage] | str) by extracting the list[T] arm that has
393 # from_dict, so the conversion below works the same as for plain list[T]. Other arms like str
394 # need no special handling and fall through to Pydantic or the plain return at the end.
395 if _is_union_type(unwrapped_param_type):
396 list_arms = [
397 a
398 for a in get_args(unwrapped_param_type)
399 if get_origin(a) is list and get_args(a) and hasattr(get_args(a)[0], "from_dict")
400 ]
401 unwrapped_param_type = list_arms[0] if list_arms else unwrapped_param_type
403 # We support calling from_dict on target types that have it, even if they are wrapped in a list.
404 # This allows us to support lists of dataclasses as well as single dataclass inputs.
405 target_type = (
406 get_args(unwrapped_param_type)[0] if get_origin(unwrapped_param_type) is list else unwrapped_param_type
407 )
408 if hasattr(target_type, "from_dict"):
409 if isinstance(param_value, list):
410 return [target_type.from_dict(item) if isinstance(item, dict) else item for item in param_value]
411 if isinstance(param_value, dict):
412 return target_type.from_dict(param_value)
413 return param_value
415 # Use the original type for pydantic validation
416 return TypeAdapter(param_type).validate_python(param_value)