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

63 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +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.core.type_utils import _resolve_parameter_types 

12 

13from .errors import SchemaGenerationError 

14from .parameters_schema_utils import _contains_callable_type, _is_state_annotation 

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 # resolve postponed annotations (`from __future__ import annotations`), keeping `Annotated` descriptions 

137 param_types = _resolve_parameter_types(function, include_extras=True) 

138 

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

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

141 descriptions = {} 

142 

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

144 annotation = param_types[param_name] 

145 

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

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

148 continue 

149 

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

151 if _is_state_annotation(annotation): 

152 continue 

153 

154 if annotation is param.empty: 

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

156 

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

158 if _contains_callable_type(annotation): 

159 continue 

160 

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

162 # to explicitly indicate that the parameter is required 

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

164 fields[param_name] = (annotation, default) 

165 

166 if hasattr(annotation, "__metadata__"): 

167 descriptions[param_name] = annotation.__metadata__[0] 

168 

169 # create Pydantic model and generate JSON schema 

170 try: 

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

172 schema = model.model_json_schema() 

173 except Exception as e: 

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

175 

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

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

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

179 _remove_title_from_schema(schema) 

180 

181 # add parameters descriptions to the schema 

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

183 if param_name in schema["properties"]: 

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

185 

186 is_async = inspect.iscoroutinefunction(function) 

187 

188 return Tool( 

189 name=name or function.__name__, 

190 description=tool_description, 

191 parameters=schema, 

192 function=None if is_async else function, 

193 async_function=function if is_async else None, 

194 inputs_from_state=inputs_from_state, 

195 outputs_to_state=outputs_to_state, 

196 outputs_to_string=outputs_to_string, 

197 ) 

198 

199 

200@overload 

201def tool( 

202 function: Callable, 

203 *, 

204 name: str | None = None, 

205 description: str | None = None, 

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

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

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

209) -> Tool: ... 

210 

211 

212@overload 

213def tool( 

214 function: None = None, 

215 *, 

216 name: str | None = None, 

217 description: str | None = None, 

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

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

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

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

222 

223 

224def tool( 

225 function: Callable | None = None, 

226 *, 

227 name: str | None = None, 

228 description: str | None = None, 

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

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

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

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

233 """ 

234 Decorator to convert a function into a Tool. 

235 

236 Can be used with or without parameters: 

237 @tool # without parameters 

238 def my_function(): ... 

239 

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

241 def my_function(): ... 

242 

243 ### Usage example 

244 ```python 

245 from typing import Annotated, Literal 

246 from haystack.tools import tool 

247 

248 @tool 

249 def get_weather( 

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

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

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

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

254 

255 print(get_weather) 

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

257 # >> parameters={ 

258 # >> 'type': 'object', 

259 # >> 'properties': { 

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

261 # >> 'unit': { 

262 # >> 'type': 'string', 

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

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

265 # >> 'default': 'Celsius', 

266 # >> }, 

267 # >> } 

268 # >> }, 

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

270 ``` 

271 

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

273 :param name: Optional custom name for the tool 

274 :param description: Optional custom description 

275 :param inputs_from_state: 

276 Optional dictionary mapping state keys to tool parameter names. 

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

278 :param outputs_to_state: 

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

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

281 Example: 

282 ```python 

283 { 

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

285 } 

286 ``` 

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

288 Example: 

289 ```python 

290 { 

291 "documents": {"handler": custom_handler} 

292 } 

293 ``` 

294 :param outputs_to_string: 

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

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

297 

298 `outputs_to_string` supports two formats: 

299 

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

301 ```python 

302 { 

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

304 } 

305 ``` 

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

307 tool result is sent to the handler. 

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

309 final result. 

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

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

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

313 Generators. 

314 

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

316 ```python 

317 { 

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

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

320 } 

321 ``` 

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

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

324 

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

326 """ 

327 

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

329 return create_tool_from_function( 

330 function=func, 

331 name=name, 

332 description=description, 

333 inputs_from_state=inputs_from_state, 

334 outputs_to_state=outputs_to_state, 

335 outputs_to_string=outputs_to_string, 

336 ) 

337 

338 if function is None: 

339 return decorator 

340 return decorator(function) 

341 

342 

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

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

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

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

347_NAME_KEYED_SCHEMA_MAPS = frozenset( 

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

349) 

350 

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

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

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

354 

355 

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

357 """ 

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

359 

360 :param schema: 

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

362 """ 

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

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

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

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

367 # misinterpreted as schema keywords. 

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

369 for sub_schema in value.values(): 

370 if isinstance(sub_schema, dict): 

371 _remove_title_from_schema(sub_schema) 

372 elif key in _DATA_SCHEMA_KEYWORDS: 

373 continue 

374 elif key == "title": 

375 del schema[key] 

376 elif isinstance(value, dict): 

377 _remove_title_from_schema(value) 

378 elif isinstance(value, list): 

379 for item in value: 

380 if isinstance(item, dict): 

381 _remove_title_from_schema(item)