Coverage for haystack/tools/from_function.py: 100%

61 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 

5import inspect 

6from collections.abc import Callable 

7from typing import Any, overload 

8 

9from pydantic import create_model 

10 

11from haystack.components.agents.state.state import State 

12 

13from .errors import SchemaGenerationError 

14from .parameters_schema_utils import _contains_callable_type, _unwrap_optional 

15from .tool import Tool 

16 

17 

18def create_tool_from_function( 

19 function: Callable, 

20 name: str | None = None, 

21 description: str | None = None, 

22 inputs_from_state: dict[str, str] | None = None, 

23 outputs_to_state: dict[str, dict[str, Any]] | None = None, 

24 outputs_to_string: dict[str, Any] | None = None, 

25) -> "Tool": 

26 """ 

27 Create a Tool instance from a function. 

28 

29 Allows customizing the Tool name and description. 

30 For simpler use cases, consider using the `@tool` decorator. 

31 

32 ### Usage example 

33 

34 ```python 

35 from typing import Annotated, Literal 

36 from haystack.tools import create_tool_from_function 

37 

38 def get_weather( 

39 city: Annotated[str, "the city for which to get the weather"] = "Munich", 

40 unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius"): 

41 '''A simple function to get the current weather for a location.''' 

42 return f"Weather report for {city}: 20 {unit}, sunny" 

43 

44 tool = create_tool_from_function(get_weather) 

45 

46 print(tool) 

47 # >> Tool(name='get_weather', description='A simple function to get the current weather for a location.', 

48 # >> parameters={ 

49 # >> 'type': 'object', 

50 # >> 'properties': { 

51 # >> 'city': {'type': 'string', 'description': 'the city for which to get the weather', 'default': 'Munich'}, 

52 # >> 'unit': { 

53 # >> 'type': 'string', 

54 # >> 'enum': ['Celsius', 'Fahrenheit'], 

55 # >> 'description': 'the unit for the temperature', 

56 # >> 'default': 'Celsius', 

57 # >> }, 

58 # >> } 

59 # >> }, 

60 # >> function=<function get_weather at 0x7f7b3a8a9b80>) 

61 ``` 

62 

63 :param function: 

64 The function to be converted into a Tool. May be either a regular function (assigned to the 

65 resulting Tool's `function` field) or a coroutine function defined with `async def` (assigned 

66 to `async_function`). 

67 The function must include type hints for all parameters. 

68 The function is expected to have basic python input types (str, int, float, bool, list, dict, tuple). 

69 Other input types may work but are not guaranteed. 

70 If a parameter is annotated using `typing.Annotated`, its metadata will be used as parameter description. 

71 :param name: 

72 The name of the Tool. If not provided, the name of the function will be used. 

73 :param description: 

74 The description of the Tool. If not provided, the docstring of the function will be used. 

75 To intentionally leave the description empty, pass an empty string. 

76 :param inputs_from_state: 

77 Optional dictionary mapping state keys to tool parameter names. 

78 Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter. 

79 :param outputs_to_state: 

80 Optional dictionary defining how tool outputs map to keys within state as well as optional handlers. 

81 If the source is provided only the specified output key is sent to the handler. 

82 Example: 

83 ```python 

84 { 

85 "documents": {"source": "docs", "handler": custom_handler} 

86 } 

87 ``` 

88 If the source is omitted the whole tool result is sent to the handler. 

89 Example: 

90 ```python 

91 { 

92 "documents": {"handler": custom_handler} 

93 } 

94 ``` 

95 :param outputs_to_string: 

96 Optional dictionary defining how tool outputs should be converted into string(s) or results. 

97 If not provided, the tool result is converted to a string using a default handler. 

98 

99 `outputs_to_string` supports two formats: 

100 

101 1. Single output format - use "source", "handler", and/or "raw_result" at the root level: 

102 ```python 

103 { 

104 "source": "docs", "handler": format_documents, "raw_result": False 

105 } 

106 ``` 

107 - `source`: If provided, only the specified output key is sent to the handler. If not provided, the whole 

108 tool result is sent to the handler. 

109 - `handler`: A function that takes the tool output (or the extracted source value) and returns the 

110 final result. 

111 - `raw_result`: If `True`, the result is returned raw without string conversion, but applying the `handler` 

112 if provided. This is intended for tools that return images. In this mode, the Tool function or the 

113 `handler` must return a list of `TextContent`/`ImageContent` objects to ensure compatibility with Chat 

114 Generators. 

115 

116 2. Multiple output format - map keys to individual configurations: 

117 ```python 

118 { 

119 "formatted_docs": {"source": "docs", "handler": format_documents}, 

120 "summary": {"source": "summary_text", "handler": str.upper} 

121 } 

122 ``` 

123 Each key maps to a dictionary that can contain "source" and/or "handler". 

124 Note that `raw_result` is not supported in the multiple output format. 

125 :returns: 

126 The Tool created from the function. 

127 

128 :raises ValueError: 

129 If any parameter of the function lacks a type hint. 

130 :raises SchemaGenerationError: 

131 If there is an error generating the JSON schema for the Tool. 

132 """ 

133 tool_description = description if description is not None else (function.__doc__ or "") 

134 

135 signature = inspect.signature(function) 

136 

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

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

139 descriptions = {} 

140 

141 for param_name, param in signature.parameters.items(): 

142 # Skip adding parameter names that will be passed to the tool from State 

143 if inputs_from_state and param_name in inputs_from_state.values(): 

144 continue 

145 

146 # Skip State-typed parameters (including Optional[State]) - Agent tool execution injects them at runtime 

147 if _unwrap_optional(param.annotation) is State: 

148 continue 

149 

150 if param.annotation is param.empty: 

151 raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.") 

152 

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

154 if _contains_callable_type(param.annotation): 

155 continue 

156 

157 # if the parameter has not a default value, Pydantic requires an Ellipsis (...) 

158 # to explicitly indicate that the parameter is required 

159 default = param.default if param.default is not param.empty else ... 

160 fields[param_name] = (param.annotation, default) 

161 

162 if hasattr(param.annotation, "__metadata__"): 

163 descriptions[param_name] = param.annotation.__metadata__[0] 

164 

165 # create Pydantic model and generate JSON schema 

166 try: 

167 model = create_model(function.__name__, **fields) 

168 schema = model.model_json_schema() 

169 except Exception as e: 

170 raise SchemaGenerationError(f"Failed to create JSON schema for function '{function.__name__}'") from e 

171 

172 # we don't want to include title keywords in the schema, as they contain redundant information 

173 # there is no programmatic way to prevent Pydantic from adding them, so we remove them later 

174 # see https://github.com/pydantic/pydantic/discussions/8504 

175 _remove_title_from_schema(schema) 

176 

177 # add parameters descriptions to the schema 

178 for param_name, param_description in descriptions.items(): 

179 if param_name in schema["properties"]: 

180 schema["properties"][param_name]["description"] = param_description 

181 

182 is_async = inspect.iscoroutinefunction(function) 

183 

184 return Tool( 

185 name=name or function.__name__, 

186 description=tool_description, 

187 parameters=schema, 

188 function=None if is_async else function, 

189 async_function=function if is_async else None, 

190 inputs_from_state=inputs_from_state, 

191 outputs_to_state=outputs_to_state, 

192 outputs_to_string=outputs_to_string, 

193 ) 

194 

195 

196@overload 

197def tool( 

198 function: Callable, 

199 *, 

200 name: str | None = None, 

201 description: str | None = None, 

202 inputs_from_state: dict[str, str] | None = None, 

203 outputs_to_state: dict[str, dict[str, Any]] | None = None, 

204 outputs_to_string: dict[str, Any] | None = None, 

205) -> Tool: ... 

206 

207 

208@overload 

209def tool( 

210 function: None = None, 

211 *, 

212 name: str | None = None, 

213 description: str | None = None, 

214 inputs_from_state: dict[str, str] | None = None, 

215 outputs_to_state: dict[str, dict[str, Any]] | None = None, 

216 outputs_to_string: dict[str, Any] | None = None, 

217) -> Callable[[Callable], Tool]: ... 

218 

219 

220def tool( 

221 function: Callable | None = None, 

222 *, 

223 name: str | None = None, 

224 description: str | None = None, 

225 inputs_from_state: dict[str, str] | None = None, 

226 outputs_to_state: dict[str, dict[str, Any]] | None = None, 

227 outputs_to_string: dict[str, Any] | None = None, 

228) -> Tool | Callable[[Callable], Tool]: 

229 """ 

230 Decorator to convert a function into a Tool. 

231 

232 Can be used with or without parameters: 

233 @tool # without parameters 

234 def my_function(): ... 

235 

236 @tool(name="custom_name") # with parameters 

237 def my_function(): ... 

238 

239 ### Usage example 

240 ```python 

241 from typing import Annotated, Literal 

242 from haystack.tools import tool 

243 

244 @tool 

245 def get_weather( 

246 city: Annotated[str, "the city for which to get the weather"] = "Munich", 

247 unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius"): 

248 '''A simple function to get the current weather for a location.''' 

249 return f"Weather report for {city}: 20 {unit}, sunny" 

250 

251 print(get_weather) 

252 # >> Tool(name='get_weather', description='A simple function to get the current weather for a location.', 

253 # >> parameters={ 

254 # >> 'type': 'object', 

255 # >> 'properties': { 

256 # >> 'city': {'type': 'string', 'description': 'the city for which to get the weather', 'default': 'Munich'}, 

257 # >> 'unit': { 

258 # >> 'type': 'string', 

259 # >> 'enum': ['Celsius', 'Fahrenheit'], 

260 # >> 'description': 'the unit for the temperature', 

261 # >> 'default': 'Celsius', 

262 # >> }, 

263 # >> } 

264 # >> }, 

265 # >> function=<function get_weather at 0x7f7b3a8a9b80>) 

266 ``` 

267 

268 :param function: The function to decorate (when used without parameters) 

269 :param name: Optional custom name for the tool 

270 :param description: Optional custom description 

271 :param inputs_from_state: 

272 Optional dictionary mapping state keys to tool parameter names. 

273 Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter. 

274 :param outputs_to_state: 

275 Optional dictionary defining how tool outputs map to keys within state as well as optional handlers. 

276 If the source is provided only the specified output key is sent to the handler. 

277 Example: 

278 ```python 

279 { 

280 "documents": {"source": "docs", "handler": custom_handler} 

281 } 

282 ``` 

283 If the source is omitted the whole tool result is sent to the handler. 

284 Example: 

285 ```python 

286 { 

287 "documents": {"handler": custom_handler} 

288 } 

289 ``` 

290 :param outputs_to_string: 

291 Optional dictionary defining how tool outputs should be converted into string(s) or results. 

292 If not provided, the tool result is converted to a string using a default handler. 

293 

294 `outputs_to_string` supports two formats: 

295 

296 1. Single output format - use "source", "handler", and/or "raw_result" at the root level: 

297 ```python 

298 { 

299 "source": "docs", "handler": format_documents, "raw_result": False 

300 } 

301 ``` 

302 - `source`: If provided, only the specified output key is sent to the handler. If not provided, the whole 

303 tool result is sent to the handler. 

304 - `handler`: A function that takes the tool output (or the extracted source value) and returns the 

305 final result. 

306 - `raw_result`: If `True`, the result is returned raw without string conversion, but applying the `handler` 

307 if provided. This is intended for tools that return images. In this mode, the Tool function or the 

308 `handler` must return a list of `TextContent`/`ImageContent` objects to ensure compatibility with Chat 

309 Generators. 

310 

311 2. Multiple output format - map keys to individual configurations: 

312 ```python 

313 { 

314 "formatted_docs": {"source": "docs", "handler": format_documents}, 

315 "summary": {"source": "summary_text", "handler": str.upper} 

316 } 

317 ``` 

318 Each key maps to a dictionary that can contain "source" and/or "handler". 

319 Note that `raw_result` is not supported in the multiple output format. 

320 

321 :returns: Either a Tool instance or a decorator function that will create one 

322 """ 

323 

324 def decorator(func: Callable) -> Tool: 

325 return create_tool_from_function( 

326 function=func, 

327 name=name, 

328 description=description, 

329 inputs_from_state=inputs_from_state, 

330 outputs_to_state=outputs_to_state, 

331 outputs_to_string=outputs_to_string, 

332 ) 

333 

334 if function is None: 

335 return decorator 

336 return decorator(function) 

337 

338 

339# Keywords whose value is a mapping keyed by *names chosen by the user* — property 

340# names, definition names, regexes — rather than by JSON Schema keywords. Their keys 

341# must survive even when they spell 'title', so we recurse into the values only. 

342# Deleting a key here would drop a declared property or leave a '$ref' dangling. 

343_NAME_KEYED_SCHEMA_MAPS = frozenset( 

344 {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependentRequired"} 

345) 

346 

347# Keywords whose value is instance *data*, not a sub-schema. A 'title' key inside a 

348# default value is part of that value, so removing it would change the tool's contract. 

349_DATA_SCHEMA_KEYWORDS = frozenset({"default", "const", "enum", "examples", "example"}) 

350 

351 

352def _remove_title_from_schema(schema: dict[str, Any]) -> None: 

353 """ 

354 Remove the 'title' keyword from JSON schema and contained property schemas. 

355 

356 :param schema: 

357 The JSON schema to remove the 'title' keyword from. 

358 """ 

359 for key, value in list(schema.items()): 

360 # Keys of a name-keyed mapping are property or definition names, not schema 

361 # keywords. Recurse only into the sub-schemas so that parameters named 

362 # 'title' (or any other keyword, e.g. 'properties') are never removed or 

363 # misinterpreted as schema keywords. 

364 if key in _NAME_KEYED_SCHEMA_MAPS and isinstance(value, dict): 

365 for sub_schema in value.values(): 

366 if isinstance(sub_schema, dict): 

367 _remove_title_from_schema(sub_schema) 

368 elif key in _DATA_SCHEMA_KEYWORDS: 

369 continue 

370 elif key == "title": 

371 del schema[key] 

372 elif isinstance(value, dict): 

373 _remove_title_from_schema(value) 

374 elif isinstance(value, list): 

375 for item in value: 

376 if isinstance(item, dict): 

377 _remove_title_from_schema(item)