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
« 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
5import inspect
6from collections.abc import Callable
7from typing import Any, overload
9from pydantic import create_model
11from haystack.components.agents.state.state import State
13from .errors import SchemaGenerationError
14from .parameters_schema_utils import _contains_callable_type, _unwrap_optional
15from .tool import Tool
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.
29 Allows customizing the Tool name and description.
30 For simpler use cases, consider using the `@tool` decorator.
32 ### Usage example
34 ```python
35 from typing import Annotated, Literal
36 from haystack.tools import create_tool_from_function
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"
44 tool = create_tool_from_function(get_weather)
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 ```
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.
99 `outputs_to_string` supports two formats:
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.
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.
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 "")
135 signature = inspect.signature(function)
137 # collect fields (types and defaults) and descriptions from function parameters
138 fields: dict[str, Any] = {}
139 descriptions = {}
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
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
150 if param.annotation is param.empty:
151 raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.")
153 # Skip Callable types since Pydantic cannot generate JSON schemas for them
154 if _contains_callable_type(param.annotation):
155 continue
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)
162 if hasattr(param.annotation, "__metadata__"):
163 descriptions[param_name] = param.annotation.__metadata__[0]
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
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)
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
182 is_async = inspect.iscoroutinefunction(function)
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 )
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: ...
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]: ...
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.
232 Can be used with or without parameters:
233 @tool # without parameters
234 def my_function(): ...
236 @tool(name="custom_name") # with parameters
237 def my_function(): ...
239 ### Usage example
240 ```python
241 from typing import Annotated, Literal
242 from haystack.tools import tool
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"
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 ```
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.
294 `outputs_to_string` supports two formats:
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.
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.
321 :returns: Either a Tool instance or a decorator function that will create one
322 """
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 )
334 if function is None:
335 return decorator
336 return decorator(function)
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)
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"})
352def _remove_title_from_schema(schema: dict[str, Any]) -> None:
353 """
354 Remove the 'title' keyword from JSON schema and contained property schemas.
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)