Skip to content

feat: Add Toolset to tooling architecture #9161

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
002dcc0
Add Toolset abstraction
vblagoje Mar 15, 2025
04b9d23
Add reno note
vblagoje Mar 15, 2025
34d9508
More pydoc improvements
vblagoje Mar 15, 2025
d1bac91
Update test
vblagoje Mar 15, 2025
cf3f370
Merge branch 'main' into toolset
vblagoje Mar 18, 2025
3ddbb70
Simplify, Toolset is a dataclass
vblagoje Mar 18, 2025
25a1e41
Merge branch 'main' into toolset
vblagoje Mar 24, 2025
249bfd8
Wrap toolset instance with list
vblagoje Mar 24, 2025
1104309
Merge branch 'main' into toolset
vblagoje Apr 2, 2025
ca4c354
Add example
vblagoje Apr 2, 2025
12a4277
Toolset pydoc serde enhancement
vblagoje Apr 2, 2025
22af75f
Toolset as init param
vblagoje Apr 2, 2025
d94a26b
Fix types
vblagoje Apr 2, 2025
feab2d1
Linting
vblagoje Apr 3, 2025
543df7b
Merge branch 'main' into new_toolset
vblagoje Apr 3, 2025
3a2c281
Minor updates
vblagoje Apr 3, 2025
4042537
Merge branch 'main' into new_toolset
vblagoje Apr 3, 2025
6bba05e
PR feedback
vblagoje Apr 4, 2025
3c6b88a
Add to pydoc config, minor import fixes
vblagoje Apr 4, 2025
9a3b414
Improve pydoc example
vblagoje Apr 4, 2025
4bae9de
Improve coverage for test_toolset.py
vblagoje Apr 4, 2025
42c6167
Improve test_toolset.py, test custom toolset serde properly
vblagoje Apr 4, 2025
77cf733
Update haystack/utils/misc.py
vblagoje Apr 4, 2025
b0ad822
Rework Toolset pydoc
vblagoje Apr 4, 2025
2272a77
Another minor pydoc improvement
vblagoje Apr 4, 2025
8cdbd55
Prevent single Tool instantiating Toolset
vblagoje Apr 4, 2025
6622e27
Reduce number of integration tests
vblagoje Apr 4, 2025
128d16a
Remove some toolset tests from openai
vblagoje Apr 4, 2025
db7ecb2
Rework tests
vblagoje Apr 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pydoc/config/tools_api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ loaders:
- type: haystack_pydoc_tools.loaders.CustomPythonLoader
search_path: [../../../haystack/tools]
modules:
["tool", "from_function", "component_tool"]
["tool", "from_function", "component_tool", "toolset"]
ignore_when_discovered: ["__init__"]
processors:
- type: filter
Expand Down
32 changes: 20 additions & 12 deletions haystack/components/generators/chat/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
select_streaming_callback,
)
from haystack.tools.tool import Tool, _check_duplicate_tool_names, deserialize_tools_inplace
from haystack.tools.toolset import Toolset
from haystack.utils import Secret, deserialize_callable, deserialize_secrets_inplace, serialize_callable
from haystack.utils.misc import serialize_tools_or_toolset

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -81,7 +83,7 @@ def __init__( # pylint: disable=too-many-positional-arguments
generation_kwargs: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None,
max_retries: Optional[int] = None,
tools: Optional[List[Tool]] = None,
tools: Optional[Union[List[Tool], Toolset]] = None,
tools_strict: bool = False,
):
"""
Expand Down Expand Up @@ -127,7 +129,8 @@ def __init__( # pylint: disable=too-many-positional-arguments
Maximum number of retries to contact OpenAI after an internal error.
If not set, it defaults to either the `OPENAI_MAX_RETRIES` environment variable, or set to 5.
:param tools:
A list of tools for which the model can prepare calls.
A list of tools or a Toolset for which the model can prepare calls. This parameter can accept either a
list of `Tool` objects or a `Toolset` instance.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
Expand All @@ -140,10 +143,11 @@ def __init__( # pylint: disable=too-many-positional-arguments
self.organization = organization
self.timeout = timeout
self.max_retries = max_retries
self.tools = tools
self.tools = tools # Store tools as-is, whether it's a list or a Toolset
self.tools_strict = tools_strict

_check_duplicate_tool_names(tools)
# Check for duplicate tool names
_check_duplicate_tool_names(list(self.tools or []))

if timeout is None:
timeout = float(os.environ.get("OPENAI_TIMEOUT", "30.0"))
Expand Down Expand Up @@ -185,7 +189,7 @@ def to_dict(self) -> Dict[str, Any]:
api_key=self.api_key.to_dict(),
timeout=self.timeout,
max_retries=self.max_retries,
tools=[tool.to_dict() for tool in self.tools] if self.tools else None,
tools=serialize_tools_or_toolset(self.tools),
tools_strict=self.tools_strict,
)

Expand Down Expand Up @@ -213,7 +217,7 @@ def run(
streaming_callback: Optional[StreamingCallbackT] = None,
generation_kwargs: Optional[Dict[str, Any]] = None,
*,
tools: Optional[List[Tool]] = None,
tools: Optional[Union[List[Tool], Toolset]] = None,
tools_strict: Optional[bool] = None,
):
"""
Expand All @@ -228,8 +232,9 @@ def run(
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
:param tools:
A list of tools for which the model can prepare calls. If set, it will override the `tools` parameter set
during component initialization.
A list of tools or a Toolset for which the model can prepare calls. If set, it will override the
`tools` parameter set during component initialization. This parameter can accept either a list of
`Tool` objects or a `Toolset` instance.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
Expand Down Expand Up @@ -285,7 +290,7 @@ async def run_async(
streaming_callback: Optional[StreamingCallbackT] = None,
generation_kwargs: Optional[Dict[str, Any]] = None,
*,
tools: Optional[List[Tool]] = None,
tools: Optional[Union[List[Tool], Toolset]] = None,
tools_strict: Optional[bool] = None,
):
"""
Expand All @@ -304,8 +309,9 @@ async def run_async(
override the parameters passed during component initialization.
For details on OpenAI API parameters, see [OpenAI documentation](https://platform.openai.com/docs/api-reference/chat/create).
:param tools:
A list of tools for which the model can prepare calls. If set, it will override the `tools` parameter set
during component initialization.
A list of tools or a Toolset for which the model can prepare calls. If set, it will override the
`tools` parameter set during component initialization. This parameter can accept either a list of
`Tool` objects or a `Toolset` instance.
:param tools_strict:
Whether to enable strict schema adherence for tool calls. If set to `True`, the model will follow exactly
the schema provided in the `parameters` field of the tool definition, but this may increase latency.
Expand Down Expand Up @@ -362,7 +368,7 @@ def _prepare_api_call( # noqa: PLR0913
messages: List[ChatMessage],
streaming_callback: Optional[StreamingCallbackT] = None,
generation_kwargs: Optional[Dict[str, Any]] = None,
tools: Optional[List[Tool]] = None,
tools: Optional[Union[List[Tool], Toolset]] = None,
tools_strict: Optional[bool] = None,
) -> Dict[str, Any]:
# update generation kwargs by merging with the generation kwargs passed to the run method
Expand All @@ -372,6 +378,8 @@ def _prepare_api_call( # noqa: PLR0913
openai_formatted_messages = [message.to_openai_dict_format() for message in messages]

tools = tools or self.tools
if isinstance(tools, Toolset):
tools = list(tools)
tools_strict = tools_strict if tools_strict is not None else self.tools_strict
_check_duplicate_tool_names(tools)

Expand Down
64 changes: 57 additions & 7 deletions haystack/components/tools/tool_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

import inspect
import json
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

from haystack import component, default_from_dict, default_to_dict, logging
from haystack.core.component.sockets import Sockets
from haystack.dataclasses import ChatMessage, State, ToolCall
from haystack.tools.component_tool import ComponentTool
from haystack.tools.tool import Tool, ToolInvocationError, _check_duplicate_tool_names, deserialize_tools_inplace
from haystack.tools.toolset import Toolset
from haystack.utils.misc import serialize_tools_or_toolset

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -57,7 +59,8 @@ class ToolInvoker:

Usage example:
```python
from haystack.dataclasses import ChatMessage, ToolCall, Tool
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.tools import Tool
from haystack.components.tools import ToolInvoker

# Tool definition
Expand Down Expand Up @@ -108,14 +111,55 @@ def dummy_weather_function(city: str):
>> ]
>> }
```

Usage example with a Toolset:
```python
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.tools import Tool, Toolset
from haystack.components.tools import ToolInvoker

# Tool definition
def dummy_weather_function(city: str):
return f"The weather in {city} is 20 degrees."

parameters = {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}

tool = Tool(name="weather_tool",
description="A tool to get the weather",
function=dummy_weather_function,
parameters=parameters)

# Create a Toolset
toolset = Toolset([tool])

# Usually, the ChatMessage with tool_calls is generated by a Language Model
# Here, we create it manually for demonstration purposes
tool_call = ToolCall(
tool_name="weather_tool",
arguments={"city": "Berlin"}
)
message = ChatMessage.from_assistant(tool_calls=[tool_call])

# ToolInvoker initialization and run with Toolset
invoker = ToolInvoker(tools=toolset)
result = invoker.run(messages=[message])

print(result)
"""

def __init__(self, tools: List[Tool], raise_on_failure: bool = True, convert_result_to_json_string: bool = False):
def __init__(
self,
tools: Union[List[Tool], Toolset],
raise_on_failure: bool = True,
convert_result_to_json_string: bool = False,
):
"""
Initialize the ToolInvoker component.

:param tools:
A list of tools that can be invoked.
A list of tools that can be invoked or a Toolset instance that can resolve tools.
:param raise_on_failure:
If True, the component will raise an exception in case of errors
(tool not found, tool invocation errors, tool result conversion errors).
Expand All @@ -129,13 +173,20 @@ def __init__(self, tools: List[Tool], raise_on_failure: bool = True, convert_res
"""
if not tools:
raise ValueError("ToolInvoker requires at least one tool.")

# could be a Toolset instance or a list of Tools
self.tools = tools

# Convert Toolset to list for internal use
if isinstance(tools, Toolset):
tools = list(tools)

_check_duplicate_tool_names(tools)
tool_names = [tool.name for tool in tools]
duplicates = {name for name in tool_names if tool_names.count(name) > 1}
if duplicates:
raise ValueError(f"Duplicate tool names found: {duplicates}")

self.tools = tools
self._tools_with_names = dict(zip(tool_names, tools))
self.raise_on_failure = raise_on_failure
self.convert_result_to_json_string = convert_result_to_json_string
Expand Down Expand Up @@ -385,10 +436,9 @@ def to_dict(self) -> Dict[str, Any]:
:returns:
Dictionary with serialized data.
"""
serialized_tools = [tool.to_dict() for tool in self.tools]
return default_to_dict(
self,
tools=serialized_tools,
tools=serialize_tools_or_toolset(self.tools),
raise_on_failure=self.raise_on_failure,
convert_result_to_json_string=self.convert_result_to_json_string,
)
Expand Down
2 changes: 2 additions & 0 deletions haystack/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .from_function import create_tool_from_function, tool
from .tool import Tool, _check_duplicate_tool_names, deserialize_tools_inplace
from .component_tool import ComponentTool
from .toolset import Toolset


__all__ = [
Expand All @@ -17,4 +18,5 @@
"create_tool_from_function",
"tool",
"ComponentTool",
"Toolset",
]
18 changes: 17 additions & 1 deletion haystack/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError

from haystack.core.errors import DeserializationError
from haystack.core.serialization import generate_qualified_class_name, import_class_by_name
from haystack.tools.errors import ToolInvocationError
from haystack.utils import deserialize_callable, serialize_callable
Expand Down Expand Up @@ -190,8 +191,23 @@ def deserialize_tools_inplace(data: Dict[str, Any], key: str = "tools"):
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)
from haystack.tools.toolset import Toolset # avoid circular import

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")
raise TypeError(f"The value of '{key}' is not a list or a dictionary")

deserialized_tools = []
for tool in serialized_tools:
Expand Down
Loading
Loading