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
« 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
5import inspect
6from collections.abc import Callable
7from typing import Any, overload
9from pydantic import create_model
11from haystack.core.type_utils import _resolve_parameter_types
13from .errors import SchemaGenerationError
14from .parameters_schema_utils import _contains_callable_type, _is_state_annotation
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)
136 # resolve postponed annotations (`from __future__ import annotations`), keeping `Annotated` descriptions
137 param_types = _resolve_parameter_types(function, include_extras=True)
139 # collect fields (types and defaults) and descriptions from function parameters
140 fields: dict[str, Any] = {}
141 descriptions = {}
143 for param_name, param in signature.parameters.items():
144 annotation = param_types[param_name]
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
150 # Skip State-typed parameters (including Optional[State]) - Agent tool execution injects them at runtime
151 if _is_state_annotation(annotation):
152 continue
154 if annotation is param.empty:
155 raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.")
157 # Skip Callable types since Pydantic cannot generate JSON schemas for them
158 if _contains_callable_type(annotation):
159 continue
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)
166 if hasattr(annotation, "__metadata__"):
167 descriptions[param_name] = annotation.__metadata__[0]
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
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)
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
186 is_async = inspect.iscoroutinefunction(function)
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 )
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: ...
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]: ...
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.
236 Can be used with or without parameters:
237 @tool # without parameters
238 def my_function(): ...
240 @tool(name="custom_name") # with parameters
241 def my_function(): ...
243 ### Usage example
244 ```python
245 from typing import Annotated, Literal
246 from haystack.tools import tool
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"
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 ```
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.
298 `outputs_to_string` supports two formats:
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.
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.
325 :returns: Either a Tool instance or a decorator function that will create one
326 """
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 )
338 if function is None:
339 return decorator
340 return decorator(function)
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)
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"})
356def _remove_title_from_schema(schema: dict[str, Any]) -> None:
357 """
358 Remove the 'title' keyword from JSON schema and contained property schemas.
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)