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
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:
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user