chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import sys
from typing import TYPE_CHECKING
from lazy_imports import LazyImporter
# The `tool` decorator (from `from_function`) shares its name with the `tool` submodule (`tool.py`).
# `LazyImporter` registers every key in `import_structure` as a submodule symbol, so adding `"tool"` as a key collides
# with the `tool` decorator and raises a duplicate-symbol error at construction. We therefore eagerly import the
# decorator and pass it via `extra_objects`, keeping `"tool"` out of `import_structure`.
#
# Guidance for adding future exports:
# - Symbols defined in `tool.py` (e.g. `Tool`) CANNOT be lazy: doing so would require `"tool"` as a key, which
# re-introduces the collision with the `tool` decorator. Import them eagerly here and add them to `extra_objects`.
# - Symbols from `from_function.py` (e.g. `create_tool_from_function`) could be lazy, but since we already eagerly
# import this module for the `tool` decorator, we keep its exports eager in `extra_objects` too rather than
# splitting them.
# - Symbols from any other submodule have no name collision and should be added lazily to `import_structure` (plus
# the `TYPE_CHECKING` block below) like the existing entries.
from haystack.tools.from_function import create_tool_from_function, tool
from haystack.tools.tool import Tool, _check_duplicate_tool_names
_import_structure = {
"toolset": ["Toolset"],
"searchable_toolset": ["SearchableToolset"],
"skills": ["SkillToolset"],
"component_tool": ["ComponentTool"],
"pipeline_tool": ["PipelineTool"],
"serde_utils": ["deserialize_tools_or_toolset_inplace", "serialize_tools_or_toolset"],
"utils": ["flatten_tools_or_toolsets", "warm_up_tools"],
"tool_types": ["ToolsType"],
}
if TYPE_CHECKING:
from haystack.tools.component_tool import ComponentTool as ComponentTool
from haystack.tools.pipeline_tool import PipelineTool as PipelineTool
from haystack.tools.searchable_toolset import SearchableToolset as SearchableToolset
from haystack.tools.serde_utils import deserialize_tools_or_toolset_inplace as deserialize_tools_or_toolset_inplace
from haystack.tools.serde_utils import serialize_tools_or_toolset as serialize_tools_or_toolset
from haystack.tools.skills import SkillToolset as SkillToolset
from haystack.tools.tool_types import ToolsType as ToolsType
from haystack.tools.toolset import Toolset as Toolset
from haystack.tools.utils import flatten_tools_or_toolsets as flatten_tools_or_toolsets
from haystack.tools.utils import warm_up_tools as warm_up_tools
else:
sys.modules[__name__] = LazyImporter(
name=__name__,
module_file=__file__,
import_structure=_import_structure,
extra_objects={
"create_tool_from_function": create_tool_from_function,
"tool": tool,
"Tool": Tool,
"_check_duplicate_tool_names": _check_duplicate_tool_names,
},
)
+421
View File
@@ -0,0 +1,421 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections.abc import Callable
from typing import Any, get_args, get_origin
from pydantic import Field, TypeAdapter, create_model
from haystack import logging
from haystack.components.agents.state.state import State
from haystack.core.component import Component
from haystack.core.serialization import (
component_from_dict,
component_to_dict,
generate_qualified_class_name,
import_class_by_name,
)
from haystack.tools import Tool
from haystack.tools.errors import SchemaGenerationError
from haystack.tools.from_function import _remove_title_from_schema
from haystack.tools.parameters_schema_utils import (
_contains_callable_type,
_get_component_param_descriptions,
_resolve_type,
_unwrap_optional,
)
from haystack.tools.tool import (
_deserialize_outputs_to_state,
_deserialize_outputs_to_string,
_serialize_outputs_to_state,
_serialize_outputs_to_string,
)
from haystack.utils.type_serialization import _is_union_type
logger = logging.getLogger(__name__)
class ComponentTool(Tool):
"""
A Tool that wraps Haystack components, allowing them to be used as tools by LLMs.
ComponentTool automatically generates LLM-compatible tool schemas from component input sockets,
which are derived from the component's `run` method signature and type hints.
Key features:
- Automatic LLM tool calling schema generation from component input sockets
- Type conversion and validation for component inputs
- Support for types:
- Dataclasses
- Lists of dataclasses
- Basic types (str, int, float, bool, dict)
- Lists of basic types
- Automatic name generation from component class name
- Description extraction from component docstrings
To use ComponentTool, you first need a Haystack component - either an existing one or a new one you create.
You can create a ComponentTool from the component by passing the component to the ComponentTool constructor.
Below is an example of creating a ComponentTool from an existing SerperDevWebSearch component
from the `serperdev-haystack` integration package (`pip install serperdev-haystack`).
## Usage Example:
<!-- test-ignore -->
```python
from haystack import component
from haystack.tools import ComponentTool
from haystack.utils import Secret
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
# Create a SerperDev search component
search = SerperDevWebSearch(api_key=Secret.from_env_var("SERPERDEV_API_KEY"), top_k=3)
# Create a tool from the component
tool = ComponentTool(
component=search,
name="web_search", # Optional: defaults to "serper_dev_web_search"
description="Search the web for current information on any topic" # Optional: defaults to component docstring
)
# Create an Agent with an OpenAIChatGenerator and the tool
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[tool])
message = ChatMessage.from_user("Use the web search tool to find information about Nikola Tesla")
# Run the Agent
result = agent.run(messages=[message])
print(result)
```
"""
def __init__(
self,
component: Component,
name: str | None = None,
description: str | None = None,
parameters: dict[str, Any] | None = None,
*,
outputs_to_string: dict[str, str | Callable[[Any], str]] | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, str | Callable]] | None = None,
) -> None:
"""
Create a Tool instance from a Haystack component.
:param component: The Haystack component to wrap as a tool.
:param name: Optional name for the tool (defaults to snake_case of component class name).
:param description: Optional description (defaults to component's docstring).
:param parameters:
A JSON schema defining the parameters expected by the Tool.
Will fall back to the parameters defined in the component's run method signature if not provided.
:param outputs_to_string:
Optional dictionary defining how tool outputs should be converted into string(s) or results.
If not provided, the tool result is converted to a string using a default handler.
`outputs_to_string` supports two formats:
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
```python
{
"source": "docs", "handler": format_documents, "raw_result": False
}
```
- `source`: If provided, only the specified output key is sent to the handler.
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
final result.
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the
`handler` if provided. This is intended for tools that return images. In this mode, the Tool
function or the `handler` function must return a list of `TextContent`/`ImageContent` objects to
ensure compatibility with Chat Generators.
2. Multiple output format - map keys to individual configurations:
```python
{
"formatted_docs": {"source": "docs", "handler": format_documents},
"summary": {"source": "summary_text", "handler": str.upper}
}
```
Each key maps to a dictionary that can contain "source" and/or "handler".
Note that `raw_result` is not supported in the multiple output format.
:param inputs_from_state:
Optional dictionary mapping state keys to tool parameter names.
Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
:param outputs_to_state:
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
If the source is provided only the specified output key is sent to the handler.
Example:
```python
{
"documents": {"source": "docs", "handler": custom_handler}
}
```
If the source is omitted the whole tool result is sent to the handler.
Example:
```python
{
"documents": {"handler": custom_handler}
}
```
:raises TypeError: If the object passed is not a Haystack Component instance.
:raises ValueError: If the component has already been added to a pipeline, or if schema generation fails.
"""
if not isinstance(component, Component):
message = (
f"Object {component!r} is not a Haystack component. "
"Use ComponentTool only with Haystack component instances."
)
raise TypeError(message)
if getattr(component, "__haystack_added_to_pipeline__", None):
msg = (
"Component has been added to a pipeline and can't be used to create a ComponentTool. "
"Create ComponentTool from a non-pipeline component instead."
)
raise ValueError(msg)
self._unresolved_parameters = parameters
# Create the tools schema from the component run method parameters
tool_schema = parameters or self._create_tool_parameters_schema(component, inputs_from_state or {})
def component_invoker(**kwargs: Any) -> dict[str, Any]:
"""
Invokes the component using keyword arguments provided by the LLM function calling/tool-generated response.
:param kwargs: The keyword arguments to invoke the component with.
:returns: The result of the component invocation.
"""
input_sockets = component.__haystack_input__._sockets_dict # type: ignore[attr-defined]
converted_kwargs = {
param_name: self._convert_param(param_value, input_sockets[param_name].type)
for param_name, param_value in kwargs.items()
}
logger.debug(
"Invoking component {component_type} with kwargs: {converted_kwargs}",
component_type=type(component),
converted_kwargs=converted_kwargs,
)
return dict(component.run(**converted_kwargs))
async def async_component_invoker(**kwargs: Any) -> dict[str, Any]:
"""
Asynchronous counterpart of `component_invoker`. Awaits the component's `run_async`.
:param kwargs: The keyword arguments to invoke the component with.
:returns: The result of the component invocation.
"""
input_sockets = component.__haystack_input__._sockets_dict # type: ignore[attr-defined]
converted_kwargs = {
param_name: self._convert_param(param_value, input_sockets[param_name].type)
for param_name, param_value in kwargs.items()
}
logger.debug(
"Invoking component {component_type} asynchronously with kwargs: {converted_kwargs}",
component_type=type(component),
converted_kwargs=converted_kwargs,
)
# We know run_async exists at this point b/c we only pass the async invoker if the component has
# __haystack_supports_async__ = True
return dict(await component.run_async(**converted_kwargs)) # type: ignore[attr-defined]
component_supports_async = getattr(component, "__haystack_supports_async__", False)
# Generate a name for the tool if not provided
if not name:
class_name = component.__class__.__name__
# Convert camelCase/PascalCase to snake_case
name = "".join(
[
"_" + c.lower() if c.isupper() and i > 0 and not class_name[i - 1].isupper() else c.lower()
for i, c in enumerate(class_name)
]
).lstrip("_")
description = description or component.__doc__ or name
# Store component before calling super().__init__() so _get_valid_outputs() can access it
self._component = component
self._is_warmed_up = False
# Create the Tool instance with the component invoker as the function to be called and the schema.
# When the wrapped component exposes a `run_async`, also pass the async invoker.
super().__init__(
name=name,
description=description,
parameters=tool_schema,
function=component_invoker,
async_function=async_component_invoker if component_supports_async else None,
inputs_from_state=inputs_from_state,
outputs_to_state=outputs_to_state,
outputs_to_string=outputs_to_string,
)
def _get_valid_inputs(self) -> set[str]:
"""
Return valid input parameter names from the component's input sockets.
Used to validate `inputs_from_state` against the component's actual inputs.
This ensures users don't reference non-existent component inputs.
:returns: Set of component input socket names.
"""
return set(self._component.__haystack_input__._sockets_dict.keys()) # type: ignore[attr-defined]
def _get_valid_outputs(self) -> set[str]:
"""
Return valid output names from the component's output sockets.
Used to validate `outputs_to_state` against the component's actual outputs.
This ensures users don't reference non-existent component outputs.
:returns: Set of component output socket names.
"""
return set(self._component.__haystack_output__._sockets_dict.keys()) # type: ignore[attr-defined]
def warm_up(self) -> None:
"""
Prepare the ComponentTool for use.
"""
if not self._is_warmed_up:
if hasattr(self._component, "warm_up"):
self._component.warm_up()
self._is_warmed_up = True
def to_dict(self) -> dict[str, Any]:
"""
Serializes the ComponentTool to a dictionary.
"""
serialized: dict[str, Any] = {
"component": component_to_dict(obj=self._component, name=self.name),
"name": self.name,
"description": self.description,
"parameters": self._unresolved_parameters,
"inputs_from_state": self.inputs_from_state,
"outputs_to_state": _serialize_outputs_to_state(self.outputs_to_state) if self.outputs_to_state else None,
"outputs_to_string": _serialize_outputs_to_string(self.outputs_to_string)
if self.outputs_to_string
else None,
}
return {"type": generate_qualified_class_name(type(self)), "data": serialized}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ComponentTool":
"""
Deserializes the ComponentTool from a dictionary.
"""
inner_data = data["data"]
component_class = import_class_by_name(inner_data["component"]["type"])
component = component_from_dict(cls=component_class, data=inner_data["component"], name=inner_data["name"])
if "outputs_to_state" in inner_data and inner_data["outputs_to_state"]:
inner_data["outputs_to_state"] = _deserialize_outputs_to_state(inner_data["outputs_to_state"])
if inner_data.get("outputs_to_string") is not None:
inner_data["outputs_to_string"] = _deserialize_outputs_to_string(inner_data["outputs_to_string"])
return cls(
component=component,
name=inner_data["name"],
description=inner_data["description"],
parameters=inner_data.get("parameters", None),
outputs_to_string=inner_data.get("outputs_to_string", None),
inputs_from_state=inner_data.get("inputs_from_state", None),
outputs_to_state=inner_data.get("outputs_to_state", None),
)
def _create_tool_parameters_schema(self, component: Component, inputs_from_state: dict[str, Any]) -> dict[str, Any]:
"""
Creates an OpenAI tools schema from a component's run method parameters.
:param component: The component to create the schema from.
:raises SchemaGenerationError: If schema generation fails
:returns: OpenAI tools schema for the component's run method parameters.
"""
param_descriptions = _get_component_param_descriptions(component)
# collect fields (types and defaults) and descriptions from function parameters
fields: dict[str, Any] = {}
for input_name, socket in component.__haystack_input__._sockets_dict.items(): # type: ignore[attr-defined]
if inputs_from_state is not None and input_name in list(inputs_from_state.values()):
continue
input_type = socket.type
# Skip Callable types since Pydantic cannot generate JSON schemas for them
if _contains_callable_type(input_type):
continue
# Skip State-typed parameters - Agent tool execution injects them at runtime
if _unwrap_optional(input_type) is State:
continue
description = param_descriptions.get(input_name, f"Input '{input_name}' for the component.")
# if the parameter has not a default value, Pydantic requires an Ellipsis (...)
# to explicitly indicate that the parameter is required
default = ... if socket.is_mandatory else socket.default_value
resolved_type = _resolve_type(input_type)
fields[input_name] = (resolved_type, Field(default=default, description=description))
parameters_schema: dict[str, Any] = {}
try:
# No `__doc__`: it would surface as a top-level `description` on the parameters schema,
# which LLM providers ignore. The component description feeds the tool-level description.
model = create_model(component.run.__name__, **fields)
parameters_schema = model.model_json_schema()
except Exception as e:
raise SchemaGenerationError(
f"Failed to create JSON schema for the run method of Component '{component.__class__.__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(parameters_schema)
return parameters_schema
def _convert_param(self, param_value: Any, param_type: type) -> Any:
"""
Converts a single parameter value to the expected type.
:param param_value: The value to convert.
:param param_type: The expected type of the parameter.
:returns:
The converted parameter value.
"""
# We unwrap optional types so we can support types like messages: list[ChatMessage] | None
unwrapped_param_type = _unwrap_optional(param_type)
# We handle union types (e.g. list[ChatMessage] | str) by extracting the list[T] arm that has
# from_dict, so the conversion below works the same as for plain list[T]. Other arms like str
# need no special handling and fall through to Pydantic or the plain return at the end.
if _is_union_type(unwrapped_param_type):
list_arms = [
a
for a in get_args(unwrapped_param_type)
if get_origin(a) is list and get_args(a) and hasattr(get_args(a)[0], "from_dict")
]
unwrapped_param_type = list_arms[0] if list_arms else unwrapped_param_type
# We support calling from_dict on target types that have it, even if they are wrapped in a list.
# This allows us to support lists of dataclasses as well as single dataclass inputs.
target_type = (
get_args(unwrapped_param_type)[0] if get_origin(unwrapped_param_type) is list else unwrapped_param_type
)
if hasattr(target_type, "from_dict"):
if isinstance(param_value, list):
return [target_type.from_dict(item) if isinstance(item, dict) else item for item in param_value]
if isinstance(param_value, dict):
return target_type.from_dict(param_value)
return param_value
# Use the original type for pydantic validation
return TypeAdapter(param_type).validate_python(param_value)
+21
View File
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
class SchemaGenerationError(Exception):
"""
Exception raised when automatic schema generation fails.
"""
pass
class ToolInvocationError(Exception):
"""
Exception raised when a Tool invocation fails.
"""
def __init__(self, message: str, tool_name: str) -> None:
super().__init__(message)
self.tool_name = tool_name
+358
View File
@@ -0,0 +1,358 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import inspect
from collections.abc import Callable
from typing import Any, overload
from pydantic import create_model
from haystack.components.agents.state.state import State
from .errors import SchemaGenerationError
from .parameters_schema_utils import _contains_callable_type, _unwrap_optional
from .tool import Tool
def create_tool_from_function(
function: Callable,
name: str | None = None,
description: str | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, Any]] | None = None,
outputs_to_string: dict[str, Any] | None = None,
) -> "Tool":
"""
Create a Tool instance from a function.
Allows customizing the Tool name and description.
For simpler use cases, consider using the `@tool` decorator.
### Usage example
```python
from typing import Annotated, Literal
from haystack.tools import create_tool_from_function
def get_weather(
city: Annotated[str, "the city for which to get the weather"] = "Munich",
unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius"):
'''A simple function to get the current weather for a location.'''
return f"Weather report for {city}: 20 {unit}, sunny"
tool = create_tool_from_function(get_weather)
print(tool)
# >> Tool(name='get_weather', description='A simple function to get the current weather for a location.',
# >> parameters={
# >> 'type': 'object',
# >> 'properties': {
# >> 'city': {'type': 'string', 'description': 'the city for which to get the weather', 'default': 'Munich'},
# >> 'unit': {
# >> 'type': 'string',
# >> 'enum': ['Celsius', 'Fahrenheit'],
# >> 'description': 'the unit for the temperature',
# >> 'default': 'Celsius',
# >> },
# >> }
# >> },
# >> function=<function get_weather at 0x7f7b3a8a9b80>)
```
:param function:
The function to be converted into a Tool. May be either a regular function (assigned to the
resulting Tool's `function` field) or a coroutine function defined with `async def` (assigned
to `async_function`).
The function must include type hints for all parameters.
The function is expected to have basic python input types (str, int, float, bool, list, dict, tuple).
Other input types may work but are not guaranteed.
If a parameter is annotated using `typing.Annotated`, its metadata will be used as parameter description.
:param name:
The name of the Tool. If not provided, the name of the function will be used.
:param description:
The description of the Tool. If not provided, the docstring of the function will be used.
To intentionally leave the description empty, pass an empty string.
:param inputs_from_state:
Optional dictionary mapping state keys to tool parameter names.
Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
:param outputs_to_state:
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
If the source is provided only the specified output key is sent to the handler.
Example:
```python
{
"documents": {"source": "docs", "handler": custom_handler}
}
```
If the source is omitted the whole tool result is sent to the handler.
Example:
```python
{
"documents": {"handler": custom_handler}
}
```
:param outputs_to_string:
Optional dictionary defining how tool outputs should be converted into string(s) or results.
If not provided, the tool result is converted to a string using a default handler.
`outputs_to_string` supports two formats:
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
```python
{
"source": "docs", "handler": format_documents, "raw_result": False
}
```
- `source`: If provided, only the specified output key is sent to the handler. If not provided, the whole
tool result is sent to the handler.
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
final result.
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the `handler`
if provided. This is intended for tools that return images. In this mode, the Tool function or the
`handler` must return a list of `TextContent`/`ImageContent` objects to ensure compatibility with Chat
Generators.
2. Multiple output format - map keys to individual configurations:
```python
{
"formatted_docs": {"source": "docs", "handler": format_documents},
"summary": {"source": "summary_text", "handler": str.upper}
}
```
Each key maps to a dictionary that can contain "source" and/or "handler".
Note that `raw_result` is not supported in the multiple output format.
:returns:
The Tool created from the function.
:raises ValueError:
If any parameter of the function lacks a type hint.
:raises SchemaGenerationError:
If there is an error generating the JSON schema for the Tool.
"""
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
is_async = inspect.iscoroutinefunction(function)
return Tool(
name=name or function.__name__,
description=tool_description,
parameters=schema,
function=None if is_async else function,
async_function=function if is_async else None,
inputs_from_state=inputs_from_state,
outputs_to_state=outputs_to_state,
outputs_to_string=outputs_to_string,
)
@overload
def tool(
function: Callable,
*,
name: str | None = None,
description: str | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, Any]] | None = None,
outputs_to_string: dict[str, Any] | None = None,
) -> Tool: ...
@overload
def tool(
function: None = None,
*,
name: str | None = None,
description: str | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, Any]] | None = None,
outputs_to_string: dict[str, Any] | None = None,
) -> Callable[[Callable], Tool]: ...
def tool(
function: Callable | None = None,
*,
name: str | None = None,
description: str | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, Any]] | None = None,
outputs_to_string: dict[str, Any] | None = None,
) -> Tool | Callable[[Callable], Tool]:
"""
Decorator to convert a function into a Tool.
Can be used with or without parameters:
@tool # without parameters
def my_function(): ...
@tool(name="custom_name") # with parameters
def my_function(): ...
### Usage example
```python
from typing import Annotated, Literal
from haystack.tools import tool
@tool
def get_weather(
city: Annotated[str, "the city for which to get the weather"] = "Munich",
unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius"):
'''A simple function to get the current weather for a location.'''
return f"Weather report for {city}: 20 {unit}, sunny"
print(get_weather)
# >> Tool(name='get_weather', description='A simple function to get the current weather for a location.',
# >> parameters={
# >> 'type': 'object',
# >> 'properties': {
# >> 'city': {'type': 'string', 'description': 'the city for which to get the weather', 'default': 'Munich'},
# >> 'unit': {
# >> 'type': 'string',
# >> 'enum': ['Celsius', 'Fahrenheit'],
# >> 'description': 'the unit for the temperature',
# >> 'default': 'Celsius',
# >> },
# >> }
# >> },
# >> function=<function get_weather at 0x7f7b3a8a9b80>)
```
:param function: The function to decorate (when used without parameters)
:param name: Optional custom name for the tool
:param description: Optional custom description
:param inputs_from_state:
Optional dictionary mapping state keys to tool parameter names.
Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
:param outputs_to_state:
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
If the source is provided only the specified output key is sent to the handler.
Example:
```python
{
"documents": {"source": "docs", "handler": custom_handler}
}
```
If the source is omitted the whole tool result is sent to the handler.
Example:
```python
{
"documents": {"handler": custom_handler}
}
```
:param outputs_to_string:
Optional dictionary defining how tool outputs should be converted into string(s) or results.
If not provided, the tool result is converted to a string using a default handler.
`outputs_to_string` supports two formats:
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
```python
{
"source": "docs", "handler": format_documents, "raw_result": False
}
```
- `source`: If provided, only the specified output key is sent to the handler. If not provided, the whole
tool result is sent to the handler.
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
final result.
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the `handler`
if provided. This is intended for tools that return images. In this mode, the Tool function or the
`handler` must return a list of `TextContent`/`ImageContent` objects to ensure compatibility with Chat
Generators.
2. Multiple output format - map keys to individual configurations:
```python
{
"formatted_docs": {"source": "docs", "handler": format_documents},
"summary": {"source": "summary_text", "handler": str.upper}
}
```
Each key maps to a dictionary that can contain "source" and/or "handler".
Note that `raw_result` is not supported in the multiple output format.
:returns: Either a Tool instance or a decorator function that will create one
"""
def decorator(func: Callable) -> Tool:
return create_tool_from_function(
function=func,
name=name,
description=description,
inputs_from_state=inputs_from_state,
outputs_to_state=outputs_to_state,
outputs_to_string=outputs_to_string,
)
if function is None:
return decorator
return decorator(function)
def _remove_title_from_schema(schema: dict[str, Any]) -> None:
"""
Remove the 'title' keyword from JSON schema and contained property schemas.
:param schema:
The JSON schema to remove the 'title' keyword from.
"""
for key, value in list(schema.items()):
# Make sure not to remove parameters named title
if key == "properties" and isinstance(value, dict) and "title" in value:
for sub_val in value.values():
_remove_title_from_schema(sub_val)
elif key == "title":
del schema[key]
elif isinstance(value, dict):
_remove_title_from_schema(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
_remove_title_from_schema(item)
+223
View File
@@ -0,0 +1,223 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import collections
import types
from collections.abc import Callable, Sequence
from collections.abc import Callable as ABCCallable
from dataclasses import MISSING, fields, is_dataclass
from inspect import getdoc
from types import NoneType
from typing import Any, Union, get_args, get_origin
from docstring_parser import parse
from pydantic import BaseModel, Field, create_model
from haystack import logging
from haystack.dataclasses import ChatMessage
from haystack.utils.type_serialization import _is_union_type
logger = logging.getLogger(__name__)
def _unwrap_optional(type_hint: Any) -> Any:
"""
Unwrap Optional types (i.e. ``X | None`` or ``Optional[X]``) to get the inner type.
:param type_hint: The type hint to unwrap.
:returns: The inner type if ``type_hint`` is ``Optional[X]``, otherwise ``type_hint`` unchanged.
"""
origin = get_origin(type_hint)
if origin is Union or origin is types.UnionType:
non_none = [a for a in get_args(type_hint) if a is not NoneType]
if len(non_none) == 1:
return non_none[0]
return type_hint
def _contains_callable_type(type_hint: Any) -> bool:
"""
Check if a type hint contains a Callable type, including within Union types.
The purpose of this function is to help identify Callable types so they can
be skipped during schema generation.
:param type_hint: The type hint to check.
:returns: True if the type contains a Callable, False otherwise.
"""
origin = get_origin(type_hint)
# Check if it's a Callable type (direct or parameterized)
if type_hint in (Callable, ABCCallable) or origin in (Callable, ABCCallable):
return True
# Recursively check Union types (both typing.Union and types.UnionType for `X | Y` syntax)
if origin in (Union, types.UnionType):
return any(_contains_callable_type(arg) for arg in get_args(type_hint))
return False
# Schema placeholder models for Tool and Toolset
# These are used during JSON schema generation to represent non-serializable types
class _ToolSchemaPlaceholder(BaseModel):
"""Placeholder model representing a Tool for JSON schema generation."""
name: str = Field(description="Name of the tool")
description: str = Field(description="Description of the tool")
parameters: dict[str, Any] = Field(description="JSON schema of the tool parameters")
class _ToolsetSchemaPlaceholder(BaseModel):
"""Placeholder model representing a Toolset for JSON schema generation."""
tools: list[_ToolSchemaPlaceholder] = Field(description="List of tools in the toolset")
def _get_param_descriptions(method: Callable) -> tuple[str, dict[str, str]]:
"""
Extracts parameter descriptions from the method's docstring using docstring_parser.
:param method: The method to extract parameter descriptions from.
:returns:
A tuple including the short description of the method and a dictionary mapping parameter names to their
descriptions.
"""
docstring = getdoc(method)
if not docstring:
return "", {}
parsed_doc = parse(docstring)
param_descriptions = {}
for param in parsed_doc.params:
if not param.description:
logger.warning(
"Missing description for parameter '{arg_name}'. Please add a description in the component's "
"run() method docstring using the format ':param {arg_name}: <description>'. "
"This description helps the LLM understand how to use this parameter.",
arg_name=param.arg_name,
)
param_descriptions[param.arg_name] = param.description.strip() if param.description else ""
return parsed_doc.short_description or "", param_descriptions
def _get_component_param_descriptions(component: Any) -> dict[str, str]:
"""
Get parameter descriptions from a component, handling both regular Components and SuperComponents.
For regular components, this extracts descriptions from the run method's docstring.
For SuperComponents, this extracts descriptions from the underlying pipeline components.
:param component: The component to extract parameter descriptions from
:returns: A dictionary mapping parameter names to their descriptions
"""
from haystack.core.super_component.super_component import _SuperComponent
# Get descriptions from the component's run method
_, param_descriptions = _get_param_descriptions(component.run)
# If it's a SuperComponent, enhance the parameter descriptions from the original components
if isinstance(component, _SuperComponent):
for super_param_name, pipeline_paths in component.input_mapping.items():
# Collect descriptions from all mapped components
descriptions = []
for path in pipeline_paths:
try:
# Get the component and socket this input is mapped from
comp_name, socket_name = component._split_component_path(path)
pipeline_component = component.pipeline.get_component(comp_name)
# Add parameter description if available
_, run_param_descriptions = _get_param_descriptions(pipeline_component.run)
if input_param_mapping := run_param_descriptions.get(socket_name):
descriptions.append(f"Provided to the '{comp_name}' component as: '{input_param_mapping}'")
except Exception as e:
logger.debug(
"Error extracting description for {super_param_name} from {path}: {e}",
super_param_name=super_param_name,
path=path,
e=str(e),
)
# A single SuperComponent input can map to multiple pipeline components, e.g.
# input_mapping={"combined_input": ["comp_a.query", "comp_b.text"]}
if descriptions:
param_descriptions[super_param_name] = ", and ".join(descriptions) + "."
return param_descriptions
def _dataclass_to_pydantic_model(dc_type: Any) -> type[BaseModel]:
"""
Convert a Python dataclass to an equivalent Pydantic model.
:param dc_type: The dataclass type to convert.
:returns:
A dynamically generated Pydantic model class with fields and types derived from the dataclass definition.
Field descriptions are extracted from docstrings when available.
"""
_, param_descriptions = _get_param_descriptions(dc_type)
cls = dc_type if isinstance(dc_type, type) else dc_type.__class__
field_defs: dict[str, Any] = {}
for field in fields(dc_type):
f_type = field.type if isinstance(field.type, str) else _resolve_type(field.type)
default = field.default if field.default is not MISSING else ...
default = field.default_factory() if callable(field.default_factory) else default
# Special handling for ChatMessage since pydantic doesn't allow for field names with leading underscores
field_name = field.name
if dc_type is ChatMessage and field_name.startswith("_"):
# We remove the underscore since ChatMessage.from_dict does allow for field names without the underscore
field_name = field_name[1:]
description = param_descriptions.get(field_name, f"Field '{field_name}' of '{cls.__name__}'.")
field_defs[field_name] = (f_type, Field(default, description=description))
return create_model(cls.__name__, **field_defs)
def _resolve_type(_type: Any) -> Any: # noqa: PLR0911
"""
Recursively resolve and convert complex type annotations, transforming dataclasses into Pydantic-compatible types.
This function walks through nested type annotations (e.g., List, Dict, Union) and converts any dataclass types
it encounters into corresponding Pydantic models.
:param _type: The type annotation to resolve. If the type is a dataclass, it will be converted to a Pydantic model.
For generic types (like list[SomeDataclass]), the inner types are also resolved recursively.
:returns:
A fully resolved type, with all dataclass types converted to Pydantic models
"""
# Special handling for Tool and Toolset types - replace with schema placeholders
# These types contain Callables which cannot be serialized to JSON Schema
from haystack.tools.tool import Tool
from haystack.tools.toolset import Toolset
if _type is Tool:
return _ToolSchemaPlaceholder
if _type is Toolset:
return _ToolsetSchemaPlaceholder
if is_dataclass(_type):
return _dataclass_to_pydantic_model(_type)
origin = get_origin(_type)
args = get_args(_type)
if origin is list:
return list[_resolve_type(args[0]) if args else Any] # type: ignore[misc]
if origin is collections.abc.Sequence:
return Sequence[_resolve_type(args[0]) if args else Any] # type: ignore[misc]
if _is_union_type(origin):
return Union[tuple(_resolve_type(a) for a in args)]
if origin is dict:
return dict[args[0] if args else Any, _resolve_type(args[1]) if args else Any] # type: ignore[misc]
return _type
+246
View File
@@ -0,0 +1,246 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections.abc import Callable
from typing import Any
from haystack import Pipeline, SuperComponent, logging
from haystack.core.serialization import generate_qualified_class_name
from haystack.tools.component_tool import ComponentTool
from haystack.tools.tool import (
_deserialize_outputs_to_state,
_deserialize_outputs_to_string,
_serialize_outputs_to_state,
_serialize_outputs_to_string,
)
logger = logging.getLogger(__name__)
class PipelineTool(ComponentTool):
"""
A Tool that wraps Haystack Pipelines, allowing them to be used as tools by LLMs.
PipelineTool automatically generates LLM-compatible tool schemas from pipeline input sockets,
which are derived from the underlying components in the pipeline.
Key features:
- Automatic LLM tool calling schema generation from pipeline inputs
- Description extraction of pipeline inputs based on the underlying component docstrings
To use PipelineTool, you first need a Haystack pipeline.
Below is an example of creating a PipelineTool
## Usage Example:
```python
from haystack import Document, Pipeline
from haystack.dataclasses import ChatMessage
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.retrievers import InMemoryEmbeddingRetriever
from haystack.components.agents import Agent
from haystack.tools import PipelineTool
# Initialize a document store and add some documents
document_store = InMemoryDocumentStore()
document_embedder = OpenAIDocumentEmbedder()
documents = [
Document(content="Nikola Tesla was a Serbian-American inventor and electrical engineer."),
Document(
content="He is best known for his contributions to the design of the modern alternating current (AC) "
"electricity supply system."
),
]
docs_with_embeddings = document_embedder.run(documents=documents)["documents"]
document_store.write_documents(docs_with_embeddings)
# Build a simple retrieval pipeline
retrieval_pipeline = Pipeline()
retrieval_pipeline.add_component("embedder", OpenAITextEmbedder())
retrieval_pipeline.add_component("retriever", InMemoryEmbeddingRetriever(document_store=document_store))
retrieval_pipeline.connect("embedder.embedding", "retriever.query_embedding")
# Wrap the pipeline as a tool
retriever_tool = PipelineTool(
pipeline=retrieval_pipeline,
input_mapping={"query": ["embedder.text"]},
output_mapping={"retriever.documents": "documents"},
name="document_retriever",
description="For any questions about Nikola Tesla, always use this tool",
)
# Create an Agent with the tool
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4.1-mini"),
tools=[retriever_tool]
)
# Let the Agent handle a query
result = agent.run([ChatMessage.from_user("Who was Nikola Tesla?")])
# Print result of the tool call
print("Tool Call Result:")
print(result["messages"][2].tool_call_result.result)
print("")
# Print answer
print("Answer:")
print(result["messages"][-1].text)
```
"""
def __init__(
self,
pipeline: Pipeline,
*,
name: str,
description: str,
input_mapping: dict[str, list[str]] | None = None,
output_mapping: dict[str, str] | None = None,
parameters: dict[str, Any] | None = None,
outputs_to_string: dict[str, str | Callable[[Any], str]] | None = None,
inputs_from_state: dict[str, str] | None = None,
outputs_to_state: dict[str, dict[str, str | Callable]] | None = None,
) -> None:
"""
Create a Tool instance from a Haystack pipeline.
:param pipeline: The Haystack pipeline to wrap as a tool.
:param name: Name of the tool.
:param description: Description of the tool.
:param input_mapping: A dictionary mapping component input names to pipeline input socket paths.
If not provided, a default input mapping will be created based on all pipeline inputs.
Example:
```python
input_mapping={
"query": ["retriever.query", "prompt_builder.query"],
}
```
:param output_mapping: A dictionary mapping pipeline output socket paths to component output names.
If not provided, a default output mapping will be created based on all pipeline outputs.
Example:
```python
output_mapping={
"retriever.documents": "documents",
"generator.replies": "replies",
}
```
:param parameters:
A JSON schema defining the parameters expected by the Tool.
Will fall back to the parameters defined in the component's run method signature if not provided.
:param outputs_to_string:
Optional dictionary defining how tool outputs should be converted into string(s) or results.
If not provided, the tool result is converted to a string using a default handler.
`outputs_to_string` supports two formats:
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
```python
{
"source": "docs", "handler": format_documents, "raw_result": False
}
```
- `source`: If provided, only the specified output key is sent to the handler.
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
final result.
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the
`handler` if provided. This is intended for tools that return images. In this mode, the Tool
function or the `handler` function must return a list of `TextContent`/`ImageContent` objects to
ensure compatibility with Chat Generators.
2. Multiple output format - map keys to individual configurations:
```python
{
"formatted_docs": {"source": "docs", "handler": format_documents},
"summary": {"source": "summary_text", "handler": str.upper}
}
```
Each key maps to a dictionary that can contain "source" and/or "handler".
Note that `raw_result` is not supported in the multiple output format.
:param inputs_from_state:
Optional dictionary mapping state keys to tool parameter names.
Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
:param outputs_to_state:
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
If the source is provided only the specified output key is sent to the handler.
Example:
```python
{
"documents": {"source": "docs", "handler": custom_handler}
}
```
If the source is omitted the whole tool result is sent to the handler.
Example:
```python
{
"documents": {"handler": custom_handler}
}
```
:raises ValueError: If the provided pipeline is not a valid Haystack Pipeline instance.
"""
if not isinstance(pipeline, Pipeline):
raise TypeError(f"The 'pipeline' parameter must be an instance of Pipeline. Got {type(pipeline)} instead.")
super().__init__(
component=SuperComponent(pipeline=pipeline, input_mapping=input_mapping, output_mapping=output_mapping),
name=name,
description=description,
parameters=parameters,
outputs_to_string=outputs_to_string,
inputs_from_state=inputs_from_state,
outputs_to_state=outputs_to_state,
)
self._unresolved_parameters = parameters
self._pipeline = pipeline
self._input_mapping = input_mapping
self._output_mapping = output_mapping
def to_dict(self) -> dict[str, Any]:
"""
Serializes the PipelineTool to a dictionary.
:returns:
The serialized dictionary representation of PipelineTool.
"""
serialized: dict[str, Any] = {
"pipeline": self._pipeline.to_dict(),
"name": self.name,
"input_mapping": self._input_mapping,
"output_mapping": self._output_mapping,
"description": self.description,
"parameters": self._unresolved_parameters,
"inputs_from_state": self.inputs_from_state,
"outputs_to_state": _serialize_outputs_to_state(self.outputs_to_state) if self.outputs_to_state else None,
"outputs_to_string": _serialize_outputs_to_string(self.outputs_to_string)
if self.outputs_to_string
else None,
}
return {"type": generate_qualified_class_name(type(self)), "data": serialized}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PipelineTool":
"""
Deserializes the PipelineTool from a dictionary.
:param data: The dictionary representation of PipelineTool.
:returns:
The deserialized PipelineTool instance.
"""
inner_data = data["data"]
# `is_pipeline_async` is a legacy key kept only for backward compatibility
inner_data.pop("is_pipeline_async", None)
pipeline = Pipeline.from_dict(inner_data["pipeline"])
if "outputs_to_state" in inner_data and inner_data["outputs_to_state"]:
inner_data["outputs_to_state"] = _deserialize_outputs_to_state(inner_data["outputs_to_state"])
if inner_data.get("outputs_to_string") is not None:
inner_data["outputs_to_string"] = _deserialize_outputs_to_string(inner_data["outputs_to_string"])
merged_data = {**inner_data, "pipeline": pipeline}
return cls(**merged_data)
+386
View File
@@ -0,0 +1,386 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import copy
from collections.abc import Iterator
from typing import TYPE_CHECKING, Annotated, Any
from haystack.core.serialization import generate_qualified_class_name
from haystack.dataclasses import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
from haystack.tools.from_function import create_tool_from_function
from haystack.tools.serde_utils import deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset
from haystack.tools.tool import Tool, _check_duplicate_tool_names
from haystack.tools.toolset import Toolset
from haystack.tools.utils import flatten_tools_or_toolsets, warm_up_tools
if TYPE_CHECKING:
from haystack.tools import ToolsType
class SearchableToolset(Toolset):
"""
Dynamic tool discovery from large catalogs using BM25 search.
This Toolset enables LLMs to discover and use tools from large catalogs through BM25-based search.
Instead of exposing all tools at once (which can overwhelm the LLM context), it provides a `search_tools` bootstrap
tool that allows the LLM to find and load specific tools as needed.
For very small catalogs (below `search_threshold`), acts as a simple passthrough exposing all tools directly
without any discovery mechanism.
### Usage Example
```python
from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.tools import SearchableToolset, tool
@tool
def get_weather(city: Annotated[str, "The city to get the weather for"]) -> str:
'''Get the current weather for a city.'''
return f"The weather in {city} is 22°C and sunny."
@tool
def search_web(query: Annotated[str, "The query to search the web for"]) -> str:
'''Search the web for a query.'''
return f"Top result for '{query}': ..."
@tool
def convert_currency(
amount: Annotated[float, "The amount to convert"],
to_currency: Annotated[str, "The currency to convert to, e.g. 'EUR'"],
) -> str:
'''Convert an amount in USD to another currency.'''
return f"{amount} USD is {amount * 0.9} {to_currency}"
# search_threshold=2 means a catalog of 2+ tools activates discovery: the agent only sees the
# `search_tools` tool and must search to load the others (set it higher for larger catalogs).
toolset = SearchableToolset(catalog=[get_weather, search_web, convert_currency], search_threshold=2)
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
# The agent is initially provided only with the search_tools tool and will use it to find relevant tools.
result = agent.run(messages=[ChatMessage.from_user("What's the weather in Milan?")])
print(result["last_message"].text)
```
"""
_VALID_SEARCH_TOOL_PARAMS = {"tool_keywords", "k"}
def __init__(
self,
catalog: "ToolsType",
*,
top_k: int = 3,
search_threshold: int = 8,
search_tool_name: str = "search_tools",
search_tool_description: str | None = None,
search_tool_parameters_description: dict[str, str] | None = None,
) -> None:
"""
Initialize the SearchableToolset.
:param catalog: Source of tools - a list of Tools, list of Toolsets, or a single Toolset.
:param top_k: Default number of results for search_tools.
:param search_threshold: Minimum catalog size to activate search. If catalog has fewer tools, acts as
passthrough (all tools visible). Default is 8.
:param search_tool_name: Custom name for the bootstrap search tool. Default is "search_tools".
:param search_tool_description: Custom description for the bootstrap search tool. If not provided, uses a
default description.
:param search_tool_parameters_description: Custom descriptions for the bootstrap search tool's parameters.
Keys must be a subset of `{"tool_keywords", "k"}`.
Example: `{"tool_keywords": "Keywords to find tools, e.g. 'email send'"}`
"""
valid_catalog = isinstance(catalog, Toolset) or (
isinstance(catalog, list) and all(isinstance(item, (Tool, Toolset)) for item in catalog)
)
if not valid_catalog:
raise TypeError(
f"Invalid catalog type: {type(catalog)}. Expected Tool, Toolset, or list of Tools and/or Toolsets."
)
if search_tool_parameters_description is not None:
invalid_keys = set(search_tool_parameters_description.keys()) - self._VALID_SEARCH_TOOL_PARAMS
if invalid_keys:
raise ValueError(
f"Invalid search_tool_parameters_description keys: {invalid_keys}. "
f"Valid keys are: {self._VALID_SEARCH_TOOL_PARAMS}"
)
# Store raw catalog; flattening is deferred to warm_up() so that lazy toolsets
# (e.g. MCPToolset with eager_connect=False) can connect first.
self._raw_catalog: "ToolsType" = catalog
self._catalog: list[Tool] = []
self._top_k = top_k
self._search_threshold = search_threshold
self._search_tool_name = search_tool_name
self._search_tool_description = search_tool_description
self._search_tool_parameters_description = search_tool_parameters_description
# Runtime state (initialized in warm_up)
self._discovered_tools: dict[str, Tool] = {}
self._bootstrap_tool: Tool | None = None
self._document_store: InMemoryDocumentStore | None = None
self._passthrough: bool | None = None
self._is_warmed_up = False
# Initialize parent with empty tools list - we manage tools dynamically
super().__init__(tools=[])
def __add__(self, other: Tool | Toolset | list[Tool]) -> "Toolset":
"""Concatenation is not supported for SearchableToolset."""
raise NotImplementedError("SearchableToolset does not support concatenation.")
def add(self, tool: Tool | Toolset) -> None:
"""Adding new tools after initialization is not supported for SearchableToolset."""
raise NotImplementedError("SearchableToolset does not support adding new tools after initialization.")
def warm_up(self) -> None:
"""
Prepare the toolset for use.
Warms up the catalog (so lazy toolsets like MCPToolset can connect) and flattens it. Above the passthrough
threshold, it also indexes the catalog and creates the search_tools bootstrap tool.
This method is idempotent: it only warms up the toolset the first time it is called.
:raises ValueError: If the flattened catalog contains tools with duplicate names.
"""
if self._is_warmed_up:
return
# Warm up the catalog first (triggers lazy connections like MCPToolset), then flatten — lazy toolsets will
# have their real tools available.
warm_up_tools(self._raw_catalog)
self._catalog = flatten_tools_or_toolsets(self._raw_catalog)
_check_duplicate_tool_names(self._catalog)
self._passthrough = len(self._catalog) < self._search_threshold
# Build the BM25 search index only when the catalog is large enough to need discovery.
if not self._passthrough:
# shared=False keeps the BM25 index instance-local so it is freed with this toolset instead of
# accumulating in InMemoryDocumentStore's process-global storage (e.g. when a SearchableToolset is
# built per request in a served application).
self._document_store = InMemoryDocumentStore(shared=False)
documents = [
Document(content=f"{tool.name} {tool.description}", meta={"tool_name": tool.name})
for tool in self._catalog
]
self._document_store.write_documents(documents, policy=DuplicatePolicy.OVERWRITE)
self._bootstrap_tool = self._create_search_tool()
self._is_warmed_up = True
def get_selectable_tools(self) -> list[Tool]:
"""
Return the full catalog of tools that can be selected by name.
Iteration only exposes the search tool plus already-discovered tools, but name-based selection can target
any tool in the catalog, so this returns the entire flattened catalog (warming up first if needed).
:returns: The flattened catalog of tools.
"""
if not self._is_warmed_up:
self.warm_up()
return list(self._catalog)
def clear(self) -> None:
"""
Clear all discovered tools.
This method allows resetting the toolset's discovered tools between agent runs when the same toolset instance
is reused. This can be useful for long-running applications to control memory usage or to start fresh searches.
"""
self._discovered_tools.clear()
def spawn(self) -> "SearchableToolset":
"""
Return an isolated copy for a single run.
The copy shares the read-only catalog and BM25 index but gets fresh discovered tools and name selection,
plus a bootstrap search tool bound to the copy. This way concurrent runs sharing the same configured
SearchableToolset don't share discovered tools or collide on the active selection.
:returns: A run-scoped copy of this SearchableToolset.
"""
if not self._is_warmed_up:
self.warm_up()
new = copy.copy(self)
new._discovered_tools = {}
new._selected_tool_names = None
# Rebuild the bootstrap tool so its closure is bound to the copy's discovered tools / selection
# rather than the original's. The document store and catalog are read-only and stay shared.
if not self._passthrough:
new._bootstrap_tool = new._create_search_tool()
return new
def _create_search_tool(self) -> Tool:
"""Create the search_tools bootstrap tool."""
tool_by_name = {tool.name: tool for tool in self._catalog}
def search_tools(
tool_keywords: Annotated[
str,
"Space-separated words from tool names/descriptions (e.g. 'route weather search')."
" NOT the user's question or task—use vocabulary from the tools you need.",
],
k: Annotated[int | None, f"Number of results to return (default: {self._top_k})"] = None,
) -> str:
"""
ALWAYS use this tool FIRST when you need to invoke some tools but don't have the right one loaded yet.
Provide space separated tool keywords likely to appear in tool names/descriptions
(e.g. 'route distance weather', 'search email').
Do NOT pass the user's request or task (e.g. 'things to do in X', 'user question'); matching is
keyword-based.
Returns loaded tool names; they become available immediately.
"""
num_results = k if k is not None else self._top_k
if not tool_keywords.strip():
return (
"No tool keywords provided. Please provide space-separated words likely to appear in tool "
"names/descriptions (e.g. 'route weather search')."
)
# Scope the search to the selected subset if active so that top_k applies within the selected tools
filters = None
if self._selected_tool_names is not None:
filters = {"field": "meta.tool_name", "operator": "in", "value": list(self._selected_tool_names)}
# at this point, the toolset has been warmed up, so self._document_store is not None
results = self._document_store.bm25_retrieval( # type: ignore[union-attr]
query=tool_keywords, top_k=num_results, filters=filters
)
if not results:
return "No tools found matching these keywords. Try different keywords."
# Add found tools to _discovered_tools. These become available to the LLM on the next agent iteration
# when __iter__ is called again - the Agent re-iterates over the toolset each loop, picking up newly
# discovered tools.
# The return message here just confirms what was found; actual tool availability comes through the dynamic
# iteration mechanism. This way we also save tokens by not returning the full tool definitions.
#
# NOTE: The Agent can run tool calls in a step concurrently (ThreadPoolExecutor), so multiple search_tools
# calls can mutate self._discovered_tools from different threads at once. This is currently safe only
# because CPython's GIL makes individual dict assignments atomic; on a free-threaded (no-GIL) build these
# unguarded writes could corrupt the dict.
tool_names = []
for doc in results:
tool = tool_by_name[doc.meta["tool_name"]]
self._discovered_tools[tool.name] = tool
tool_names.append(tool.name)
return f"Found and loaded {len(tool_names)} tool(s): {', '.join(tool_names)}. Use them directly as tools."
bootstrap_tool = create_tool_from_function(
function=search_tools, name=self._search_tool_name, description=self._search_tool_description
)
# Override parameter descriptions if custom ones were provided
if self._search_tool_parameters_description:
for param_name, desc in self._search_tool_parameters_description.items():
if param_name in bootstrap_tool.parameters.get("properties", {}):
bootstrap_tool.parameters["properties"][param_name]["description"] = desc
return bootstrap_tool
def _is_selected(self, name: str) -> bool:
"""Whether a catalog tool name is allowed by the active `_selected_tool_names` filter (None means all)."""
return self._selected_tool_names is None or name in self._selected_tool_names
def __iter__(self) -> Iterator[Tool]:
"""
Iterate over available tools.
In passthrough mode, yields all catalog tools. Otherwise, yields the bootstrap search tool plus the
already-discovered tools. If `_selected_tool_names` is set, catalog/discovered tools are restricted to that
set, but the bootstrap search tool is always exposed so search keeps working over the selected subset.
Automatically calls warm_up() if needed to ensure the bootstrap tool is available.
"""
# Unlike base Toolset/MCPToolset, which expose a placeholder tool before warm_up, this toolset materializes
# everything (flattened catalog, bootstrap tool, passthrough decision) in warm_up.
# Without warming here, iterating before warm_up would yield nothing, so we warm up to make the toolset usable
# at all.
if not self._is_warmed_up:
self.warm_up()
if self._passthrough:
yield from (tool for tool in self._catalog if self._is_selected(tool.name))
else:
if self._bootstrap_tool is not None:
yield self._bootstrap_tool
yield from (tool for tool in self._discovered_tools.values() if self._is_selected(tool.name))
def __len__(self) -> int:
"""Return the number of currently available tools."""
# the number of tools is computed by invoking __iter__ on the toolset
return sum(1 for _ in self)
def __contains__(self, item: str | Tool) -> bool:
"""
Check if a tool is available by Tool instance or tool name string.
:param item: Tool instance or tool name string.
:returns: True if the tool is available, False otherwise.
"""
if isinstance(item, str):
return any(tool.name == item for tool in self)
if isinstance(item, Tool):
return any(tool == item for tool in self)
raise TypeError(f"Invalid item type: {type(item)}. Must be Tool or str.")
def __getitem__(self, index: int) -> Tool:
"""
Get a tool by index.
:param index: Index of the tool to retrieve.
:returns: The tool at the given index.
:raises IndexError: If the index is out of range.
"""
return list(self)[index]
def to_dict(self) -> dict[str, Any]:
"""
Serialize the toolset to a dictionary.
:returns: Dictionary representation of the toolset.
"""
data: dict[str, Any] = {
"catalog": serialize_tools_or_toolset(self._raw_catalog),
"top_k": self._top_k,
"search_threshold": self._search_threshold,
"search_tool_name": self._search_tool_name,
"search_tool_description": self._search_tool_description,
"search_tool_parameters_description": self._search_tool_parameters_description,
}
return {"type": generate_qualified_class_name(type(self)), "data": data}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SearchableToolset":
"""
Deserialize a toolset from a dictionary.
:param data: Dictionary representation of the toolset.
:returns: New SearchableToolset instance.
:raises TypeError: If a serialized catalog entry is not a subclass of Tool or Toolset.
"""
inner_data = data["data"]
deserialize_tools_or_toolset_inplace(inner_data, key="catalog")
optional_keys = (
"top_k",
"search_threshold",
"search_tool_name",
"search_tool_description",
"search_tool_parameters_description",
)
return cls(catalog=inner_data["catalog"], **{k: inner_data[k] for k in optional_keys if k in inner_data})
+82
View File
@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import TYPE_CHECKING, Any
from haystack.core.errors import DeserializationError
from haystack.core.serialization import import_class_by_name
from haystack.tools.tool import Tool
from haystack.tools.toolset import Toolset
if TYPE_CHECKING:
from haystack.tools import ToolsType
def serialize_tools_or_toolset(tools: "ToolsType | None") -> dict[str, Any] | list[dict[str, Any]] | None:
"""
Serialize tools or toolsets to dictionaries.
:param tools: A Toolset, a list of Tools and/or Toolsets, or None
:returns: Serialized representation preserving Tool/Toolset boundaries when provided
"""
if tools is None:
return None
if isinstance(tools, Toolset):
return tools.to_dict()
if isinstance(tools, list):
serialized: list[dict[str, Any]] = []
for item in tools:
if isinstance(item, (Toolset, Tool)):
serialized.append(item.to_dict())
else:
raise TypeError("Items in the tools list must be Tool or Toolset instances.")
return serialized
raise TypeError("tools must be Toolset, list[Union[Tool, Toolset]], or None")
def deserialize_tools_or_toolset_inplace(data: dict[str, Any], key: str = "tools") -> None:
"""
Deserialize a list of Tools and/or Toolsets, or a single Toolset in a dictionary inplace.
:param data:
The dictionary with the serialized data.
:param key:
The key in the dictionary where the list of Tools and/or Toolsets, or single Toolset is stored.
"""
if key in data:
serialized_tools = data[key]
if serialized_tools is None:
return
# Check if it's a serialized Toolset (a dict with "type" and "data" keys)
if isinstance(serialized_tools, dict) and all(k in serialized_tools for k in ["type", "data"]):
toolset_class_name = serialized_tools.get("type")
if not toolset_class_name:
raise DeserializationError("The 'type' key is missing or None in the serialized toolset data")
toolset_class = import_class_by_name(toolset_class_name)
if not issubclass(toolset_class, Toolset):
raise TypeError(f"Class '{toolset_class}' is not a subclass of Toolset")
data[key] = toolset_class.from_dict(serialized_tools)
return
if not isinstance(serialized_tools, list):
raise TypeError(f"The value of '{key}' is not a list or a dictionary")
deserialized_tools: list[Tool | Toolset] = []
for tool in serialized_tools:
if not isinstance(tool, dict):
raise TypeError(f"Serialized tool '{tool}' is not a dictionary")
# different classes are allowed: Tool, ComponentTool, Toolset, etc.
tool_class = import_class_by_name(tool["type"])
if issubclass(tool_class, (Tool, Toolset)):
deserialized_tools.append(tool_class.from_dict(tool))
else:
raise TypeError(f"Class '{tool_class}' is neither Tool nor Toolset")
data[key] = deserialized_tools
+7
View File
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack.tools.skills.skill_toolset import SkillToolset
__all__ = ["SkillToolset"]
+195
View File
@@ -0,0 +1,195 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Annotated, Any
from haystack.core.serialization import generate_qualified_class_name
from haystack.dataclasses.file_content import FileContent
from haystack.dataclasses.image_content import ImageContent
from haystack.dataclasses.skill_info import SkillInfo
from haystack.skill_stores.types.protocol import SkillStore
from haystack.tools.from_function import create_tool_from_function
from haystack.tools.tool import Tool
from haystack.tools.toolset import Toolset
from haystack.utils.deserialization import deserialize_component_inplace
class SkillToolset(Toolset):
"""
A Toolset that lets an Agent discover and read skills via progressive disclosure.
A skill is a directory (or equivalent storage unit) containing a `SKILL.md` file with YAML frontmatter
(`name` and `description`) and a markdown body of instructions. Skills may bundle additional files
(reference docs, examples, templates).
- On `warm_up`, the name and description of every discovered skill are baked into the `load_skill` tool
description so the model knows which skills exist without any system prompt injection.
- `load_skill` returns a skill's full instructions on demand, plus a manifest of its bundled files.
- `read_skill_file` reads a bundled file on demand.
### Usage example
```python
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.tools import SkillToolset
from haystack.skill_stores.file_system import FileSystemSkillStore
store = FileSystemSkillStore("skills/")
skills_toolset = SkillToolset(store)
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=skills_toolset)
result = agent.run(messages=[ChatMessage.from_user("Fill in this PDF form for me.")])
```
Expected filesystem layout:
```
skills/
pdf-forms/
SKILL.md # frontmatter (name, description) + markdown instructions
reference/forms.md
```
The tool names `load_skill` and `read_skill_file` are fixed, so an `Agent` can use at most one
`SkillToolset`. To serve skills from multiple sources, back a single toolset with a custom store that
merges them.
"""
def __init__(self, store: SkillStore) -> None:
"""
Initialize the SkillToolset.
Constructing the toolset does not read any skills. The store is queried for the available skills on
`warm_up()`, so stores that do I/O (reading a directory, connecting to a database) stay cheap to
construct.
The `load_skill` and `read_skill_file` tools are created right away, so the toolset can be used as a
collection (length, membership checks, iteration) immediately.
:param store: A `haystack.skill_stores.types.SkillStore` instance to back this toolset.
"""
self._store = store
self._skills: dict[str, SkillInfo] = {}
self._is_warmed_up = False
# We create both tools now and dynamically update the `load_skill` description at warm-up with the discovered
# catalog
self._load_skill_tool = self._create_load_skill_tool()
super().__init__(tools=[self._load_skill_tool, self._create_read_skill_file_tool()])
@property
def skills(self) -> dict[str, SkillInfo]:
"""Mapping of skill name to its metadata. Triggers `warm_up()` on first access if not already warmed up."""
if not self._is_warmed_up:
self.warm_up()
return self._skills
def warm_up(self) -> None:
"""
Discover the available skills from the store and bake the catalog into the `load_skill` description.
Only the description content is dynamic, so the (static) tools created in `__init__` are reused; this
refreshes `load_skill`'s description once the catalog is known. Idempotent: repeated calls after the
first are no-ops.
"""
if self._is_warmed_up:
return
if hasattr(self._store, "warm_up"):
self._store.warm_up()
self._skills = self._store.list_skills()
self._load_skill_tool.description = self._load_skill_description()
self._is_warmed_up = True
def add(self, tool: Tool | Toolset) -> None:
"""Adding tools is not supported: a SkillToolset's tools are fixed and defined by its store."""
raise NotImplementedError(
"SkillToolset does not support adding tools. To combine it with other tools, pass it to the Agent "
"alongside them, e.g. tools=[skill_toolset, other_tool]."
)
def __add__(self, other: Tool | Toolset | list[Tool]) -> "Toolset":
"""Concatenation is not supported for SearchableToolset."""
raise NotImplementedError(
"SkillToolset does not support concatenation. To combine it with other tools, pass it to the Agent "
"alongside them, e.g. tools=[skill_toolset, other_tool]."
)
def _load_skill_description(self) -> str:
"""
Build the `load_skill` tool description, including the catalog of discovered skills.
The available skills (name + description) are baked into the description so the model can see which skills
exist and decide when to load one, without relying on any system prompt injection.
:returns: The tool description text.
"""
lines = [
"Load a skill's full instructions before doing a task it covers. Skills are specialized instruction "
"sets for specific task types; once loaded, follow them exactly (they override your general approach). "
"If a loaded skill references a bundled file, fetch it with `read_skill_file`."
]
if self._skills:
lines += ["", "Available skills:"]
lines += [f"- {meta.name}: {meta.description}" for meta in self._skills.values()]
else:
lines += ["", "No skills are currently available."]
return "\n".join(lines)
def _create_load_skill_tool(self) -> Tool:
"""Create the `load_skill` tool, closed over this toolset's store."""
def load_skill(name: Annotated[str, "Exact name of the skill to load, from the Available skills list."]) -> str:
# The store raises an actionable error (e.g. unknown skill) on failure. We let it propagate so the Agent
# applies its own tool-failure policy.
body, bundled = self._store.load_skill(name)
if bundled:
manifest = "\n".join(f"- {path}" for path in bundled)
body = f"{body}\n\nBundled files (read with `read_skill_file`):\n{manifest}"
return body
return create_tool_from_function(
function=load_skill, name="load_skill", description=self._load_skill_description()
)
def _create_read_skill_file_tool(self) -> Tool:
"""Create the `read_skill_file` tool, closed over this toolset's store."""
def read_skill_file(
name: Annotated[str, "Name of the skill that owns the file."],
path: Annotated[str, "Path of the file relative to the skill directory, e.g. 'reference/forms.md'."],
) -> str | list[ImageContent | FileContent]:
"""Read a file bundled with a skill (reference docs, examples, templates, images, PDFs)."""
# The store raises an actionable error (e.g. unknown skill) on failure. We let it propagate so the Agent
# applies its own tool-failure policy.
content = self._store.read_skill_file(name, path)
# Text is returned as-is; images/PDFs are wrapped in a list so they ride back as multimodal tool-result
# content parts for the model to ingest directly.
return content if isinstance(content, str) else [content]
# raw_result keeps ImageContent/FileContent intact instead of stringifying them, so they reach the model as
# image/file content parts. This requires a multimodal-capable generator (e.g. OpenAIResponsesChatGenerator).
return create_tool_from_function(
function=read_skill_file, name="read_skill_file", outputs_to_string={"raw_result": True}
)
def to_dict(self) -> dict[str, Any]:
"""
Serialize the toolset to a dictionary.
:returns: Dictionary representation of the toolset.
"""
return {"type": generate_qualified_class_name(type(self)), "data": {"store": self._store.to_dict()}}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SkillToolset":
"""
Deserialize a toolset from a dictionary.
:param data: Dictionary representation of the toolset, as produced by `to_dict`.
:returns: A new SkillToolset instance.
"""
inner_data = data["data"]
deserialize_component_inplace(inner_data, key="store")
return cls(**inner_data)
+457
View File
@@ -0,0 +1,457 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import inspect
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from haystack.core.serialization import generate_qualified_class_name
from haystack.tools.errors import ToolInvocationError
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
@dataclass
class Tool:
"""
Data class representing a Tool that Language Models can prepare a call for.
Accurate definitions of the textual attributes such as `name` and `description`
are important for the Language Model to correctly prepare the call.
For resource-intensive operations like establishing connections to remote services or
loading models, override the `warm_up()` method. This method is called before the Tool
is used and should be idempotent, as it may be called multiple times during
pipeline/agent setup.
:param name:
Name of the Tool.
:param description:
Description of the Tool.
:param parameters:
A JSON schema defining the parameters expected by the Tool.
: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.
:param async_function:
Optional coroutine function awaited by `Tool.invoke_async`. When only `async_function` is set, `invoke` raises
a `ToolInvocationError`. When only `function` is set, `invoke_async` falls back to running `function` in a
worker thread via `asyncio.to_thread`.
:param outputs_to_string:
Optional dictionary defining how tool outputs should be converted into string(s) or results.
If not provided, the tool result is converted to a string using a default handler.
`outputs_to_string` supports two formats:
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
```python
{
"source": "docs", "handler": format_documents, "raw_result": False
}
```
- `source`: If provided, only the specified output key is sent to the handler. If not provided, the whole
tool result is sent to the handler.
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
final result.
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the `handler`
if provided. This is intended for tools that return images. In this mode, the Tool function or the
`handler` must return a list of `TextContent`/`ImageContent` objects to ensure compatibility with Chat
Generators.
2. Multiple output format - map keys to individual configurations:
```python
{
"formatted_docs": {"source": "docs", "handler": format_documents},
"summary": {"source": "summary_text", "handler": str.upper}
}
```
Each key maps to a dictionary that can contain "source" and/or "handler".
Note that `raw_result` is not supported in the multiple output format.
:param inputs_from_state:
Optional dictionary mapping state keys to tool parameter names.
Example: `{"repository": "repo"}` maps state's "repository" to tool's "repo" parameter.
:param outputs_to_state:
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
If the source is provided only the specified output key is sent to the handler.
Example:
```python
{
"documents": {"source": "docs", "handler": custom_handler}
}
```
If the source is omitted the whole tool result is sent to the handler.
Example:
```python
{
"documents": {"handler": custom_handler}
}
```
:raises ValueError: If neither `function` nor `async_function` is provided, if `function` is a
coroutine function, if `async_function` is not a coroutine function, if `parameters` is not a
valid JSON schema, or if the `outputs_to_state`, `outputs_to_string`, or `inputs_from_state`
configurations are invalid.
:raises TypeError: If any configuration value in `outputs_to_state`, `outputs_to_string`, or
`inputs_from_state` has the wrong type.
"""
name: str
description: str
parameters: dict[str, Any]
function: Callable | None = None
outputs_to_string: dict[str, Any] | None = None
inputs_from_state: dict[str, str] | None = None
outputs_to_state: dict[str, dict[str, Any]] | None = None
async_function: Callable | None = None
def __post_init__(self) -> None: # noqa: C901, PLR0912
# At least one of function / async_function must be set.
if self.function is None and self.async_function is None:
raise ValueError(f"Tool '{self.name}' requires at least one of `function` or `async_function` to be set.")
# `function` must be a regular (sync) function. Coroutine functions belong on `async_function`.
if self.function is not None and inspect.iscoroutinefunction(self.function):
raise ValueError(
f"`function` must be a synchronous function. "
f"The function '{self.function.__name__}' is a coroutine function. "
f"Pass it as `async_function` instead."
)
# `async_function` must be a coroutine function defined with `async def`.
if self.async_function is not None and not inspect.iscoroutinefunction(self.async_function):
raise ValueError(
f"`async_function` must be a coroutine function defined with `async def`. "
f"Got '{getattr(self.async_function, '__name__', repr(self.async_function))}'."
)
# Check that the parameters define a valid JSON schema
try:
Draft202012Validator.check_schema(self.parameters)
except SchemaError as e:
raise ValueError("The provided parameters do not define a valid JSON schema") from e
# Validate outputs structure if provided
if self.outputs_to_state is not None:
for key, config in self.outputs_to_state.items():
if not isinstance(config, dict):
raise TypeError(f"outputs_to_state configuration for key '{key}' must be a dictionary")
if "source" in config and not isinstance(config["source"], str):
raise ValueError(f"outputs_to_state source for key '{key}' must be a string.")
if "handler" in config and not callable(config["handler"]):
raise ValueError(f"outputs_to_state handler for key '{key}' must be callable")
# Validate that outputs_to_state source keys exist as valid tool outputs
valid_outputs: set[str] | None = self._get_valid_outputs()
if valid_outputs is not None:
for state_key, config in self.outputs_to_state.items():
source = config.get("source")
if source is not None and source not in valid_outputs:
raise ValueError(
f"outputs_to_state: '{self.name}' maps state key '{state_key}' to unknown output '{source}'"
f"Valid outputs are: {valid_outputs}."
)
if self.outputs_to_string is not None:
if "source" in self.outputs_to_string and not isinstance(self.outputs_to_string["source"], str):
raise ValueError("outputs_to_string source must be a string.")
if "handler" in self.outputs_to_string and not callable(self.outputs_to_string["handler"]):
raise ValueError("outputs_to_string handler must be callable")
if "raw_result" in self.outputs_to_string and not isinstance(self.outputs_to_string["raw_result"], bool):
raise ValueError("outputs_to_string raw_result must be a boolean.")
if (
"source" in self.outputs_to_string
or "handler" in self.outputs_to_string
or "raw_result" in self.outputs_to_string
):
# Single output configuration
for key in self.outputs_to_string:
if key not in {"source", "handler", "raw_result"}:
raise ValueError(
"Invalid outputs_to_string config. "
"When using 'source', 'handler' or 'raw_result' at the root level, no other keys are "
" allowed. Use individual output configs instead."
)
else:
# Multiple outputs configuration
for key, config in self.outputs_to_string.items():
if not isinstance(config, dict):
raise TypeError(f"outputs_to_string configuration for key '{key}' must be a dictionary")
if "raw_result" in config:
raise ValueError(
f"Invalid outputs_to_string configuration for key '{key}': "
f"'raw_result' is not supported in the multiple output format."
)
if "source" not in config:
raise ValueError(
f"Invalid outputs_to_string configuration for key '{key}': "
f"each output must have a 'source' defined."
)
if "source" in config and not isinstance(config["source"], str):
raise ValueError(f"outputs_to_string source for key '{key}' must be a string.")
if "handler" in config and not callable(config["handler"]):
raise ValueError(f"outputs_to_string handler for key '{key}' must be callable")
# Validate that inputs_from_state parameter names exist as valid tool parameters
if self.inputs_from_state is not None:
valid_inputs = self._get_valid_inputs()
for state_key, param_name in self.inputs_from_state.items():
if not isinstance(param_name, str):
raise TypeError(
f"inputs_from_state values must be str, not {type(param_name).__name__}. "
f"Got {param_name!r} for key '{state_key}'."
)
if valid_inputs and param_name not in valid_inputs:
raise ValueError(
f"inputs_from_state maps '{state_key}' to unknown parameter '{param_name}'. "
f"Valid parameters are: {valid_inputs}."
)
def _get_valid_inputs(self) -> set[str]:
"""
Return the set of valid input parameter names that this tool accepts.
Used to validate that `inputs_from_state` only references parameters that actually exist.
This prevents typos and catches configuration errors at tool construction time.
By default, introspects the function signature to get ALL parameters, including those
that may be excluded from the JSON schema (e.g., parameters mapped from state).
Falls back to schema properties if introspection fails.
Subclasses like ComponentTool override this to return component input socket names.
:returns: Set of valid input parameter names for validation.
"""
# Combine parameters from both function signature and schema for robustness
# Function signature includes all parameters (even those excluded from schema)
# Schema properties provide the validated parameter set
valid_params: set[str] = set()
# Try to get parameters from function introspection.
# Prefer `function`; fall back to `async_function` for async-only tools.
introspection_target = self.function if self.function is not None else self.async_function
if introspection_target is not None:
try:
sig = inspect.signature(introspection_target)
valid_params.update(sig.parameters.keys())
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())
return valid_params
def _get_valid_outputs(self) -> set[str] | None:
"""
Return the set of valid output names that this tool produces.
Used to validate that `outputs_to_state` only references outputs that actually exist.
This prevents typos and catches configuration errors at tool construction time.
By default, returns None because regular function-based tools don't have a formal
output schema. When None is returned, output validation is skipped.
Subclasses like ComponentTool override this to return component output socket names,
enabling validation for tools where outputs are known.
:returns: Set of valid output names for validation, or None to skip validation.
"""
return None
@property
def tool_spec(self) -> dict[str, Any]:
"""
Return the Tool specification to be used by the Language Model.
"""
return {"name": self.name, "description": self.description, "parameters": self.parameters}
def warm_up(self) -> None:
"""
Prepare the Tool for use.
Override this method to establish connections to remote services, load models,
or perform other resource-intensive initialization. This method should be idempotent,
as it may be called multiple times.
"""
pass
def invoke(self, **kwargs: Any) -> Any:
"""
Invoke the Tool synchronously with the provided keyword arguments.
:raises ToolInvocationError: If the Tool has no sync `function`, or if the underlying call
raises an exception.
"""
if self.function is None:
raise ToolInvocationError(
f"Tool `{self.name}` has no sync `function` and can only be invoked via `invoke_async` "
f"(use `Agent.run_async`).",
tool_name=self.name,
)
try:
result = self.function(**kwargs)
except Exception as e:
raise ToolInvocationError(
f"Failed to invoke Tool `{self.name}` with parameters {kwargs}. Error: {e}", tool_name=self.name
) from e
return result
async def invoke_async(self, **kwargs: Any) -> Any:
"""
Invoke the Tool asynchronously with the provided keyword arguments.
If `async_function` is set, it is awaited directly. Otherwise the sync `function` is dispatched to a worker
thread via `asyncio.to_thread`, which propagates the current context to the worker.
:raises ToolInvocationError: If the underlying call raises an exception.
"""
try:
if self.async_function is not None:
return await self.async_function(**kwargs)
# `function` is guaranteed to be set: __post_init__ enforces at least one of the two.
return await asyncio.to_thread(self.function, **kwargs) # type: ignore[arg-type]
except Exception as e:
raise ToolInvocationError(
f"Failed to invoke Tool `{self.name}` with parameters {kwargs}. Error: {e}", tool_name=self.name
) from e
def to_dict(self) -> dict[str, Any]:
"""
Serializes the Tool to a dictionary.
:returns:
Dictionary with serialized data.
"""
data = asdict(self)
data["function"] = serialize_callable(self.function) if self.function is not None else None
data["async_function"] = serialize_callable(self.async_function) if self.async_function is not None else None
if self.outputs_to_state is not None:
data["outputs_to_state"] = _serialize_outputs_to_state(self.outputs_to_state)
if self.outputs_to_string is not None:
data["outputs_to_string"] = _serialize_outputs_to_string(self.outputs_to_string)
return {"type": generate_qualified_class_name(type(self)), "data": data}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Tool":
"""
Deserializes the Tool from a dictionary.
:param data:
Dictionary to deserialize from.
:returns:
Deserialized Tool.
"""
init_parameters = data["data"]
init_parameters["function"] = (
deserialize_callable(init_parameters["function"]) if init_parameters.get("function") is not None else None
)
if init_parameters.get("async_function") is not None:
init_parameters["async_function"] = deserialize_callable(init_parameters["async_function"])
if "outputs_to_state" in init_parameters and init_parameters["outputs_to_state"]:
init_parameters["outputs_to_state"] = _deserialize_outputs_to_state(init_parameters["outputs_to_state"])
if init_parameters.get("outputs_to_string") is not None:
init_parameters["outputs_to_string"] = _deserialize_outputs_to_string(init_parameters["outputs_to_string"])
return cls(**init_parameters)
def _check_duplicate_tool_names(tools: list[Tool] | None) -> None:
"""
Checks for duplicate tool names and raises a ValueError if they are found.
:param tools: The list of tools to check.
:raises ValueError: If duplicate tool names are found.
"""
if tools is None:
return
tool_names = [tool.name for tool in tools]
duplicate_tool_names = {name for name in tool_names if tool_names.count(name) > 1}
if duplicate_tool_names:
raise ValueError(f"Duplicate tool names found: {duplicate_tool_names}")
def _convert_handler(config: dict[str, Any], converter: Callable[[Any], Any]) -> dict[str, Any]:
"""
Copies a single output config, converting its "handler" entry (if present) via `converter`.
:param config: A single output configuration dictionary that may contain a "handler" key.
:param converter: `serialize_callable` or `deserialize_callable`, applied to the "handler" value.
:returns: A copy of `config` with the "handler" value converted, if present.
"""
new_config = config.copy()
if "handler" in config:
new_config["handler"] = converter(config["handler"])
return new_config
def _convert_handler_in_configs(
configs: dict[str, dict[str, Any]], converter: Callable[[Any], Any]
) -> dict[str, dict[str, Any]]:
"""
Applies `_convert_handler` to every config in a dictionary of named output configs.
:param configs: A mapping of keys to output configuration dictionaries.
:param converter: `serialize_callable` or `deserialize_callable`, applied to each "handler" value.
:returns: A new mapping with the same keys, each config converted via `_convert_handler`.
"""
return {key: _convert_handler(config, converter) for key, config in configs.items()}
def _serialize_outputs_to_state(outputs_to_state: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""
Serializes the outputs_to_state dictionary, converting any callable handlers to their string representation.
:param outputs_to_state: The outputs_to_state dictionary to serialize.
:returns: The serialized outputs_to_state dictionary.
"""
return _convert_handler_in_configs(outputs_to_state, serialize_callable)
def _deserialize_outputs_to_state(outputs_to_state: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""
Deserializes the outputs_to_state dictionary, converting any string handlers back to callables.
:param outputs_to_state: The outputs_to_state dictionary to deserialize.
:returns: The deserialized outputs_to_state dictionary.
"""
return _convert_handler_in_configs(outputs_to_state, deserialize_callable)
def _serialize_outputs_to_string(outputs_to_string: dict[str, Any]) -> dict[str, Any]:
"""
Serializes the outputs_to_string dictionary, converting any callable handlers to their string representation.
:param outputs_to_string: The outputs_to_string dictionary to serialize.
:returns: The serialized outputs_to_string dictionary.
"""
if "source" in outputs_to_string or "handler" in outputs_to_string or "raw_result" in outputs_to_string:
# Single output configuration
return _convert_handler(outputs_to_string, serialize_callable)
# Multiple outputs configuration
return _convert_handler_in_configs(outputs_to_string, serialize_callable)
def _deserialize_outputs_to_string(outputs_to_string: dict[str, Any]) -> dict[str, Any]:
"""
Deserializes the outputs_to_string dictionary, converting any string handlers back to callables.
:param outputs_to_string: The outputs_to_string dictionary to deserialize.
:returns: The deserialized outputs_to_string dictionary.
"""
if "source" in outputs_to_string or "handler" in outputs_to_string or "raw_result" in outputs_to_string:
# Single output configuration
return _convert_handler(outputs_to_string, deserialize_callable)
# Multiple outputs configuration
return _convert_handler_in_configs(outputs_to_string, deserialize_callable)
+18
View File
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections.abc import Sequence
from haystack.tools.tool import Tool
from haystack.tools.toolset import Toolset
# Type alias for tools parameter - allows mixing Tools and Toolsets in a sequence
# Accepts either:
# - Sequence[Tool | Toolset]: Any sequence (list, tuple, etc.) containing Tools, Toolsets, or a mix of both
# - Toolset: A single Toolset (not in a sequence)
ToolsType = Sequence[Tool | Toolset] | Toolset
# `ToolsType` is this module's only public name; `__all__` keeps the imports above (Tool, Toolset, Sequence) from
# leaking as exports on `import *`.
__all__ = ["ToolsType"]
+444
View File
@@ -0,0 +1,444 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import copy
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
from haystack.tools.tool import Tool, _check_duplicate_tool_names
@dataclass
class Toolset:
"""
A collection of related Tools that can be used and managed as a cohesive unit.
Toolset serves two main purposes:
1. Group related tools together:
Toolset allows you to organize related tools into a single collection, making it easier
to manage and use them as a unit in Haystack pipelines.
Example:
```python
from typing import Annotated
from haystack.tools import tool, Toolset
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
# Create tools with the @tool decorator (the recommended way)
@tool
def add(a: Annotated[int, "first number"], b: Annotated[int, "second number"]) -> int:
'''Add two numbers.'''
return a + b
@tool
def subtract(a: Annotated[int, "first number"], b: Annotated[int, "second number"]) -> int:
'''Subtract b from a.'''
return a - b
# Create a toolset with the math tools
math_toolset = Toolset([add, subtract])
# Use the toolset with an Agent
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=math_toolset)
```
2. Base class for dynamic tool loading:
By subclassing Toolset, you can create implementations that dynamically load tools from external sources like
OpenAPI URLs, MCP servers, or other resources.
Example:
```python
from typing import Annotated
from haystack.core.serialization import generate_qualified_class_name
from haystack.tools import tool, Toolset
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
class CalculatorToolset(Toolset):
'''A toolset for calculator operations.'''
def __init__(self) -> None:
super().__init__(self._create_tools())
def _create_tools(self):
# These tools are defined statically for illustration purposes only.
# In a real-world scenario, you would dynamically load tools from an external source here.
@tool
def add(a: Annotated[int, "first number"], b: Annotated[int, "second number"]) -> int:
'''Add two numbers.'''
return a + b
@tool
def multiply(a: Annotated[int, "first number"], b: Annotated[int, "second number"]) -> int:
'''Multiply two numbers.'''
return a * b
return [add, multiply]
def to_dict(self):
return {
"type": generate_qualified_class_name(type(self)),
"data": {}, # no data to serialize as we define the tools dynamically
}
@classmethod
def from_dict(cls, data):
return cls() # Recreate the tools dynamically during deserialization
# Create the dynamic toolset and use it with an Agent
calculator_toolset = CalculatorToolset()
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=calculator_toolset)
```
Toolset implements the collection interface (__iter__, __contains__, __len__, __getitem__), making it behave like
a list of Tools. This makes it compatible with components that expect iterable tools, such as Agent or Haystack
chat generators.
When implementing a custom Toolset subclass for dynamic tool loading:
- Perform the dynamic loading in the __init__ method
- Override to_dict() and from_dict() methods if your tools are defined dynamically
- Serialize endpoint descriptors rather than tool instances if your tools are loaded from external sources
"""
# Use field() with default_factory to initialize the list
tools: list[Tool] = field(default_factory=list)
def __post_init__(self) -> None:
"""
Validate and set up the toolset after initialization.
This handles the case when tools are provided during initialization.
"""
# If initialization was done a single Tool, raise an error
if isinstance(self.tools, Tool):
raise TypeError("A single Tool cannot be directly passed to Toolset. Please use a list: Toolset([tool])")
# Check for duplicate tool names in the initial set
_check_duplicate_tool_names(self.tools)
# Tracks whether warm_up() has already run so subsequent calls become a no-op.
self._is_warmed_up = False
# Optional per-run name filter. When set, iteration only yields tools whose name is in this set.
# None means no filtering. Set on a per-run spawn(), so it never leaks across runs.
self._selected_tool_names: set[str] | None = None
def __iter__(self) -> Iterator[Tool]:
"""
Return an iterator over the Tools in this Toolset.
This allows the Toolset to be used wherever a list of Tools is expected. If a name filter is active,
only the tools whose names are in it are yielded.
:returns: An iterator yielding Tool instances
"""
for tool in self.tools:
if self._selected_tool_names is None or tool.name in self._selected_tool_names:
yield tool
def get_selectable_tools(self) -> list[Tool]:
"""
Return the full set of tools that can be selected by name, ignoring any active name filter.
This differs from iteration, which yields only the tools currently exposed (and respects the name filter).
Override this when a Toolset's iteration does not surface every selectable tool, so name-based selection
can still target the full set.
Warms up the Toolset first if needed, so lazily loaded tools (those a Toolset fetches in `warm_up()`)
are available for selection.
:returns: The list of tools available for name-based selection.
"""
if not self._is_warmed_up:
self.warm_up()
return list(self.tools)
def spawn(self) -> "Toolset":
"""
Return an isolated copy of this Toolset for a single run.
The copy shares this Toolset's read-only state (its tools and any warmed-up resources) but gets fresh
run-scoped state, so concurrent runs that share the same configured Toolset don't corrupt each other (for
example, one run's name selection leaking into another). Warms up first if needed so the copy shares the
warmed state. Subclasses with additional run-scoped state should override this.
:returns: A run-scoped copy of this Toolset.
"""
if not self._is_warmed_up:
self.warm_up()
new = copy.copy(self)
new._selected_tool_names = None
return new
def __contains__(self, item: str | Tool) -> bool:
"""
Check if a tool is in this Toolset.
Supports checking by:
- Tool instance: tool in toolset
- Tool name: "tool_name" in toolset
:param item: Tool instance or tool name string
:returns: True if contained, False otherwise
"""
if isinstance(item, str):
return any(tool.name == item for tool in self)
if isinstance(item, Tool):
return any(tool is item or tool == item for tool in self)
return False
def warm_up(self) -> None:
"""
Prepare the Toolset for use.
By default, this method iterates through and warms up all tools in the Toolset.
Subclasses can override this method to customize initialization behavior, such as:
- Setting up shared resources (database connections, HTTP sessions) instead of
warming individual tools
- Implementing custom initialization logic for dynamically loaded tools
- Controlling when and how tools are initialized
For example, a Toolset that manages tools from an external service (like MCPToolset)
might override this to initialize a shared connection rather than warming up
individual tools:
```python
class MCPToolset(Toolset):
def warm_up(self) -> None:
# Only warm up the shared MCP connection, not individual tools
self.mcp_connection = establish_connection(self.server_url)
```
This method is idempotent: it only warms up the tools the first time it is called.
Subclasses overriding it should preserve this contract (for example by guarding on
`self._is_warmed_up`).
"""
if self._is_warmed_up:
return
for tool in self.tools:
if hasattr(tool, "warm_up"):
tool.warm_up()
self._is_warmed_up = True
def add(self, tool: "Tool | Toolset") -> None:
"""
Add a new Tool or merge another Toolset.
If this Toolset has already been warmed up, the newly added Tool (or the tools of the
added Toolset) are warmed up immediately so they are ready to use without requiring a
second `warm_up()` call on the whole Toolset.
Note: adding a Toolset flattens it into its individual tools, so this is only recommended
for Toolsets that don't manage shared resources in their `warm_up()` (or `__init__`).
For example, combining with an `MCPToolset`, which owns a shared connection, is not
recommended: the connection's lifecycle would no longer be managed by the original
Toolset. In those cases combine Toolsets with `+` (which preserves each Toolset as a
unit via `_ToolsetWrapper`) instead.
:param tool: A Tool instance or another Toolset to add
:raises ValueError: If adding the tool would result in duplicate tool names
:raises TypeError: If the provided object is not a Tool or Toolset
"""
if not isinstance(tool, (Tool, Toolset)):
raise TypeError(f"Expected Tool or Toolset, got {type(tool).__name__}")
# Warm up the source before flattening so that lazily-loaded toolsets (e.g. MCPToolset)
# expose their tools, and so newly added tools are ready to use right away.
if self._is_warmed_up and hasattr(tool, "warm_up"):
tool.warm_up()
new_tools = [tool] if isinstance(tool, Tool) else list(tool)
# Check for duplicates before adding
combined_tools = self.tools + new_tools
_check_duplicate_tool_names(combined_tools)
self.tools.extend(new_tools)
def to_dict(self) -> dict[str, Any]:
"""
Serialize the Toolset to a dictionary.
:returns: A dictionary representation of the Toolset
Note for subclass implementers:
The default implementation is ideal for scenarios where Tool resolution is static. However, if your subclass
of Toolset dynamically resolves Tool instances from external sources—such as an MCP server, OpenAPI URL, or
a local OpenAPI specification—you should consider serializing the endpoint descriptor instead of the Tool
instances themselves. This strategy preserves the dynamic nature of your Toolset and minimizes the overhead
associated with serializing potentially large collections of Tool objects. Moreover, by serializing the
descriptor, you ensure that the deserialization process can accurately reconstruct the Tool instances, even
if they have been modified or removed since the last serialization. Failing to serialize the descriptor may
lead to issues where outdated or incorrect Tool configurations are loaded, potentially causing errors or
unexpected behavior.
"""
return {
"type": generate_qualified_class_name(type(self)),
"data": {"tools": [tool.to_dict() for tool in self.tools]},
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Toolset":
"""
Deserialize a Toolset from a dictionary.
:param data: Dictionary representation of the Toolset
:returns: A new Toolset instance
"""
inner_data = data["data"]
tools_data = inner_data.get("tools", [])
tools = []
for tool_data in tools_data:
tool_class = import_class_by_name(tool_data["type"])
if not issubclass(tool_class, Tool):
raise TypeError(f"Class '{tool_class}' is not a subclass of Tool")
tools.append(tool_class.from_dict(tool_data))
return cls(tools=tools)
def __add__(self, other: "Tool | Toolset | list[Tool]") -> "Toolset":
"""
Concatenate this Toolset with another Tool, Toolset, or list of Tools.
:param other: Another Tool, Toolset, or list of Tools to concatenate
:returns: A new Toolset containing all tools
:raises TypeError: If the other parameter is not a Tool, Toolset, or list of Tools
:raises ValueError: If the combination would result in duplicate tool names
"""
if isinstance(other, Tool):
return Toolset(tools=self.tools + [other])
if isinstance(other, Toolset):
return _ToolsetWrapper([self, other])
if isinstance(other, list) and all(isinstance(item, Tool) for item in other):
return Toolset(tools=self.tools + other)
raise TypeError(f"Cannot add {type(other).__name__} to Toolset")
def __len__(self) -> int:
"""
Return the number of Tools in this Toolset (respecting any active name filter).
:returns: Number of Tools
"""
return sum(1 for _ in self)
def __getitem__(self, index: int) -> Tool:
"""
Get a Tool by index (respecting any active name filter).
:param index: Index of the Tool to get
:returns: The Tool at the specified index
"""
return list(self)[index]
class _ToolsetWrapper(Toolset):
"""
A wrapper that holds multiple toolsets and provides a unified interface.
This is used internally when combining different types of toolsets to preserve
their individual configurations while still being usable with Agent and Haystack chat generators.
"""
def __init__(self, toolsets: list[Toolset]) -> None:
super().__init__([tool for toolset in toolsets for tool in toolset])
self.toolsets = toolsets
# Tracks whether warm_up() has already run so subsequent calls become a no-op.
self._is_warmed_up = False
def __iter__(self) -> Iterator[Tool]:
"""Iterate over all tools from all toolsets, honoring any active name filter."""
for toolset in self.toolsets:
for tool in toolset:
if self._selected_tool_names is None or tool.name in self._selected_tool_names:
yield tool
def get_selectable_tools(self) -> list[Tool]:
"""Return every selectable tool across all wrapped toolsets, ignoring any active filter."""
return [tool for toolset in self.toolsets for tool in toolset.get_selectable_tools()]
def spawn(self) -> "_ToolsetWrapper":
"""Return an isolated copy with each wrapped toolset spawned."""
return _ToolsetWrapper([toolset.spawn() for toolset in self.toolsets])
def __contains__(self, item: Any) -> bool:
"""Check if a tool is in any of the toolsets."""
return any(item in toolset for toolset in self.toolsets)
def warm_up(self) -> None:
"""
Warm up all wrapped toolsets.
This method is idempotent: it only warms up the wrapped toolsets the first time it is
called. The individual toolsets are themselves expected to have idempotent `warm_up()`
methods.
"""
if self._is_warmed_up:
return
for toolset in self.toolsets:
toolset.warm_up()
self._is_warmed_up = True
def to_dict(self) -> dict[str, Any]:
"""
Serialize the wrapper to a dictionary.
Each wrapped toolset is serialized via its own `to_dict()`, so any subclass that
overrides serialization (e.g. a toolset that serializes a connection/endpoint
descriptor) is preserved.
:returns: A dictionary representation of the wrapper.
"""
return {
"type": generate_qualified_class_name(type(self)),
"data": {"toolsets": [toolset.to_dict() for toolset in self.toolsets]},
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "_ToolsetWrapper":
"""
Deserialize a wrapper from a dictionary.
:param data: Dictionary representation of the wrapper.
:returns: A new `_ToolsetWrapper` instance.
:raises TypeError: If any serialized entry is not a subclass of Toolset.
"""
inner_data = data["data"]
toolsets_data = inner_data.get("toolsets", [])
toolsets = []
for toolset_data in toolsets_data:
toolset_class = import_class_by_name(toolset_data["type"])
if not issubclass(toolset_class, Toolset):
raise TypeError(f"Class '{toolset_class}' is not a subclass of Toolset")
toolsets.append(toolset_class.from_dict(toolset_data))
return cls(toolsets=toolsets)
def __len__(self) -> int:
"""Return total number of tools across all toolsets (respecting any active name filter)."""
return sum(1 for _ in self)
def __getitem__(self, index: int) -> Tool:
"""Get a tool by index across all toolsets."""
# Leverage iteration instead of manual index tracking
for i, tool in enumerate(self):
if i == index:
return tool
raise IndexError("ToolsetWrapper index out of range")
def __add__(self, other: Toolset | Tool | list[Tool]) -> "_ToolsetWrapper":
"""Add another toolset or tool to this wrapper."""
if isinstance(other, Toolset):
return _ToolsetWrapper(self.toolsets + [other])
if isinstance(other, Tool):
return _ToolsetWrapper(self.toolsets + [Toolset([other])])
if isinstance(other, list) and all(isinstance(item, Tool) for item in other):
return _ToolsetWrapper(self.toolsets + [Toolset(other)])
raise TypeError(f"Cannot add {type(other).__name__} to _ToolsetWrapper")
+64
View File
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import TYPE_CHECKING
from haystack.tools.tool import Tool
from haystack.tools.toolset import Toolset
if TYPE_CHECKING:
from haystack.tools import ToolsType
def warm_up_tools(tools: "ToolsType | None" = None) -> None:
"""
Warm up tools from various formats (Tools, Toolsets, or mixed lists).
For Toolset objects, this delegates to Toolset.warm_up(), which by default
warms up all tools in the Toolset. Toolset subclasses can override warm_up()
to customize initialization behavior (e.g., setting up shared resources).
:param tools: A list of Tool and/or Toolset objects, a single Toolset, or None.
"""
if tools is None:
return
# If tools is a single Toolset or Tool, warm it up
if isinstance(tools, (Toolset, Tool)):
if hasattr(tools, "warm_up"):
tools.warm_up()
return
# If tools is a list, warm up each item (Tool or Toolset)
if isinstance(tools, list):
for item in tools:
if hasattr(item, "warm_up"):
item.warm_up()
def flatten_tools_or_toolsets(tools: "ToolsType | None") -> list[Tool]:
"""
Flatten tools from various formats into a list of Tool instances.
:param tools: Tools in list[Union[Tool, Toolset]], Toolset, or None format.
:returns: A flat list of Tool instances.
"""
if tools is None:
return []
if isinstance(tools, Toolset):
return list(tools)
if isinstance(tools, list):
flattened: list[Tool] = []
for item in tools:
if isinstance(item, Toolset):
flattened.extend(list(item))
elif isinstance(item, Tool):
flattened.append(item)
else:
raise TypeError("Items in the tools list must be Tool or Toolset instances.")
return flattened
raise TypeError("tools must be list[Union[Tool, Toolset]], Toolset, or None")