Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

233 lines
8.3 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Annotated
import pytest
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.core.serialization import generate_qualified_class_name
from haystack.tools import Tool, Toolset, tool
from haystack.tools.toolset import _ToolsetWrapper
@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
@tool
def subtract(a: Annotated[int, "first number"], b: Annotated[int, "second number"]) -> int:
"""Subtract b from a."""
return a - b
@tool
def rebuilt() -> str:
"""A rebuilt tool."""
return "rebuilt"
@pytest.fixture
def add_tool():
return add
@pytest.fixture
def multiply_tool():
return multiply
@pytest.fixture
def subtract_tool():
return subtract
class WarmUpCountingTool(Tool):
"""A Tool that records how many times warm_up() was called."""
def __init__(self, name: str):
super().__init__(
name=name,
description=f"{name} tool",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
self.warm_up_count = 0
def warm_up(self) -> None:
self.warm_up_count += 1
class WarmUpCountingToolset(Toolset):
"""A Toolset that records how many times its own warm_up() did real work."""
def __init__(self, tools):
super().__init__(tools)
self.warm_up_count = 0
def warm_up(self) -> None:
if self._is_warmed_up:
return
self.warm_up_count += 1
super().warm_up()
class RebuildingToolset(Toolset):
"""A toolset that rebuilds its tools on from_dict() instead of serializing them (like a dynamic toolset)."""
def __init__(self):
super().__init__([rebuilt])
def to_dict(self):
return {"type": generate_qualified_class_name(type(self)), "data": {}}
@classmethod
def from_dict(cls, data):
return cls()
class TestToolsetWrapper:
"""Tests for the _ToolsetWrapper class"""
def test_toolset_plus_toolset_creates_wrapper(self, add_tool, multiply_tool):
"""Test that combining two Toolsets creates a _ToolsetWrapper and works correctly."""
result = Toolset([add_tool]) + Toolset([multiply_tool])
assert isinstance(result, _ToolsetWrapper)
assert len(result) == 2
assert add_tool in result
assert multiply_tool in result
def test_wrapper_with_agent(self, add_tool, multiply_tool, monkeypatch):
"""Test that _ToolsetWrapper works with Agent."""
monkeypatch.setenv("OPENAI_API_KEY", "test")
wrapper = Toolset([add_tool]) + Toolset([multiply_tool])
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=wrapper)
agent.warm_up()
assert len(list(agent.tools)) == 2
def test_wrapper_chaining_and_duplicate_detection(self, add_tool, multiply_tool, subtract_tool):
"""Test chaining operations and that duplicates are still detected."""
# Chaining should work
result = Toolset([add_tool]) + Toolset([multiply_tool]) + Toolset([subtract_tool])
assert len(result) == 3
# Duplicates should be detected
toolset_with_dup = Toolset([add_tool])
with pytest.raises(ValueError, match="Duplicate tool names found"):
_ = result + toolset_with_dup
class TestToolsetWrapperWarmUp:
"""Tests for warm_up behavior of _ToolsetWrapper."""
def test_new_wrapper_is_not_warmed_up(self):
wrapper = Toolset([WarmUpCountingTool("a")]) + Toolset([WarmUpCountingTool("b")])
assert wrapper._is_warmed_up is False
def test_warm_up_delegates_to_each_toolset(self):
ts1 = WarmUpCountingToolset([WarmUpCountingTool("a")])
ts2 = WarmUpCountingToolset([WarmUpCountingTool("b")])
wrapper = ts1 + ts2
wrapper.warm_up()
assert ts1.warm_up_count == 1
assert ts2.warm_up_count == 1
assert wrapper._is_warmed_up is True
def test_warm_up_is_idempotent(self):
ts1 = WarmUpCountingToolset([WarmUpCountingTool("a")])
ts2 = WarmUpCountingToolset([WarmUpCountingTool("b")])
wrapper = ts1 + ts2
wrapper.warm_up()
wrapper.warm_up()
wrapper.warm_up()
assert ts1.warm_up_count == 1
assert ts2.warm_up_count == 1
class TestToolsetWrapperSerialization:
"""Tests for to_dict/from_dict of _ToolsetWrapper."""
def test_to_dict(self, add_tool, multiply_tool):
wrapper = Toolset([add_tool]) + Toolset([multiply_tool])
data = wrapper.to_dict()
assert data["type"] == "haystack.tools.toolset._ToolsetWrapper"
assert len(data["data"]["toolsets"]) == 2
assert all(ts["type"] == "haystack.tools.toolset.Toolset" for ts in data["data"]["toolsets"])
def test_from_dict_round_trip(self, add_tool, multiply_tool):
wrapper = Toolset([add_tool]) + Toolset([multiply_tool])
restored = _ToolsetWrapper.from_dict(wrapper.to_dict())
assert isinstance(restored, _ToolsetWrapper)
assert len(restored) == 2
assert len(restored.toolsets) == 2
assert "add" in restored
assert "multiply" in restored
def test_to_dict_preserves_subclass_serialization(self, add_tool):
# RebuildingToolset has a custom to_dict that serializes no tools (they are rebuilt on from_dict).
wrapper = RebuildingToolset() + Toolset([add_tool])
data = wrapper.to_dict()
# Each wrapped toolset is serialized via its own to_dict, so the custom one is preserved.
assert data["data"]["toolsets"][0]["type"].endswith("RebuildingToolset")
assert data["data"]["toolsets"][0]["data"] == {}
restored = _ToolsetWrapper.from_dict(data)
assert isinstance(restored.toolsets[0], RebuildingToolset)
assert "rebuilt" in restored
assert "add" in restored
def test_from_dict_rejects_non_toolset(self, add_tool):
data = Toolset([add_tool]).to_dict()
data["data"] = {"toolsets": [{"type": "haystack.tools.tool.Tool", "data": {}}]}
with pytest.raises(TypeError, match="is not a subclass of Toolset"):
_ToolsetWrapper.from_dict(data)
class TestToolsetWrapperToolSelection:
"""Tests for get_selectable_tools(), the name filter, and spawn() on _ToolsetWrapper."""
def test_get_selectable_tools_aggregates_all_toolsets(self, add_tool, multiply_tool, subtract_tool):
wrapper = Toolset([add_tool]) + Toolset([multiply_tool, subtract_tool])
assert {tool.name for tool in wrapper.get_selectable_tools()} == {"add", "multiply", "subtract"}
def test_get_selectable_tools_ignores_active_filter(self, add_tool, multiply_tool):
wrapper = Toolset([add_tool]) + Toolset([multiply_tool])
wrapper._selected_tool_names = {"add"}
# Iteration is filtered, but get_selectable_tools still returns the full set.
assert [tool.name for tool in wrapper] == ["add"]
assert {tool.name for tool in wrapper.get_selectable_tools()} == {"add", "multiply"}
def test_filter_restricts_iteration_and_len(self, add_tool, multiply_tool, subtract_tool):
wrapper = Toolset([add_tool, multiply_tool]) + Toolset([subtract_tool])
wrapper._selected_tool_names = {"add", "subtract"}
assert [tool.name for tool in wrapper] == ["add", "subtract"]
assert len(wrapper) == 2
def test_spawn_isolates_own_and_child_state(self, add_tool, multiply_tool):
ts1 = Toolset([add_tool])
ts2 = Toolset([multiply_tool])
wrapper = ts1 + ts2
spawned = wrapper.spawn()
# The spawn and its wrapped toolsets are independent copies.
assert spawned is not wrapper
spawned._selected_tool_names = {"add"}
assert {tool.name for tool in spawned} == {"add"}
# The configured wrapper and its children are untouched.
assert wrapper._selected_tool_names is None
assert ts1._selected_tool_names is None
assert ts2._selected_tool_names is None
assert {tool.name for tool in wrapper} == {"add", "multiply"}