Files
deepset-ai--haystack/test/tools/test_searchable_toolset.py
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

1011 lines
41 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import os
from collections.abc import Callable
from typing import Annotated, Any
import pytest
from haystack import component
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage, ToolCall
from haystack.tools import SearchableToolset, Tool, Toolset, flatten_tools_or_toolsets
from haystack.tools.from_function import create_tool_from_function
# Test helper functions
def get_weather(city: Annotated[str, "The city to get the weather for"]) -> str:
"""Get current weather for a city."""
return f"Weather in {city}: 22°C, sunny"
def add_numbers(a: Annotated[int, "The first number"], b: Annotated[int, "The second number"]) -> int:
"""Add two numbers together."""
return a + b
def multiply_numbers(a: Annotated[int, "The first number"], b: Annotated[int, "The second number"]) -> int:
"""Multiply two numbers."""
return a * b
def get_stock_price(symbol: Annotated[str, "The ticker symbol, e.g. 'AAPL'"]) -> str:
"""Get stock price by ticker symbol."""
return f"{symbol}: $150.00"
def search_database(query: Annotated[str, "The query to search records for"]) -> str:
"""Search the database for records."""
return f"Found 5 records matching '{query}'"
def send_email(
to: Annotated[str, "The recipient email address"],
subject: Annotated[str, "The email subject"],
body: Annotated[str, "The email body"],
) -> str:
"""Send an email to a recipient."""
return f"Email sent to {to}"
def calculate_tax(
amount: Annotated[float, "The amount to tax"], rate: Annotated[float, "The tax rate as a fraction, e.g. 0.2"]
) -> float:
"""Calculate tax on an amount."""
return amount * rate
def convert_currency(
amount: Annotated[float, "The amount to convert"],
from_currency: Annotated[str, "The source currency, e.g. 'USD'"],
to_currency: Annotated[str, "The target currency, e.g. 'EUR'"],
) -> float:
"""Convert currency from one to another."""
return amount * 1.1 # Simplified conversion
# Test fixtures
@pytest.fixture
def weather_tool():
return create_tool_from_function(get_weather)
@pytest.fixture
def add_tool():
return create_tool_from_function(add_numbers)
@pytest.fixture
def multiply_tool():
return create_tool_from_function(multiply_numbers)
@pytest.fixture
def stock_tool():
return create_tool_from_function(get_stock_price)
@pytest.fixture
def small_catalog(weather_tool, add_tool, multiply_tool):
"""Small catalog that triggers passthrough mode (< 8 tools)."""
return [weather_tool, add_tool, multiply_tool]
@pytest.fixture
def large_catalog():
"""Larger catalog that requires discovery (>= 8 tools)."""
functions: list[Callable[..., Any]] = [
get_weather,
add_numbers,
multiply_numbers,
get_stock_price,
search_database,
send_email,
calculate_tax,
convert_currency,
]
return [create_tool_from_function(fn) for fn in functions]
class TestSearchableToolset:
def test_init_with_invalid_catalog(self):
with pytest.raises(TypeError):
SearchableToolset(catalog=123) # type: ignore[arg-type]
with pytest.raises(TypeError):
SearchableToolset(catalog=[123]) # type: ignore[list-item]
with pytest.raises(TypeError):
SearchableToolset(
catalog=Tool( # type: ignore[arg-type]
name="test",
description="test",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
)
def test_not_implemented_methods(self):
toolset = SearchableToolset(catalog=[])
with pytest.raises(NotImplementedError):
toolset + Tool(
name="test", description="test", parameters={"type": "object", "properties": {}}, function=lambda: None
)
with pytest.raises(NotImplementedError):
toolset.add(
Tool(
name="test",
description="test",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
)
def test_clear(self, large_catalog):
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
toolset._bootstrap_tool.invoke(tool_keywords="weather temperature city")
assert len(toolset._discovered_tools) > 0
toolset.clear()
assert len(toolset._discovered_tools) == 0
class TestSearchableToolsetPassthrough:
"""Tests for passthrough mode (small catalogs)."""
def test_passthrough_mode_detected(self, small_catalog):
"""Test that small catalogs trigger passthrough mode."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
assert toolset._passthrough is True
def test_passthrough_exposes_all_tools(self, small_catalog):
"""Test that passthrough mode exposes all catalog tools."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
assert len(toolset) == len(small_catalog)
tool_names = [tool.name for tool in toolset]
assert "get_weather" in tool_names
assert "add_numbers" in tool_names
assert "multiply_numbers" in tool_names
def test_passthrough_no_bootstrap_tool(self, small_catalog):
"""Test that passthrough mode neither creates nor exposes the search bootstrap tool."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is None
# The search tool must never be present in passthrough mode.
assert "search_tools" not in toolset
assert all(tool.name != "search_tools" for tool in toolset)
def test_passthrough_contains_by_name(self, small_catalog):
"""Test __contains__ by name in passthrough mode."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
assert "get_weather" in toolset
assert "nonexistent" not in toolset
def test_passthrough_contains_by_tool(self, small_catalog, weather_tool):
"""Test __contains__ by tool instance in passthrough mode."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
assert weather_tool in toolset
def test_passthrough_contains_by_tool_invalid_type(self, small_catalog):
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
with pytest.raises(TypeError):
123 in toolset # type: ignore[operator] # noqa: B015
def test_custom_search_threshold(self, large_catalog):
"""Test that custom search_threshold changes passthrough behavior."""
# With threshold of 10, 8 tools should be passthrough
toolset = SearchableToolset(catalog=large_catalog, search_threshold=10)
toolset.warm_up()
assert toolset._passthrough is True
assert len(list(toolset)) == 8
class TestSearchableToolsetBM25Mode:
"""Tests for BM25 discovery mode."""
def test_bm25_mode_creates_search_tools(self, large_catalog):
"""Test that BM25 mode creates search_tools bootstrap tool."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
assert toolset._bootstrap_tool.name == "search_tools"
def test_bm25_mode_initializes_document_store(self, large_catalog):
"""Test that BM25 mode initializes document store."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._document_store is not None
def test_search_tools_finds_relevant_tools(self, large_catalog):
"""Test that search_tools finds relevant tools."""
toolset = SearchableToolset(catalog=large_catalog, top_k=3)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
result = toolset._bootstrap_tool.invoke(tool_keywords="weather temperature city")
assert "get_weather" in result
assert "get_weather" in toolset._discovered_tools
def test_search_tools_auto_loads(self, large_catalog):
"""Test that search_tools auto-loads found tools."""
toolset = SearchableToolset(catalog=large_catalog, top_k=3)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
# Initially only bootstrap tool
assert len(toolset) == 1
toolset._bootstrap_tool.invoke(tool_keywords="add numbers multiply")
# Should have bootstrap + discovered tools
assert len(toolset) > 1
def test_search_tools_respects_k(self, large_catalog):
"""Test that search_tools respects k parameter."""
toolset = SearchableToolset(catalog=large_catalog, top_k=2)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
# Search with explicit k=1
result = toolset._bootstrap_tool.invoke(tool_keywords="add numbers together", k=1)
# Should find exactly 1 tool
assert "Found and loaded 1 tool(s):" in result
def test_search_with_selection_scopes_retrieval(self):
"""With a name selection, search is scoped to the selected tools so a small top_k still finds them."""
def fn(x):
return x
params = {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}
# Several decoys match "weather" strongly; the target matches it too but less strongly.
catalog = [
Tool(name=f"decoy_{i}", description="weather weather weather forecast", parameters=params, function=fn)
for i in range(5)
]
catalog.append(Tool(name="target_weather", description="weather report", parameters=params, function=fn))
toolset = SearchableToolset(catalog=catalog, search_threshold=3, top_k=1)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
toolset._selected_tool_names = {"target_weather"}
result = toolset._bootstrap_tool.invoke(tool_keywords="weather")
# Even with top_k=1 and several stronger-matching decoys, the scoped search still finds the selected tool
# (the old approach retrieved top_k across the whole catalog and then dropped non-selected results).
assert "target_weather" in result
assert set(toolset._discovered_tools) == {"target_weather"}
def test_search_tools_no_results(self, large_catalog):
"""Test search_tools with no matching results."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
result = toolset._bootstrap_tool.invoke(tool_keywords="xyznonexistent123")
assert "No tools found" in result
assert len(toolset._discovered_tools) == 0
def test_search_tools_no_keywords(self, large_catalog):
"""Test search_tools with no keywords."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
result = toolset._bootstrap_tool.invoke(tool_keywords="")
assert "No tool keywords provided" in result
assert len(toolset._discovered_tools) == 0
class TestSearchableToolsetIteration:
"""Tests for iteration and collection behavior."""
def test_iter_passthrough(self, small_catalog):
"""Test iteration in passthrough mode."""
toolset = SearchableToolset(catalog=small_catalog)
toolset.warm_up()
tools = list(toolset)
assert len(tools) == len(small_catalog)
def test_iter_with_discovered_tools(self, large_catalog):
"""Test iteration with discovered tools."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
# Search for tools
toolset._bootstrap_tool.invoke(tool_keywords="weather")
toolset._bootstrap_tool.invoke(tool_keywords="math addition")
tools = list(toolset)
assert len(tools) >= 2 # bootstrap + discovered tools
tool_names = [t.name for t in tools]
assert "search_tools" in tool_names
assert "get_weather" in tool_names
def test_iter_automatically_warms_up(self, large_catalog):
toolset = SearchableToolset(catalog=large_catalog)
assert not toolset._is_warmed_up
list(toolset)
assert toolset._is_warmed_up
def test_contains_bootstrap_tool(self, large_catalog):
"""Test __contains__ for bootstrap tool."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert "search_tools" in toolset
assert toolset._bootstrap_tool is not None
assert toolset._bootstrap_tool in toolset
def test_contains_discovered_tool(self, large_catalog):
"""Test __contains__ for discovered tools."""
toolset = SearchableToolset(catalog=large_catalog, top_k=1)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
toolset._bootstrap_tool.invoke(tool_keywords="weather")
assert "get_weather" in toolset
assert "add_numbers" not in toolset # Not discovered yet
def test_getitem(self, large_catalog):
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
tool = toolset[0]
assert tool.name == "search_tools"
class TestSearchableToolsetSerialization:
"""Tests for serialization and deserialization."""
def test_to_dict(self, large_catalog):
"""Test serialization to dict."""
toolset = SearchableToolset(catalog=large_catalog, top_k=3, search_threshold=5)
data = toolset.to_dict()
assert "type" in data
assert "haystack.tools.searchable_toolset.SearchableToolset" in data["type"]
assert "data" in data
assert data["data"]["top_k"] == 3
assert data["data"]["search_threshold"] == 5
assert len(data["data"]["catalog"]) == len(large_catalog)
def test_to_dict_with_toolset(self, large_catalog):
toolset = Toolset(tools=large_catalog)
searchable_toolset = SearchableToolset(catalog=toolset)
data = searchable_toolset.to_dict()
assert "type" in data
assert "haystack.tools.searchable_toolset.SearchableToolset" in data["type"]
assert "data" in data
assert data["data"]["top_k"] == 3
assert data["data"]["search_threshold"] == 8
# A single Toolset catalog serializes as that toolset, not wrapped in a list.
assert isinstance(data["data"]["catalog"], dict)
assert data["data"]["catalog"]["type"] == "haystack.tools.toolset.Toolset"
def test_serde_roundtrip_with_toolset_catalog(self, large_catalog):
"""A single Toolset catalog round-trips back to a Toolset, not a list."""
searchable_toolset = SearchableToolset(catalog=Toolset(tools=large_catalog))
restored = SearchableToolset.from_dict(searchable_toolset.to_dict())
assert isinstance(restored._raw_catalog, Toolset)
restored.warm_up()
assert len(restored._catalog) == len(large_catalog)
def test_from_dict(self, large_catalog):
"""Test deserialization from dict."""
toolset = SearchableToolset(catalog=large_catalog, top_k=3)
data = toolset.to_dict()
restored = SearchableToolset.from_dict(data)
restored.warm_up()
assert restored._top_k == 3
assert len(restored._catalog) == len(large_catalog)
def test_from_dict_with_invalid_item_type(self):
data = {
"type": "haystack.tools.searchable_toolset.SearchableToolset",
"data": {"catalog": [{"type": "haystack.dataclasses.Document", "data": "irrelevant"}]},
}
with pytest.raises(TypeError):
SearchableToolset.from_dict(data)
def test_serde_roundtrip(self, large_catalog):
"""Test full serialization roundtrip."""
toolset = SearchableToolset(catalog=large_catalog, top_k=5, search_threshold=6)
toolset.warm_up()
# Serialize
data = toolset.to_dict()
# Deserialize
restored = SearchableToolset.from_dict(data)
restored.warm_up()
assert restored._bootstrap_tool is not None
# Verify behavior matches
assert restored._passthrough == toolset._passthrough
# Verify bootstrap tool works
result = restored._bootstrap_tool.invoke(tool_keywords="weather")
assert "get_weather" in result
assert "get_weather" in restored._discovered_tools
def test_serde_preserves_catalog_tools(self, large_catalog):
"""Test that serialization preserves catalog tool functionality."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
data = toolset.to_dict()
restored = SearchableToolset.from_dict(data)
restored.warm_up()
assert restored._bootstrap_tool is not None
# Search and invoke a tool
result_text = restored._bootstrap_tool.invoke(tool_keywords="add numbers")
assert "add_numbers" in result_text
add_tool = restored._discovered_tools["add_numbers"]
result = add_tool.invoke(a=10, b=5)
assert result == 15
class TestSearchableToolsetWithToolset:
"""Tests for using a Toolset as catalog input."""
def test_accepts_toolset_as_catalog(self, small_catalog):
"""Test that a Toolset can be used as catalog."""
base_toolset = Toolset(tools=small_catalog)
search_toolset = SearchableToolset(catalog=base_toolset, search_threshold=10)
search_toolset.warm_up()
assert len(search_toolset._catalog) == len(small_catalog)
def test_toolset_catalog_passthrough(self, small_catalog):
"""Test passthrough mode with Toolset catalog."""
base_toolset = Toolset(tools=small_catalog)
search_toolset = SearchableToolset(catalog=base_toolset)
search_toolset.warm_up()
assert search_toolset._passthrough is True
assert len(list(search_toolset)) == len(small_catalog)
def test_accepts_list_of_toolsets(self, weather_tool, add_tool, multiply_tool, stock_tool):
"""Test that a list of Toolsets can be used as catalog."""
toolset1 = Toolset(tools=[weather_tool, add_tool])
toolset2 = Toolset(tools=[multiply_tool, stock_tool])
search_toolset = SearchableToolset(catalog=[toolset1, toolset2], search_threshold=10)
search_toolset.warm_up()
assert len(search_toolset._catalog) == 4
assert any(t.name == "get_weather" for t in search_toolset._catalog)
assert any(t.name == "add_numbers" for t in search_toolset._catalog)
assert any(t.name == "multiply_numbers" for t in search_toolset._catalog)
assert any(t.name == "get_stock_price" for t in search_toolset._catalog)
def test_accepts_mixed_list(self, weather_tool, add_tool, multiply_tool):
"""Test that a mixed list of Tools and Toolsets can be used as catalog."""
toolset = Toolset(tools=[add_tool, multiply_tool])
search_toolset = SearchableToolset(catalog=[weather_tool, toolset], search_threshold=10)
search_toolset.warm_up()
assert len(search_toolset._catalog) == 3
assert any(t.name == "get_weather" for t in search_toolset._catalog)
assert any(t.name == "add_numbers" for t in search_toolset._catalog)
assert any(t.name == "multiply_numbers" for t in search_toolset._catalog)
class TestSearchableToolsetWarmUp:
"""Tests for warm_up behavior."""
def test_not_warmed_up_after_agent_init(self, large_catalog, monkeypatch):
"""Initializing an Agent with a SearchableToolset must not warm it up (no premature flatten/connect)."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
toolset = SearchableToolset(catalog=large_catalog)
assert toolset._is_warmed_up is False
Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
assert toolset._is_warmed_up is False
def test_warm_up_idempotent(self, large_catalog):
"""Test that warm_up can be called multiple times safely."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
first_bootstrap = toolset._bootstrap_tool
toolset.warm_up()
second_bootstrap = toolset._bootstrap_tool
# Should be the same instance
assert first_bootstrap is second_bootstrap
def test_catalog_empty_before_warm_up(self, small_catalog):
"""Test that catalog is empty before warm_up (deferred flattening)."""
toolset = SearchableToolset(catalog=small_catalog)
# Before warm_up, _catalog is empty (flattening is deferred)
assert len(toolset._catalog) == 0
# After warm_up, catalog is populated
toolset.warm_up()
assert len(list(toolset)) == len(small_catalog)
def test_bootstrap_tool_before_warm_up(self, large_catalog):
"""Test that bootstrap tool is None before warm_up."""
toolset = SearchableToolset(catalog=large_catalog)
assert toolset._bootstrap_tool is None
def test_warm_up_raises_on_duplicate_tool_names(self):
"""Test that warm_up raises when the flattened catalog has duplicate tool names."""
params = {"type": "object", "properties": {}}
tool1 = Tool(name="dup", description="first", parameters=params, function=lambda: 1)
tool2 = Tool(name="dup", description="second", parameters=params, function=lambda: 2)
toolset = SearchableToolset(catalog=[tool1, tool2])
with pytest.raises(ValueError, match="Duplicate tool names found"):
toolset.warm_up()
class TestSearchableToolsetEdgeCases:
"""Tests for edge cases and error handling."""
def test_empty_catalog(self):
"""Test with empty catalog."""
toolset = SearchableToolset(catalog=[])
toolset.warm_up()
assert toolset._passthrough is True
assert len(list(toolset)) == 0
def test_single_tool_catalog(self, weather_tool):
"""Test with single tool catalog."""
toolset = SearchableToolset(catalog=[weather_tool])
toolset.warm_up()
assert toolset._passthrough is True
assert len(list(toolset)) == 1
def test_exactly_at_threshold(self):
"""Test catalog size exactly at threshold."""
tools = [
Tool(
name=f"tool_{i}",
description=f"Tool number {i}",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
for i in range(8)
]
toolset = SearchableToolset(catalog=tools, search_threshold=8)
toolset.warm_up()
# Should NOT be passthrough (>= threshold triggers discovery)
assert toolset._passthrough is False
def test_one_below_threshold(self):
"""Test catalog size one below threshold."""
tools = [
Tool(
name=f"tool_{i}",
description=f"Tool number {i}",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
for i in range(7)
]
toolset = SearchableToolset(catalog=tools, search_threshold=8)
toolset.warm_up()
# Should be passthrough
assert toolset._passthrough is True
def test_multiple_loads_same_tool(self, large_catalog):
"""Test searching for the same tool multiple times."""
toolset = SearchableToolset(catalog=large_catalog)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
# Search for same tool twice
toolset._bootstrap_tool.invoke(tool_keywords="weather")
toolset._bootstrap_tool.invoke(tool_keywords="weather temperature")
# Should still only have discovered tools (may be multiple if they match "weather")
assert "get_weather" in toolset._discovered_tools
assert len(toolset) >= 2 # bootstrap + discovered tools
class TestSearchableToolsetLazyToolset:
"""Tests for lazy toolsets (e.g. MCPToolset with eager_connect=False)."""
def test_lazy_toolset_tools_available_after_warm_up(self):
"""Test that a lazy toolset's tools become available after warm_up."""
class LazyToolset(Toolset):
"""A toolset that only provides real tools after warm_up (like MCPToolset)."""
def __init__(self):
# Before warm_up, no real tools — mimics MCPToolset with eager_connect=False
super().__init__(tools=[])
self._connected = False
def warm_up(self) -> None:
if self._connected:
return
# Simulate connecting and discovering tools
self.tools = [
Tool(
name=f"lazy_tool_{i}",
description=f"Lazy tool number {i} for testing",
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
function=lambda x: x,
)
for i in range(10)
]
self._connected = True
lazy = LazyToolset()
# Before warm_up, iterating yields nothing
assert len(list(lazy)) == 0
toolset = SearchableToolset(catalog=lazy)
# Before warm_up, catalog is empty
assert len(toolset._catalog) == 0
toolset.warm_up()
# After warm_up, all 10 lazy tools should be in the catalog
assert len(toolset._catalog) == 10
# 10 tools >= 8 threshold -> BM25 mode
assert toolset._passthrough is False
assert toolset._bootstrap_tool is not None
# Search should find lazy tools
result = toolset._bootstrap_tool.invoke(tool_keywords="lazy tool testing")
assert "lazy_tool" in result
def test_lazy_toolset_passthrough_mode(self):
"""Test lazy toolset with few tools ends up in passthrough mode."""
class SmallLazyToolset(Toolset):
def __init__(self):
super().__init__(tools=[])
def warm_up(self) -> None:
self.tools = [
Tool(
name="lazy_single",
description="A single lazy tool",
parameters={"type": "object", "properties": {}},
function=lambda: "result",
)
]
toolset = SearchableToolset(catalog=SmallLazyToolset())
toolset.warm_up()
assert toolset._passthrough is True
assert len(list(toolset)) == 1
assert "lazy_single" in toolset
def test_mixed_lazy_and_eager_toolsets(self):
"""Test catalog with both lazy and eager toolsets."""
class LazyToolset(Toolset):
def __init__(self):
super().__init__(tools=[])
def warm_up(self) -> None:
self.tools = [
Tool(
name=f"lazy_{i}",
description=f"Lazy tool {i}",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
for i in range(5)
]
eager_tools = [
Tool(
name=f"eager_{i}",
description=f"Eager tool {i}",
parameters={"type": "object", "properties": {}},
function=lambda: None,
)
for i in range(5)
]
toolset = SearchableToolset(catalog=[LazyToolset(), *eager_tools])
toolset.warm_up()
# Should have 5 lazy + 5 eager = 10 tools
assert len(toolset._catalog) == 10
assert toolset._passthrough is False
assert any(t.name == "lazy_0" for t in toolset._catalog)
assert any(t.name == "eager_0" for t in toolset._catalog)
class TestSearchableToolsetCustomSearchTool:
"""Tests for customizing the bootstrap search tool."""
def test_invalid_parameters_description_key(self):
"""Test that invalid parameter keys raise ValueError."""
with pytest.raises(ValueError, match="Invalid search_tool_parameters_description keys"):
SearchableToolset(catalog=[], search_tool_parameters_description={"invalid_key": "some description"})
def test_custom_tool_behavior(self, large_catalog):
"""Test custom name, description, parameter overrides, collection access, and discovery."""
toolset = SearchableToolset(
catalog=large_catalog,
search_tool_name="find_tools",
search_tool_description="Find tools by keyword.",
search_tool_parameters_description={"tool_keywords": "Keywords.", "k": "How many."},
)
toolset.warm_up()
assert toolset._bootstrap_tool is not None
# Custom name and description applied
assert toolset._bootstrap_tool.name == "find_tools"
assert toolset._bootstrap_tool.description == "Find tools by keyword."
# Both parameter descriptions overridden
props = toolset._bootstrap_tool.parameters["properties"]
assert props["tool_keywords"]["description"] == "Keywords."
assert props["k"]["description"] == "How many."
# Collection access uses custom name
assert "find_tools" in toolset
assert "search_tools" not in toolset
assert toolset[0].name == "find_tools"
# Discovery still works
result = toolset._bootstrap_tool.invoke(tool_keywords="weather")
assert "get_weather" in result
assert "get_weather" in toolset._discovered_tools
def test_serialization(self, large_catalog):
"""Test to_dict includes all fields and serde roundtrip preserves settings."""
toolset = SearchableToolset(
catalog=large_catalog,
search_tool_name="find_tools",
search_tool_description="Custom description.",
search_tool_parameters_description={"tool_keywords": "Custom param desc."},
)
# to_dict includes custom values
data = toolset.to_dict()
assert data["data"]["search_tool_name"] == "find_tools"
assert data["data"]["search_tool_description"] == "Custom description."
assert data["data"]["search_tool_parameters_description"] == {"tool_keywords": "Custom param desc."}
# Roundtrip preserves behavior
restored = SearchableToolset.from_dict(data)
restored.warm_up()
assert restored._bootstrap_tool is not None
assert restored._bootstrap_tool.name == "find_tools"
assert restored._bootstrap_tool.description == "Custom description."
assert restored._bootstrap_tool.parameters["properties"]["tool_keywords"]["description"] == "Custom param desc."
result = restored._bootstrap_tool.invoke(tool_keywords="weather")
assert "get_weather" in result
class TestSearchableToolsetAgentToolSelection:
"""Deterministic Agent tests for runtime tool-name selection and lazy tool_call_counts."""
def test_get_selectable_tools_exposes_full_catalog(self, large_catalog):
"""get_selectable_tools() exposes the whole catalog, unlike iteration (search tool + discovered only)."""
toolset = SearchableToolset(catalog=large_catalog, search_threshold=3)
toolset.warm_up()
# Iteration only exposes the bootstrap search tool before anything is discovered.
assert [tool.name for tool in toolset] == ["search_tools"]
# The catalog, however, is fully available for name-based selection.
assert {tool.name for tool in toolset.get_selectable_tools()} == {tool.name for tool in large_catalog}
def test_runtime_tool_names_select_isolated_spawn_and_preserve_search(self, large_catalog, monkeypatch):
"""Selecting catalog tool names returns an isolated spawn carrying the selection and keeping search active."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
toolset = SearchableToolset(catalog=large_catalog, search_threshold=3) # 8 tools -> search mode
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
selected = agent._select_tools(["get_weather", "add_numbers"])
# An isolated spawn is returned with the selection; the configured toolset is not mutated.
assert len(selected) == 1
spawned = selected[0]
assert isinstance(spawned, SearchableToolset)
assert spawned is not toolset
assert spawned._selected_tool_names == {"get_weather", "add_numbers"}
assert toolset._selected_tool_names is None
# Search is preserved on the spawn (not dismantled): only the bootstrap tool is exposed up front.
assert [tool.name for tool in spawned] == ["search_tools"]
# And search only discovers tools within the selected subset.
assert spawned._bootstrap_tool is not None
spawned._bootstrap_tool.invoke(tool_keywords="weather add stock multiply")
assert set(spawned._discovered_tools) <= {"get_weather", "add_numbers"}
# The configured toolset's discovered tools are untouched.
assert toolset._discovered_tools == {}
def test_spawns_have_independent_discovered_tools_and_selection(self, large_catalog):
"""Two spawns of one SearchableToolset don't share discovered tools or collide on the active selection."""
toolset = SearchableToolset(catalog=large_catalog, search_threshold=3)
toolset.warm_up()
spawn_a = toolset.spawn()
spawn_b = toolset.spawn()
assert spawn_a is not spawn_b
assert spawn_a is not toolset
# Bootstrap tools are rebound per spawn (not shared with the original or each other).
assert spawn_a._bootstrap_tool is not None
assert spawn_a._bootstrap_tool is not spawn_b._bootstrap_tool
spawn_a._selected_tool_names = {"get_weather"}
spawn_a._bootstrap_tool.invoke(tool_keywords="weather add stock multiply")
# Discovery on spawn_a does not leak into spawn_b or the configured toolset.
assert set(spawn_a._discovered_tools) <= {"get_weather"}
assert spawn_b._discovered_tools == {}
assert toolset._discovered_tools == {}
# The selection is likewise isolated.
assert spawn_b._selected_tool_names is None
assert toolset._selected_tool_names is None
def test_runtime_tool_names_passthrough_exposes_selected(self, large_catalog, monkeypatch):
"""In passthrough mode, selecting names exposes exactly those catalog tools directly."""
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
toolset = SearchableToolset(catalog=large_catalog, search_threshold=20) # 8 < 20 -> passthrough
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
selected = agent._select_tools(["get_weather", "add_numbers"])
assert selected == [toolset]
assert {tool.name for tool in flatten_tools_or_toolsets(selected)} == {"get_weather", "add_numbers"}
def test_agent_run_with_runtime_tool_names(self, large_catalog):
"""An Agent with a SearchableToolset runs with specific catalog tools selected by name on an isolated spawn."""
toolset = SearchableToolset(catalog=large_catalog, search_threshold=20) # passthrough exposes the selection
@component
class WeatherCallingGenerator:
invoked = False
@component.output_types(replies=list[ChatMessage])
def run(self, messages, tools=None, **kwargs):
# In passthrough mode the selected catalog tool is exposed directly.
assert [tool.name for tool in tools] == ["get_weather"]
if self.invoked:
return {"replies": [ChatMessage.from_assistant("done")]}
self.invoked = True
return {
"replies": [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="get_weather", arguments={"city": "Berlin"})]
)
]
}
agent = Agent(chat_generator=WeatherCallingGenerator(), tools=toolset, max_agent_steps=5)
result = agent.run(messages=[ChatMessage.from_user("What's the weather in Berlin?")], tools=["get_weather"])
assert result["tool_call_counts"]["get_weather"] == 1
# The Agent runs against an isolated spawn, so the configured toolset's selection never gets set.
assert toolset._selected_tool_names is None
def test_discovered_tool_call_counts_added_lazily(self, large_catalog):
"""tool_call_counts seeds only the search tool up front; a discovered+called tool is added lazily."""
toolset = SearchableToolset(catalog=large_catalog, search_threshold=3, top_k=5)
@component
class SearchThenWeatherGenerator:
step = 0
@component.output_types(replies=list[ChatMessage])
def run(self, messages, tools=None, **kwargs):
self.step += 1
if self.step == 1:
return {
"replies": [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="search_tools", arguments={"tool_keywords": "weather"})]
)
]
}
if self.step == 2:
return {
"replies": [
ChatMessage.from_assistant(
tool_calls=[ToolCall(tool_name="get_weather", arguments={"city": "Berlin"})]
)
]
}
return {"replies": [ChatMessage.from_assistant("done")]}
agent = Agent(chat_generator=SearchThenWeatherGenerator(), tools=toolset, max_agent_steps=6)
result = agent.run(messages=[ChatMessage.from_user("What's the weather in Berlin?")])
counts = result["tool_call_counts"]
# search_tools is seeded at init; get_weather is only counted after being discovered and called.
assert counts["search_tools"] == 1
assert counts["get_weather"] == 1
# The Agent discovers tools on an isolated spawn, so the configured toolset's discovered tools stay empty.
assert toolset._discovered_tools == {}
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
@pytest.mark.integration
class TestSearchableToolsetAgentIntegration:
"""Integration tests with real Agent and OpenAIChatGenerator."""
def test_agent_discovers_and_uses_tools(self, large_catalog):
"""Agent discovers tools via BM25 search and uses them."""
toolset = SearchableToolset(catalog=large_catalog, top_k=2, search_threshold=3)
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"), tools=toolset, max_agent_steps=5)
assert len(agent.tools) == 1
result = agent.run(messages=[ChatMessage.from_user("What's the weather in Milan?")])
# Discovered tools are cleared after the run (reset), so only the search tool remains exposed.
assert len(agent.tools) == 1
assert "messages" in result
messages = result["messages"]
assert len(messages) > 1
# Discovery happened during the run: the agent searched and then called the discovered tool.
tool_calls = [tool_call for msg in messages if msg.tool_calls for tool_call in msg.tool_calls]
assert len(tool_calls) > 1
assert any(tool_call.tool_name == "search_tools" for tool_call in tool_calls)
assert any(tool_call.tool_name == "get_weather" for tool_call in tool_calls)
assert "22" in messages[-1].text
def test_agent_discovers_multiple_tools_across_steps(self, large_catalog):
"""A task needing two different tools forces the agent to search for and load each one."""
# Use two tools the model cannot answer on its own (live weather and stock price) so it is forced
# to call both. top_k=1 means a single search returns at most one tool, so the agent must search
# again for the second tool — exercising that discovered tools accumulate across agent steps.
toolset = SearchableToolset(catalog=large_catalog, top_k=1, search_threshold=3)
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4.1-nano"), tools=toolset, max_agent_steps=8)
result = agent.run(
messages=[
ChatMessage.from_user(
"What is the current weather in Milan, and what is the current stock price of AAPL? "
"Use the available tools to look up both."
)
]
)
tool_calls = [tool_call for msg in result["messages"] if msg.tool_calls for tool_call in msg.tool_calls]
called = {tc.tool_name for tc in tool_calls}
# The agent had to search (more than once given top_k=1) and use both discovered tools.
assert sum(tc.tool_name == "search_tools" for tc in tool_calls) >= 2
assert "get_weather" in called
assert "get_stock_price" in called