diff --git a/haystack/tools/from_function.py b/haystack/tools/from_function.py index 8001e9cdac5..d1e02403c95 100644 --- a/haystack/tools/from_function.py +++ b/haystack/tools/from_function.py @@ -15,6 +15,78 @@ from .tool import Tool +def _create_tool_parameter_schema( + function: Callable, inputs_from_state: dict[str, str] | None = None +) -> dict[str, Any]: + """ + Generate a JSON schema for a Tool's parameters by introspecting a function's signature. + + This is the shared implementation used both by `create_tool_from_function` and by + `Tool.__post_init__` (to auto-derive `parameters` when they are not provided explicitly). + + :param function: + The function whose typed signature is converted into a JSON schema. Every parameter + must include a type hint (except those excluded below). + :param inputs_from_state: + Optional dictionary mapping state keys to tool parameter names. Parameters that are + supplied from state are excluded from the generated schema. + :returns: + A JSON schema (as a dictionary) describing the function's parameters. + :raises ValueError: + If any (non-excluded) parameter of the function lacks a type hint. + :raises SchemaGenerationError: + If there is an error generating the JSON schema for the function. + """ + signature = inspect.signature(function) + + # collect fields (types and defaults) and descriptions from function parameters + fields: dict[str, Any] = {} + descriptions = {} + + for param_name, param in signature.parameters.items(): + # Skip adding parameter names that will be passed to the tool from State + if inputs_from_state and param_name in inputs_from_state.values(): + continue + + # Skip State-typed parameters (including Optional[State]) - Agent tool execution injects them at runtime + if _unwrap_optional(param.annotation) is State: + continue + + if param.annotation is param.empty: + raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.") + + # Skip Callable types since Pydantic cannot generate JSON schemas for them + if _contains_callable_type(param.annotation): + continue + + # if the parameter has not a default value, Pydantic requires an Ellipsis (...) + # to explicitly indicate that the parameter is required + default = param.default if param.default is not param.empty else ... + fields[param_name] = (param.annotation, default) + + if hasattr(param.annotation, "__metadata__"): + descriptions[param_name] = param.annotation.__metadata__[0] + + # create Pydantic model and generate JSON schema + try: + model = create_model(function.__name__, **fields) + schema = model.model_json_schema() + except Exception as e: + raise SchemaGenerationError(f"Failed to create JSON schema for function '{function.__name__}'") from e + + # we don't want to include title keywords in the schema, as they contain redundant information + # there is no programmatic way to prevent Pydantic from adding them, so we remove them later + # see https://github.com/pydantic/pydantic/discussions/8504 + _remove_title_from_schema(schema) + + # add parameters descriptions to the schema + for param_name, param_description in descriptions.items(): + if param_name in schema["properties"]: + schema["properties"][param_name]["description"] = param_description + + return schema + + def create_tool_from_function( function: Callable, name: str | None = None, @@ -132,52 +204,7 @@ def get_weather( """ tool_description = description if description is not None else (function.__doc__ or "") - signature = inspect.signature(function) - - # collect fields (types and defaults) and descriptions from function parameters - fields: dict[str, Any] = {} - descriptions = {} - - for param_name, param in signature.parameters.items(): - # Skip adding parameter names that will be passed to the tool from State - if inputs_from_state and param_name in inputs_from_state.values(): - continue - - # Skip State-typed parameters (including Optional[State]) - Agent tool execution injects them at runtime - if _unwrap_optional(param.annotation) is State: - continue - - if param.annotation is param.empty: - raise ValueError(f"Function '{function.__name__}': parameter '{param_name}' does not have a type hint.") - - # Skip Callable types since Pydantic cannot generate JSON schemas for them - if _contains_callable_type(param.annotation): - continue - - # if the parameter has not a default value, Pydantic requires an Ellipsis (...) - # to explicitly indicate that the parameter is required - default = param.default if param.default is not param.empty else ... - fields[param_name] = (param.annotation, default) - - if hasattr(param.annotation, "__metadata__"): - descriptions[param_name] = param.annotation.__metadata__[0] - - # create Pydantic model and generate JSON schema - try: - model = create_model(function.__name__, **fields) - schema = model.model_json_schema() - except Exception as e: - raise SchemaGenerationError(f"Failed to create JSON schema for function '{function.__name__}'") from e - - # we don't want to include title keywords in the schema, as they contain redundant information - # there is no programmatic way to prevent Pydantic from adding them, so we remove them later - # see https://github.com/pydantic/pydantic/discussions/8504 - _remove_title_from_schema(schema) - - # add parameters descriptions to the schema - for param_name, param_description in descriptions.items(): - if param_name in schema["properties"]: - schema["properties"][param_name]["description"] = param_description + schema = _create_tool_parameter_schema(function, inputs_from_state) is_async = inspect.iscoroutinefunction(function) diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py index 8ac886482e8..4eec59b0f36 100644 --- a/haystack/tools/tool.py +++ b/haystack/tools/tool.py @@ -32,9 +32,12 @@ class Tool: :param name: Name of the Tool. :param description: - Description of the Tool. + Description of the Tool. If not provided, it is derived from the docstring of `function` + (or `async_function`). Pass an empty string to intentionally leave the description empty. :param parameters: - A JSON schema defining the parameters expected by the Tool. + A JSON schema defining the parameters expected by the Tool. If not provided, it is + generated automatically from the type hints of `function` (or `async_function`), using the + same logic as `create_tool_from_function`. :param function: The synchronous function invoked by `Tool.invoke`. Must be a regular function — coroutine functions should be passed to `async_function` instead. Either `function` or `async_function` (or both) must be set. @@ -100,8 +103,8 @@ class Tool: """ name: str - description: str - parameters: dict[str, Any] + description: str | None = None + parameters: dict[str, Any] | None = None function: Callable | None = None outputs_to_string: dict[str, Any] | None = None inputs_from_state: dict[str, str] | None = None @@ -128,6 +131,9 @@ def __post_init__(self) -> None: # noqa: C901, PLR0912 f"Got '{getattr(self.async_function, '__name__', repr(self.async_function))}'." ) + # Derive `description` and/or `parameters` from the tool's function when not provided. + self._derive_description_and_parameters() + # Check that the parameters define a valid JSON schema try: Draft202012Validator.check_schema(self.parameters) @@ -211,6 +217,32 @@ def __post_init__(self) -> None: # noqa: C901, PLR0912 f"Valid parameters are: {valid_inputs}." ) + def _derive_description_and_parameters(self) -> None: + """ + Fill in `description` and/or `parameters` from the tool's function when they are missing. + + Mirrors `create_tool_from_function`, so a Tool can be built directly from a typed function, + e.g. `Tool(name="get_weather", function=get_weather)`. Each field is derived only when it is + missing, so an explicitly provided value is never regenerated (regenerating could otherwise + raise for a function with un-hinted parameters). + """ + if self.description is not None and self.parameters is not None: + return + + # At least one of `function`/`async_function` is guaranteed to be set (checked in + # `__post_init__`); the `is not None` guard reflects that and narrows the type below. + source_function = self.function or self.async_function + if source_function is None: + return + + if self.description is None: + self.description = source_function.__doc__ or "" + if self.parameters is None: + # Imported lazily to avoid a circular import (`from_function` imports `Tool`). + from haystack.tools.from_function import _create_tool_parameter_schema + + self.parameters = _create_tool_parameter_schema(source_function, self.inputs_from_state) + def _get_valid_inputs(self) -> set[str]: """ Return the set of valid input parameter names that this tool accepts. @@ -241,8 +273,9 @@ def _get_valid_inputs(self) -> set[str]: except (ValueError, TypeError): pass # Introspection failed, will rely on schema - # Add parameters from schema (union with function params) - valid_params.update(self.parameters.get("properties", {}).keys()) + # Add parameters from schema (union with function params). `parameters` is always + # populated by `__post_init__`; `or {}` narrows the Optional type for mypy. + valid_params.update((self.parameters or {}).get("properties", {}).keys()) return valid_params diff --git a/releasenotes/notes/tool-derive-description-parameters-from-function-3af1339d289db14f.yaml b/releasenotes/notes/tool-derive-description-parameters-from-function-3af1339d289db14f.yaml new file mode 100644 index 00000000000..3349d8d7d78 --- /dev/null +++ b/releasenotes/notes/tool-derive-description-parameters-from-function-3af1339d289db14f.yaml @@ -0,0 +1,10 @@ +--- +enhancements: + - | + `Tool` can now derive its `description` and `parameters` directly from its + `function` (or `async_function`) when they are not provided explicitly, using the + same logic as `create_tool_from_function`. This makes it possible to construct a + tool with just `Tool(name="get_weather", function=get_weather)`: the description is + taken from the function's docstring and the parameters JSON schema is generated from + its type hints. Passing `description` and `parameters` explicitly keeps the previous + behavior unchanged. diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py index b0acf18f5bc..78909c300c3 100644 --- a/test/tools/test_tool.py +++ b/test/tools/test_tool.py @@ -3,12 +3,12 @@ # SPDX-License-Identifier: Apache-2.0 import re -from typing import Any +from typing import Annotated, Any, Literal import pytest from haystack.dataclasses import TextContent -from haystack.tools import Tool, _check_duplicate_tool_names +from haystack.tools import Tool, _check_duplicate_tool_names, create_tool_from_function from haystack.tools.errors import ToolInvocationError from haystack.tools.tool import ( _deserialize_outputs_to_state, @@ -362,6 +362,101 @@ def _get_valid_outputs(self): ) +def annotated_weather( + city: Annotated[str, "the city for which to get the weather"] = "Munich", + unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius", +) -> str: + """A simple function to get the current weather for a location.""" + return f"Weather report for {city}: 20 {unit}, sunny" + + +async def annotated_weather_async(city: Annotated[str, "the city"] = "Rome") -> str: + """Async weather lookup.""" + return f"Weather report for {city}" + + +def no_docstring_tool(value: int = 1) -> int: + return value + + +class TestToolAutoDeriveFromFunction: + def test_derives_description_and_parameters_from_function(self): + tool = Tool(name="get_weather", function=annotated_weather) + + # description comes from the docstring + assert tool.description == "A simple function to get the current weather for a location." + # parameters are generated from the typed signature, identical to create_tool_from_function + assert tool.parameters == create_tool_from_function(annotated_weather).parameters + assert tool.parameters is not None + assert tool.parameters["properties"]["city"]["description"] == "the city for which to get the weather" + # the resulting tool is fully functional + assert tool.invoke(city="Berlin", unit="Celsius") == "Weather report for Berlin: 20 Celsius, sunny" + + def test_derives_from_async_function(self): + tool = Tool(name="get_weather", async_function=annotated_weather_async) + + assert tool.function is None + assert tool.description == "Async weather lookup." + assert tool.parameters is not None + assert "city" in tool.parameters["properties"] + assert tool.parameters == create_tool_from_function(annotated_weather_async).parameters + + def test_explicit_values_are_not_overwritten(self): + explicit_params = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} + tool = Tool( + name="weather", description="Explicit description", parameters=explicit_params, function=annotated_weather + ) + + assert tool.description == "Explicit description" + assert tool.parameters == explicit_params + + def test_derives_only_missing_parameters(self): + tool = Tool(name="weather", description="Explicit description", function=annotated_weather) + + assert tool.description == "Explicit description" + assert tool.parameters == create_tool_from_function(annotated_weather).parameters + + def test_derives_only_missing_description(self): + explicit_params = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} + tool = Tool(name="weather", parameters=explicit_params, function=annotated_weather) + + assert tool.description == "A simple function to get the current weather for a location." + assert tool.parameters == explicit_params + + def test_derives_empty_description_when_function_has_no_docstring(self): + tool = Tool(name="no_doc", function=no_docstring_tool) + + assert tool.description == "" + assert tool.parameters == create_tool_from_function(no_docstring_tool).parameters + + def test_derived_schema_excludes_inputs_from_state_params(self): + def weather_with_state(city: Annotated[str, "the city"], user_id: str | None = None) -> str: + """Weather with a state-provided parameter.""" + return f"Weather for {city} ({user_id})" + + tool = Tool(name="weather", function=weather_with_state, inputs_from_state={"user_id": "user_id"}) + + assert tool.parameters is not None + assert "city" in tool.parameters["properties"] + assert "user_id" not in tool.parameters["properties"] + + def test_derivation_serialization_round_trip(self): + tool = Tool(name="get_weather", function=annotated_weather) + restored = Tool.from_dict(tool.to_dict()) + + assert restored.name == tool.name + assert restored.description == tool.description + assert restored.parameters == tool.parameters + + def test_missing_type_hint_raises_when_deriving(self): + def bad_tool(city): # missing type hint (intentionally fully untyped) + """Bad tool.""" + return city + + with pytest.raises(ValueError, match="does not have a type hint"): + Tool(name="bad", function=bad_tool) + + @pytest.fixture def async_tool(): return Tool(