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

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5from collections.abc import Callable 

6from typing import Any, get_args, get_origin 

7 

8from pydantic import Field, TypeAdapter, create_model 

9 

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 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class ComponentTool(Tool): 

36 """ 

37 A Tool that wraps Haystack components, allowing them to be used as tools by LLMs. 

38 

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. 

41 

42 

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 

53 

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`). 

58 

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 

69 

70 # Create a SerperDev search component 

71 search = SerperDevWebSearch(api_key=Secret.from_env_var("SERPERDEV_API_KEY"), top_k=3) 

72 

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 ) 

79 

80 # Create an Agent with an OpenAIChatGenerator and the tool 

81 agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]) 

82 

83 message = ChatMessage.from_user("Use the web search tool to find information about Nikola Tesla") 

84 

85 # Run the Agent 

86 result = agent.run(messages=[message]) 

87 

88 print(result) 

89 ``` 

90 

91 """ 

92 

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. 

106 

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. 

116 

117 `outputs_to_string` supports two formats: 

118 

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. 

132 

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) 

170 

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) 

177 

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 {}) 

181 

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. 

185 

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)) 

200 

201 async def async_component_invoker(**kwargs: Any) -> dict[str, Any]: 

202 """ 

203 Asynchronous counterpart of `component_invoker`. Awaits the component's `run_async`. 

204 

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] 

221 

222 component_supports_async = getattr(component, "__haystack_supports_async__", False) 

223 

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("_") 

234 

235 description = description or component.__doc__ or name 

236 

237 # Store component before calling super().__init__() so _get_valid_outputs() can access it 

238 self._component = component 

239 self._is_warmed_up = False 

240 

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 ) 

253 

254 def _get_valid_inputs(self) -> set[str]: 

255 """ 

256 Return valid input parameter names from the component's input sockets. 

257 

258 Used to validate `inputs_from_state` against the component's actual inputs. 

259 This ensures users don't reference non-existent component inputs. 

260 

261 :returns: Set of component input socket names. 

262 """ 

263 return set(self._component.__haystack_input__._sockets_dict.keys()) # type: ignore[attr-defined] 

264 

265 def _get_valid_outputs(self) -> set[str]: 

266 """ 

267 Return valid output names from the component's output sockets. 

268 

269 Used to validate `outputs_to_state` against the component's actual outputs. 

270 This ensures users don't reference non-existent component outputs. 

271 

272 :returns: Set of component output socket names. 

273 """ 

274 return set(self._component.__haystack_output__._sockets_dict.keys()) # type: ignore[attr-defined] 

275 

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 

284 

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 } 

300 

301 return {"type": generate_qualified_class_name(type(self)), "data": serialized} 

302 

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") 

310 

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"]) 

313 

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"]) 

316 

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 ) 

326 

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. 

330 

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) 

336 

337 # collect fields (types and defaults) and descriptions from function parameters 

338 fields: dict[str, Any] = {} 

339 

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 

344 

345 # Skip Callable types since Pydantic cannot generate JSON schemas for them 

346 if _contains_callable_type(input_type): 

347 continue 

348 

349 # Skip State-typed parameters - Agent tool execution injects them at runtime 

350 if _unwrap_optional(input_type) is State: 

351 continue 

352 

353 description = param_descriptions.get(input_name, f"Input '{input_name}' for the component.") 

354 

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)) 

360 

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 

371 

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) 

376 

377 return parameters_schema 

378 

379 def _convert_param(self, param_value: Any, param_type: type) -> Any: 

380 """ 

381 Converts a single parameter value to the expected type. 

382 

383 :param param_value: The value to convert. 

384 :param param_type: The expected type of the parameter. 

385 

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) 

391 

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 

402 

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 

414 

415 # Use the original type for pydantic validation 

416 return TypeAdapter(param_type).validate_python(param_value)