Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 73 additions & 46 deletions haystack/tools/from_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
45 changes: 39 additions & 6 deletions haystack/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 97 additions & 2 deletions test/tools/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down