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,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,248 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.agents.state import State
|
||||
from haystack.components.agents.tool_calling import _run_tool
|
||||
from haystack.dataclasses import ChatMessage, FileContent, ImageContent, ToolCall
|
||||
from haystack.skill_stores.file_system.skill_store import FileSystemSkillStore
|
||||
from haystack.tools import SkillToolset, Tool
|
||||
from haystack.tools.errors import ToolInvocationError
|
||||
|
||||
|
||||
def _get_tool(toolset, name):
|
||||
"""Warm up the toolset and return its tool with the given name."""
|
||||
toolset.warm_up()
|
||||
return next(t for t in toolset if t.name == name)
|
||||
|
||||
|
||||
def _write_skill(skills_dir, name, description=None, body="Instructions.", files=None):
|
||||
skill_dir = skills_dir / name
|
||||
skill_dir.mkdir(parents=True)
|
||||
frontmatter = f"---\nname: {name}\n"
|
||||
if description is not None:
|
||||
frontmatter += f"description: {description}\n"
|
||||
frontmatter += "---\n"
|
||||
(skill_dir / "SKILL.md").write_text(frontmatter + body, encoding="utf-8")
|
||||
for rel_path, content in (files or {}).items():
|
||||
target = skill_dir / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestSkillToolset:
|
||||
def test_tools_present_before_warm_up_without_io(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
|
||||
# The (static) tool set is available immediately, with no store access required.
|
||||
assert toolset._is_warmed_up is False
|
||||
assert len(toolset) == 2
|
||||
assert {t.name for t in toolset} == {"load_skill", "read_skill_file"}
|
||||
assert "load_skill" in toolset
|
||||
assert toolset._is_warmed_up is False
|
||||
|
||||
def test_scans_skills_on_warm_up(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
_write_skill(tmp_path, "excel", description="Use to edit spreadsheets.")
|
||||
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
|
||||
# The catalog is only scanned on warm_up.
|
||||
assert toolset._is_warmed_up is False
|
||||
|
||||
toolset.warm_up()
|
||||
|
||||
assert toolset._is_warmed_up is True
|
||||
assert set(toolset.skills) == {"pdf-forms", "excel"}
|
||||
assert toolset.skills["pdf-forms"].description == "Use to fill PDF forms."
|
||||
|
||||
def test_skills_property_warms_up_lazily(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
# Accessing `skills` without an explicit warm_up triggers it.
|
||||
assert set(toolset.skills) == {"pdf-forms"}
|
||||
assert toolset._is_warmed_up is True
|
||||
|
||||
def test_warm_up_is_idempotent(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
toolset.warm_up()
|
||||
toolset.warm_up()
|
||||
assert set(toolset.skills) == {"pdf-forms"}
|
||||
|
||||
def test_warm_up_warms_up_the_store(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
store = FileSystemSkillStore(tmp_path)
|
||||
toolset = SkillToolset(store)
|
||||
toolset.warm_up()
|
||||
assert store._is_warmed_up is True
|
||||
|
||||
def test_concurrent_warm_up(self, tmp_path):
|
||||
# Concurrent first use (e.g. parallel requests hitting a shared Agent) must produce a complete,
|
||||
# consistent catalog in every thread.
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
_write_skill(tmp_path, "excel", description="Use to edit spreadsheets.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
|
||||
num_threads = 8
|
||||
barrier = threading.Barrier(num_threads)
|
||||
|
||||
def warm_up_and_list():
|
||||
barrier.wait()
|
||||
toolset.warm_up()
|
||||
return set(toolset.skills)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
results = list(executor.map(lambda _: warm_up_and_list(), range(num_threads)))
|
||||
|
||||
assert all(result == {"pdf-forms", "excel"} for result in results)
|
||||
assert "- pdf-forms: Use to fill PDF forms." in toolset._load_skill_tool.description
|
||||
|
||||
def test_add_is_not_supported(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
extra = Tool(name="extra", description="d", parameters={"type": "object", "properties": {}}, function=len)
|
||||
with pytest.raises(NotImplementedError, match="does not support adding tools"):
|
||||
toolset.add(extra)
|
||||
|
||||
def test_concat_is_not_supported(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
extra = Tool(name="extra", description="d", parameters={"type": "object", "properties": {}}, function=len)
|
||||
with pytest.raises(NotImplementedError, match="does not support concatenation"):
|
||||
_ = toolset + extra
|
||||
|
||||
def test_accepts_skill_store_instance(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
store = FileSystemSkillStore(tmp_path)
|
||||
toolset = SkillToolset(store)
|
||||
toolset.warm_up()
|
||||
assert set(toolset.skills) == {"pdf-forms"}
|
||||
assert toolset._store is store
|
||||
|
||||
def test_load_skill_description_lists_skills(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
load_skill = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "load_skill")
|
||||
assert "Available skills:" in load_skill.description
|
||||
assert "- pdf-forms: Use to fill PDF forms." in load_skill.description
|
||||
|
||||
def test_load_skill_description_when_empty(self, tmp_path):
|
||||
load_skill = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "load_skill")
|
||||
assert "No skills are currently available." in load_skill.description
|
||||
|
||||
def test_load_skill_returns_body_and_manifest(self, tmp_path):
|
||||
_write_skill(
|
||||
tmp_path,
|
||||
"pdf-forms",
|
||||
description="Use to fill PDF forms.",
|
||||
body="Step 1. Do the thing.",
|
||||
files={"reference/forms.md": "details"},
|
||||
)
|
||||
load_skill = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "load_skill")
|
||||
result = load_skill.invoke(name="pdf-forms")
|
||||
assert "Step 1. Do the thing." in result
|
||||
assert "reference/forms.md" in result
|
||||
|
||||
def test_load_skill_unknown_raises(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
load_skill = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "load_skill")
|
||||
# The error propagates (wrapped by Tool.invoke) so the Agent can apply its tool-failure policy.
|
||||
with pytest.raises(ToolInvocationError, match="Unknown skill 'nope'"):
|
||||
load_skill.invoke(name="nope")
|
||||
|
||||
def test_read_skill_file(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="d", files={"reference/forms.md": "form details"})
|
||||
read = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "read_skill_file")
|
||||
assert read.invoke(name="pdf-forms", path="reference/forms.md") == "form details"
|
||||
|
||||
def test_read_skill_file_returns_image_as_content_part(self, tmp_path):
|
||||
skill_dir = _write_skill(tmp_path, "pdf-forms", description="d")
|
||||
(skill_dir / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\xff\xfe")
|
||||
read = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "read_skill_file")
|
||||
# raw_result keeps the ImageContent intact (no string conversion) and the tool wraps it in a list so it
|
||||
# rides back as a multimodal tool-result content part.
|
||||
assert read.outputs_to_string == {"raw_result": True}
|
||||
result = read.invoke(name="pdf-forms", path="logo.png")
|
||||
assert isinstance(result, list)
|
||||
assert isinstance(result[0], ImageContent)
|
||||
|
||||
def test_read_skill_file_returns_pdf_as_content_part(self, tmp_path):
|
||||
skill_dir = _write_skill(tmp_path, "pdf-forms", description="d")
|
||||
(skill_dir / "guide.pdf").write_bytes(b"%PDF-1.4 fake pdf bytes")
|
||||
read = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "read_skill_file")
|
||||
result = read.invoke(name="pdf-forms", path="guide.pdf")
|
||||
assert isinstance(result, list)
|
||||
assert isinstance(result[0], FileContent)
|
||||
|
||||
def test_read_skill_file_in_agent_loop_yields_expected_tool_message_parts(self, tmp_path):
|
||||
# Use the Agent tool-execution step (`_run_tool`, what the Agent loop calls) to check that multimodal content
|
||||
# from `read_skill_file` comes back as expected in the tool-result messages.
|
||||
skill_dir = _write_skill(tmp_path, "pdf-forms", description="d", files={"reference/forms.md": "form details"})
|
||||
(skill_dir / "logo.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00\xff\xfe") # PNG magic header + non-UTF-8 byte
|
||||
(skill_dir / "guide.pdf").write_bytes(b"%PDF-1.4 fake pdf bytes") # minimal PDF header
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
|
||||
tool_calls = [
|
||||
ToolCall("read_skill_file", {"name": "pdf-forms", "path": "reference/forms.md"}, id="text"),
|
||||
ToolCall("read_skill_file", {"name": "pdf-forms", "path": "logo.png"}, id="image"),
|
||||
ToolCall("read_skill_file", {"name": "pdf-forms", "path": "guide.pdf"}, id="pdf"),
|
||||
]
|
||||
tool_messages, _ = _run_tool(
|
||||
messages=[ChatMessage.from_assistant(tool_calls=tool_calls)], state=State(schema={}), tools=toolset
|
||||
)
|
||||
|
||||
assert len(tool_messages) == 3
|
||||
results = {}
|
||||
for message in tool_messages:
|
||||
assert message.is_from("tool")
|
||||
tool_result = message.tool_call_results[0]
|
||||
assert tool_result.error is False
|
||||
results[tool_result.origin.id] = tool_result.result
|
||||
|
||||
# Text comes back as a plain string; images/PDFs as a one-element list of the matching content part.
|
||||
assert results["text"] == "form details"
|
||||
assert isinstance(results["image"], list) and isinstance(results["image"][0], ImageContent)
|
||||
assert results["image"][0].mime_type == "image/png"
|
||||
assert isinstance(results["pdf"], list) and isinstance(results["pdf"][0], FileContent)
|
||||
assert results["pdf"][0].mime_type == "application/pdf"
|
||||
|
||||
def test_read_skill_file_blocks_traversal(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="d")
|
||||
(tmp_path / "secret.txt").write_text("top secret")
|
||||
read = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "read_skill_file")
|
||||
with pytest.raises(ToolInvocationError, match="outside the skill directory") as exc:
|
||||
read.invoke(name="pdf-forms", path="../secret.txt")
|
||||
assert "top secret" not in str(exc.value)
|
||||
|
||||
def test_read_skill_file_missing(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="d")
|
||||
read = _get_tool(SkillToolset(FileSystemSkillStore(tmp_path)), "read_skill_file")
|
||||
with pytest.raises(ToolInvocationError, match="not found"):
|
||||
read.invoke(name="pdf-forms", path="nope.md")
|
||||
|
||||
def test_to_dict_and_from_dict(self, tmp_path):
|
||||
_write_skill(tmp_path, "pdf-forms", description="Use to fill PDF forms.")
|
||||
toolset = SkillToolset(FileSystemSkillStore(tmp_path))
|
||||
|
||||
data = toolset.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.tools.skills.skill_toolset.SkillToolset",
|
||||
"data": {
|
||||
"store": {
|
||||
"type": "haystack.skill_stores.file_system.skill_store.FileSystemSkillStore",
|
||||
"init_parameters": {"skills_dir": str(tmp_path)},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
restored = SkillToolset.from_dict(data)
|
||||
restored.warm_up()
|
||||
assert set(restored.skills) == {"pdf-forms"}
|
||||
@@ -0,0 +1,940 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
|
||||
from haystack import Pipeline, SuperComponent, component
|
||||
from haystack.components.agents import Agent, State
|
||||
from haystack.components.builders import PromptBuilder
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.core.pipeline.utils import _deepcopy_with_exceptions
|
||||
from haystack.dataclasses import ChatMessage, ChatRole, Document
|
||||
from haystack.tools import ComponentTool, ToolsType
|
||||
from test.tools.test_parameters_schema_utils import BYTE_STREAM_SCHEMA, DOCUMENT_SCHEMA, SPARSE_EMBEDDING_SCHEMA
|
||||
|
||||
# Component and Model Definitions
|
||||
|
||||
|
||||
@component
|
||||
class SimpleComponentUsingChatMessages:
|
||||
"""A simple component that generates text."""
|
||||
|
||||
@component.output_types(reply=str)
|
||||
def run(self, messages: list[ChatMessage]) -> dict[str, str]:
|
||||
"""
|
||||
A simple component that generates text.
|
||||
|
||||
:param messages: Users messages
|
||||
:return: A dictionary with the generated text.
|
||||
"""
|
||||
return {"reply": f"Hello, {messages[0].text}!"}
|
||||
|
||||
|
||||
@component
|
||||
class SimpleComponent:
|
||||
"""A simple component that generates text."""
|
||||
|
||||
def warm_up(self):
|
||||
"""
|
||||
Prepare the component for use.
|
||||
"""
|
||||
|
||||
@component.output_types(reply=str)
|
||||
def run(self, text: str) -> dict[str, str]:
|
||||
"""
|
||||
A simple component that generates text.
|
||||
|
||||
:param text: user's name
|
||||
:return: A dictionary with the generated text.
|
||||
"""
|
||||
return {"reply": f"Hello, {text}!"}
|
||||
|
||||
|
||||
def reply_formatter(input_text: str) -> str:
|
||||
return f"Formatted reply: {input_text}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""A simple user dataclass."""
|
||||
|
||||
name: str = "Anonymous"
|
||||
age: int = 0
|
||||
|
||||
|
||||
@component
|
||||
class UserGreeter:
|
||||
"""A simple component that processes a User."""
|
||||
|
||||
@component.output_types(message=str)
|
||||
def run(self, user: User) -> dict[str, str]:
|
||||
"""
|
||||
A simple component that processes a User.
|
||||
|
||||
:param user: The User object to process.
|
||||
:return: A dictionary with a message about the user.
|
||||
"""
|
||||
return {"message": f"User {user.name} is {user.age} years old"}
|
||||
|
||||
|
||||
@component
|
||||
class ListProcessor:
|
||||
"""A component that processes a list of strings."""
|
||||
|
||||
@component.output_types(concatenated=str)
|
||||
def run(self, texts: list[str]) -> dict[str, str]:
|
||||
"""
|
||||
Concatenates a list of strings into a single string.
|
||||
|
||||
:param texts: The list of strings to concatenate.
|
||||
:return: A dictionary with the concatenated string.
|
||||
"""
|
||||
return {"concatenated": " ".join(texts)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Address:
|
||||
"""A dataclass representing a physical address."""
|
||||
|
||||
street: str
|
||||
city: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
"""A person with an address."""
|
||||
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
|
||||
@component
|
||||
class PersonProcessor:
|
||||
"""A component that processes a Person with nested Address."""
|
||||
|
||||
@component.output_types(info=str)
|
||||
def run(self, person: Person) -> dict[str, str]:
|
||||
"""
|
||||
Creates information about the person.
|
||||
|
||||
:param person: The Person to process.
|
||||
:return: A dictionary with the person's information.
|
||||
"""
|
||||
return {"info": f"{person.name} lives at {person.address.street}, {person.address.city}."}
|
||||
|
||||
|
||||
@component
|
||||
class DocumentProcessor:
|
||||
"""A component that processes a list of Documents."""
|
||||
|
||||
@component.output_types(concatenated=str)
|
||||
def run(self, documents: list[Document], top_k: int = 5) -> dict[str, str]:
|
||||
"""
|
||||
Concatenates the content of multiple documents with newlines.
|
||||
|
||||
:param documents: List of Documents whose content will be concatenated
|
||||
:param top_k: The number of top documents to concatenate
|
||||
:returns: Dictionary containing the concatenated document contents
|
||||
"""
|
||||
return {"concatenated": "\n".join(doc.content for doc in documents[:top_k] if doc.content)}
|
||||
|
||||
|
||||
@component
|
||||
class FakeChatGenerator:
|
||||
def __init__(self, messages: list[ChatMessage]):
|
||||
self.messages = messages
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
def run(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
generation_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
tools: ToolsType | None = None,
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
return {"replies": self.messages}
|
||||
|
||||
|
||||
def output_handler(old, new):
|
||||
"""
|
||||
Output handler to test serialization.
|
||||
"""
|
||||
return old + new
|
||||
|
||||
|
||||
class TestComponentTool:
|
||||
def test_from_component_basic(self):
|
||||
tool = ComponentTool(component=SimpleComponent())
|
||||
|
||||
assert tool.name == "simple_component"
|
||||
assert tool.description == "A simple component that generates text."
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string", "description": "user's name"}},
|
||||
"required": ["text"],
|
||||
}
|
||||
|
||||
# Test tool invocation
|
||||
result = tool.invoke(text="world")
|
||||
assert isinstance(result, dict)
|
||||
assert "reply" in result
|
||||
assert result["reply"] == "Hello, world!"
|
||||
|
||||
def test_from_component_long_description(self):
|
||||
tool = ComponentTool(component=SimpleComponent(), description="".join(["A"] * 1024))
|
||||
assert len(tool.description) == 1024
|
||||
|
||||
def test_from_component_with_inputs_from_state(self):
|
||||
tool = ComponentTool(component=SimpleComponent(), inputs_from_state={"text": "text"})
|
||||
assert tool.inputs_from_state == {"text": "text"}
|
||||
# Inputs should be excluded from schema generation
|
||||
assert tool.parameters == {"type": "object", "properties": {}}
|
||||
|
||||
def test_from_component_with_inputs_from_state_different_names(self):
|
||||
tool = ComponentTool(component=SimpleComponent(), inputs_from_state={"state_text": "text"})
|
||||
assert tool.inputs_from_state == {"state_text": "text"}
|
||||
# Inputs should be excluded from schema generation
|
||||
assert tool.parameters == {"type": "object", "properties": {}}
|
||||
|
||||
def test_from_component_with_invalid_inputs_from_state_nested_dict(self):
|
||||
"""Test that ComponentTool rejects nested dict format for inputs_from_state"""
|
||||
with pytest.raises(TypeError, match="must be str, not dict"):
|
||||
ComponentTool(component=SimpleComponent(), inputs_from_state={"documents": {"source": "documents"}}) # type: ignore[dict-item]
|
||||
|
||||
def test_from_component_with_outputs_to_state(self):
|
||||
tool = ComponentTool(component=SimpleComponent(), outputs_to_state={"replies": {"source": "reply"}})
|
||||
assert tool.outputs_to_state == {"replies": {"source": "reply"}}
|
||||
|
||||
def test_from_component_with_invalid_outputs_to_state_source(self):
|
||||
"""Test that ComponentTool validates outputs_to_state source against component outputs"""
|
||||
with pytest.raises(ValueError, match="unknown output"):
|
||||
ComponentTool(component=SimpleComponent(), outputs_to_state={"result": {"source": "nonexistent"}})
|
||||
|
||||
def test_from_component_with_dataclass(self):
|
||||
tool = ComponentTool(component=UserGreeter())
|
||||
assert tool.parameters == {
|
||||
"$defs": {
|
||||
"User": {
|
||||
"properties": {
|
||||
"name": {"description": "Field 'name' of 'User'.", "type": "string", "default": "Anonymous"},
|
||||
"age": {"description": "Field 'age' of 'User'.", "type": "integer", "default": 0},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {"user": {"$ref": "#/$defs/User", "description": "The User object to process."}},
|
||||
"required": ["user"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
assert tool.name == "user_greeter"
|
||||
assert tool.description == "A simple component that processes a User."
|
||||
|
||||
# Test tool invocation
|
||||
result = tool.invoke(user={"name": "Alice", "age": 30})
|
||||
assert isinstance(result, dict)
|
||||
assert "message" in result
|
||||
assert result["message"] == "User Alice is 30 years old"
|
||||
|
||||
def test_from_component_with_list_input(self):
|
||||
tool = ComponentTool(
|
||||
component=ListProcessor(), name="list_processing_tool", description="A tool that concatenates strings"
|
||||
)
|
||||
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"texts": {
|
||||
"type": "array",
|
||||
"description": "The list of strings to concatenate.",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
"required": ["texts"],
|
||||
}
|
||||
|
||||
# Test tool invocation
|
||||
result = tool.invoke(texts=["hello", "world"])
|
||||
assert isinstance(result, dict)
|
||||
assert "concatenated" in result
|
||||
assert result["concatenated"] == "hello world"
|
||||
|
||||
def test_from_component_with_nested_dataclass(self):
|
||||
tool = ComponentTool(
|
||||
component=PersonProcessor(), name="person_tool", description="A tool that processes people"
|
||||
)
|
||||
|
||||
assert tool.parameters == {
|
||||
"$defs": {
|
||||
"Address": {
|
||||
"properties": {
|
||||
"street": {"description": "Field 'street' of 'Address'.", "type": "string"},
|
||||
"city": {"description": "Field 'city' of 'Address'.", "type": "string"},
|
||||
},
|
||||
"required": ["street", "city"],
|
||||
"type": "object",
|
||||
},
|
||||
"Person": {
|
||||
"properties": {
|
||||
"name": {"description": "Field 'name' of 'Person'.", "type": "string"},
|
||||
"address": {"$ref": "#/$defs/Address", "description": "Field 'address' of 'Person'."},
|
||||
},
|
||||
"required": ["name", "address"],
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"properties": {"person": {"$ref": "#/$defs/Person", "description": "The Person to process."}},
|
||||
"required": ["person"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
# Test tool invocation
|
||||
result = tool.invoke(person={"name": "Diana", "address": {"street": "123 Elm Street", "city": "Metropolis"}})
|
||||
assert isinstance(result, dict)
|
||||
assert "info" in result
|
||||
assert result["info"] == "Diana lives at 123 Elm Street, Metropolis."
|
||||
|
||||
def test_from_component_with_list_of_documents(self):
|
||||
tool = ComponentTool(
|
||||
component=DocumentProcessor(),
|
||||
name="document_processor",
|
||||
description="A tool that concatenates document contents",
|
||||
)
|
||||
|
||||
assert tool.parameters == {
|
||||
"$defs": {
|
||||
"ByteStream": BYTE_STREAM_SCHEMA,
|
||||
"Document": DOCUMENT_SCHEMA,
|
||||
"SparseEmbedding": SPARSE_EMBEDDING_SCHEMA,
|
||||
},
|
||||
"properties": {
|
||||
"documents": {
|
||||
"description": "List of Documents whose content will be concatenated",
|
||||
"items": {"$ref": "#/$defs/Document"},
|
||||
"type": "array",
|
||||
},
|
||||
"top_k": {"description": "The number of top documents to concatenate", "type": "integer", "default": 5},
|
||||
},
|
||||
"required": ["documents"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
# Test tool invocation
|
||||
result = tool.invoke(documents=[{"content": "First document"}, {"content": "Second document"}])
|
||||
assert isinstance(result, dict)
|
||||
assert "concatenated" in result
|
||||
assert result["concatenated"] == "First document\nSecond document"
|
||||
|
||||
def test_from_component_with_dynamic_input_types(self):
|
||||
builder = PromptBuilder(template="Hello, {{name}}!")
|
||||
tool = ComponentTool(component=builder, name="prompt_builder_tool")
|
||||
assert tool.parameters == {
|
||||
"properties": {
|
||||
"name": {"description": "Input 'name' for the component."},
|
||||
"template": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "An optional string template to overwrite PromptBuilder's default template. If "
|
||||
"None, the default template\nprovided at initialization is used.",
|
||||
},
|
||||
"template_variables": {
|
||||
"anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "An optional dictionary of template variables to overwrite the pipeline variables.",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def test_from_component_with_invalid_component(self):
|
||||
class NotAComponent:
|
||||
def foo(self, text: str) -> dict[str, str]:
|
||||
return {"reply": f"Hello, {text}!"}
|
||||
|
||||
not_a_component = NotAComponent()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
ComponentTool(component=not_a_component, name="invalid_tool", description="This should fail") # type: ignore[arg-type]
|
||||
|
||||
def test_component_invoker_with_chat_message_input(self):
|
||||
tool = ComponentTool(
|
||||
component=SimpleComponentUsingChatMessages(), name="simple_tool", description="A simple tool"
|
||||
)
|
||||
result = tool.invoke(messages=[ChatMessage.from_user(text="world")])
|
||||
assert result == {"reply": "Hello, world!"}
|
||||
|
||||
def test_component_tool_with_super_component_docstrings(self, monkeypatch):
|
||||
"""Test that ComponentTool preserves docstrings from underlying pipeline components in SuperComponents."""
|
||||
|
||||
@component
|
||||
class AnnotatedComponent:
|
||||
"""An annotated component with descriptive parameter docstrings."""
|
||||
|
||||
@component.output_types(result=str)
|
||||
def run(self, text: str, number: int = 42) -> dict[str, str]:
|
||||
"""
|
||||
Process inputs and return result.
|
||||
|
||||
:param text: A detailed description of the text parameter that should be preserved
|
||||
:param number: A detailed description of the number parameter that should be preserved
|
||||
"""
|
||||
return {"result": f"Processed: {text} and {number}"}
|
||||
|
||||
# Create a pipeline with the annotated component
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("processor", AnnotatedComponent())
|
||||
# Create SuperComponent with mapping
|
||||
super_comp = SuperComponent(
|
||||
pipeline=pipeline,
|
||||
input_mapping={"input_text": ["processor.text"], "input_number": ["processor.number"]},
|
||||
output_mapping={"processor.result": "processed_result"},
|
||||
)
|
||||
|
||||
# Create ComponentTool from SuperComponent
|
||||
tool = ComponentTool(component=super_comp, name="text_processor")
|
||||
|
||||
# Verify that schema includes the per-parameter docstrings from the original component
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input_text": {
|
||||
"type": "string",
|
||||
"description": "Provided to the 'processor' component as: 'A detailed description of the text "
|
||||
"parameter that should be preserved'.",
|
||||
},
|
||||
"input_number": {
|
||||
"type": "integer",
|
||||
"description": "Provided to the 'processor' component as: 'A detailed description of the number "
|
||||
"parameter that should be preserved'.",
|
||||
},
|
||||
},
|
||||
"required": ["input_text"],
|
||||
}
|
||||
|
||||
# Test the tool functionality works
|
||||
result = tool.invoke(input_text="Hello", input_number=42)
|
||||
assert result["processed_result"] == "Processed: Hello and 42"
|
||||
|
||||
def test_component_tool_with_multiple_mapped_docstrings(self):
|
||||
"""
|
||||
Test ComponentTool combines docstrings from multiple components when a single input maps to multiple components.
|
||||
"""
|
||||
|
||||
@component
|
||||
class ComponentA:
|
||||
"""Component A with descriptive docstrings."""
|
||||
|
||||
@component.output_types(output_a=str)
|
||||
def run(self, query: str) -> dict[str, str]:
|
||||
"""
|
||||
Process query in component A.
|
||||
|
||||
:param query: The query string for component A
|
||||
"""
|
||||
return {"output_a": f"A processed: {query}"}
|
||||
|
||||
@component
|
||||
class ComponentB:
|
||||
"""Component B with descriptive docstrings."""
|
||||
|
||||
@component.output_types(output_b=str)
|
||||
def run(self, text: str) -> dict[str, str]:
|
||||
"""
|
||||
Process text in component B.
|
||||
|
||||
:param text: Text to process in component B
|
||||
"""
|
||||
return {"output_b": f"B processed: {text}"}
|
||||
|
||||
# Create a pipeline with both components
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("comp_a", ComponentA())
|
||||
pipeline.add_component("comp_b", ComponentB())
|
||||
|
||||
# Create SuperComponent with a single input mapped to both components
|
||||
super_comp = SuperComponent(
|
||||
pipeline=pipeline, input_mapping={"combined_input": ["comp_a.query", "comp_b.text"]}
|
||||
)
|
||||
|
||||
# Create ComponentTool from SuperComponent
|
||||
tool = ComponentTool(component=super_comp, name="combined_processor")
|
||||
|
||||
# Verify that schema includes combined per-parameter docstrings from both components
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"combined_input": {
|
||||
"type": "string",
|
||||
"description": "Provided to the 'comp_a' component as: 'The query string for component A', and "
|
||||
"Provided to the 'comp_b' component as: 'Text to process in component B'.",
|
||||
}
|
||||
},
|
||||
"required": ["combined_input"],
|
||||
}
|
||||
|
||||
# Test the tool functionality works
|
||||
result = tool.invoke(combined_input="test input")
|
||||
assert result["output_a"] == "A processed: test input"
|
||||
assert result["output_b"] == "B processed: test input"
|
||||
|
||||
def test_warm_up_is_idempotent(self):
|
||||
"""Test that calling warm_up multiple times only warms up the component once."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
component = SimpleComponent()
|
||||
|
||||
tool = ComponentTool(component=component)
|
||||
|
||||
with patch.object(component, "warm_up", MagicMock()) as mock_warm_up:
|
||||
# Call warm_up multiple times
|
||||
tool.warm_up()
|
||||
tool.warm_up()
|
||||
tool.warm_up()
|
||||
|
||||
# Component's warm_up should only be called once
|
||||
mock_warm_up.assert_called_once()
|
||||
|
||||
def test_from_component_with_callable_params_skipped(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"))
|
||||
|
||||
tool = ComponentTool(
|
||||
component=agent,
|
||||
name="agent_tool",
|
||||
description="An agent tool",
|
||||
outputs_to_string={"source": "last_message"},
|
||||
)
|
||||
|
||||
assert tool.name == "agent_tool"
|
||||
assert tool.description == "An agent tool"
|
||||
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "snapshot_callback" not in param_names
|
||||
assert "streaming_callback" not in param_names
|
||||
assert "messages" in param_names
|
||||
|
||||
def test_from_component_with_state_param_excluded_from_schema(self):
|
||||
@component
|
||||
class ComponentWithState:
|
||||
"""A component that takes State as a direct input."""
|
||||
|
||||
@component.output_types(result=str)
|
||||
def run(self, query: str, state: State) -> dict:
|
||||
return {"result": query}
|
||||
|
||||
tool = ComponentTool(component=ComponentWithState(), name="state_comp", description="test")
|
||||
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "state" not in param_names
|
||||
assert "query" in param_names
|
||||
|
||||
def test_from_component_with_optional_state_param_excluded_from_schema(self):
|
||||
@component
|
||||
class ComponentWithOptionalState:
|
||||
"""A component that takes Optional[State] as an input (e.g. Agent tool-calling style)."""
|
||||
|
||||
@component.output_types(result=str)
|
||||
def run(self, query: str, state: State | None = None) -> dict:
|
||||
return {"result": query}
|
||||
|
||||
tool = ComponentTool(component=ComponentWithOptionalState(), name="opt_state_comp", description="test")
|
||||
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "state" not in param_names
|
||||
assert "query" in param_names
|
||||
|
||||
def test_component_invoker_with_agent(self):
|
||||
"""Tests that Agent as a ComponentTool can be invoked when calling it with a list of dicts"""
|
||||
agent = Agent(chat_generator=FakeChatGenerator(messages=[ChatMessage.from_assistant("Answer")]))
|
||||
tool = ComponentTool(
|
||||
component=agent,
|
||||
name="agent_tool",
|
||||
description="An agent tool",
|
||||
outputs_to_string={"source": "last_message"},
|
||||
)
|
||||
result = tool.invoke(messages=[{"role": "user", "content": [{"text": "A 4-day trip in the south of France"}]}])
|
||||
assert result["last_message"] == ChatMessage.from_assistant("Answer")
|
||||
|
||||
def test_convert_param_union_with_list_arm(self):
|
||||
@component
|
||||
class ComponentWithUnionMessages:
|
||||
@component.output_types(reply=str)
|
||||
def run(self, messages: list[ChatMessage] | str) -> dict:
|
||||
return {"reply": "ok"}
|
||||
|
||||
tool = ComponentTool(component=ComponentWithUnionMessages())
|
||||
result = tool._convert_param([{"role": "user", "content": "Hello"}], list[ChatMessage] | str) # type: ignore[arg-type]
|
||||
assert result == [ChatMessage.from_user("Hello")]
|
||||
|
||||
|
||||
def _agent_tool_messages(result: dict[str, Any]) -> list[ChatMessage]:
|
||||
return [message for message in result["agent"]["messages"] if message.is_from(ChatRole.TOOL)]
|
||||
|
||||
|
||||
class TestComponentToolInAgent:
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_component_tool(self):
|
||||
# Create component and convert it to tool
|
||||
tool = ComponentTool(
|
||||
component=SimpleComponent(),
|
||||
name="hello_tool",
|
||||
description="A tool that generates a greeting message for the user",
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(text="Using a single tool call, greet Vladimir")
|
||||
|
||||
# Run pipeline
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
|
||||
# Check results
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None
|
||||
assert "Vladimir" in tool_call_result.result
|
||||
assert not tool_call_result.error
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.flaky(reruns=3, reruns_delay=10)
|
||||
def test_component_tool_openai_tools_strict(self):
|
||||
# Create component and convert it to tool
|
||||
tool = ComponentTool(
|
||||
component=SimpleComponent(),
|
||||
name="hello_tool",
|
||||
description="A tool that generates a greeting message for the user",
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(tools_strict=True), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(text="Using tools, greet Vladimir")
|
||||
|
||||
# Run pipeline
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
|
||||
# Check results
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None
|
||||
assert "Vladimir" in tool_call_result.result
|
||||
assert not tool_call_result.error
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_user_greeter(self):
|
||||
tool = ComponentTool(
|
||||
component=UserGreeter(), name="user_greeter", description="A tool that greets users with their name and age"
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(text="Greet the user Alice who is 30 years old")
|
||||
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None
|
||||
assert tool_call_result.result == json.dumps({"message": "User Alice is 30 years old"})
|
||||
assert not tool_call_result.error
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_list_processor(self):
|
||||
tool = ComponentTool(
|
||||
component=ListProcessor(), name="list_processor", description="A tool that concatenates a list of strings"
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
# Be explicit about using tools, otherwise model will ignore the tool call and return the result directly.
|
||||
message = ChatMessage.from_user(text="Using tools, join these words: hello, beautiful, world")
|
||||
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
# The model may issue one or more parallel tool calls, so check the content across all of them.
|
||||
assert len(tool_messages) >= 1
|
||||
|
||||
combined = ""
|
||||
for tool_message in tool_messages:
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None and not tool_call_result.error
|
||||
assert isinstance(tool_call_result.result, str)
|
||||
assert "concatenated" in tool_call_result.result
|
||||
combined += " " + tool_call_result.result
|
||||
# Normalize whitespace and check the concatenated output contains the expected words.
|
||||
normalized_result = " ".join(combined.split())
|
||||
assert "hello" in normalized_result
|
||||
assert "beautiful" in normalized_result
|
||||
assert "world" in normalized_result
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_person_processor(self):
|
||||
tool = ComponentTool(
|
||||
component=PersonProcessor(),
|
||||
name="person_processor",
|
||||
description="A tool that processes information about a person and their address",
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(
|
||||
text="Process information about the person Diana who lives at 123 Elm Street in Metropolis"
|
||||
)
|
||||
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None
|
||||
assert "Diana" in tool_call_result.result and "Metropolis" in tool_call_result.result
|
||||
assert not tool_call_result.error
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_document_processor(self):
|
||||
tool = ComponentTool(
|
||||
component=DocumentProcessor(),
|
||||
name="document_processor",
|
||||
description="A tool that concatenates the content of multiple documents",
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(
|
||||
text="Concatenate these documents: First one says 'Hello world' and second one says 'Goodbye world' and "
|
||||
"third one says 'Hello again', but use top_k=2. Set only content field of the document only. Do "
|
||||
"not set id, meta, score, embedding, sparse_embedding, dataframe, blob fields."
|
||||
)
|
||||
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
tool_call_result = tool_message.tool_call_result
|
||||
assert tool_call_result is not None
|
||||
assert isinstance(tool_call_result.result, str)
|
||||
result = json.loads(tool_call_result.result)
|
||||
assert "concatenated" in result
|
||||
assert "Hello world" in result["concatenated"]
|
||||
assert "Goodbye world" in result["concatenated"]
|
||||
assert not tool_call_result.error
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
@pytest.mark.integration
|
||||
def test_lost_in_middle_ranker(self):
|
||||
from haystack.components.rankers import LostInTheMiddleRanker
|
||||
|
||||
tool = ComponentTool(
|
||||
component=LostInTheMiddleRanker(),
|
||||
name="lost_in_middle_ranker",
|
||||
description="A tool that ranks documents using the Lost in the Middle algorithm and returns top k results",
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
message = ChatMessage.from_user(
|
||||
text="I have three documents with content: 'First doc', 'Middle doc', and 'Last doc'. Rank them top_k=2. "
|
||||
"Set only content field of the document only. Do not set id, meta, score, embedding, "
|
||||
"sparse_embedding, dataframe, blob fields."
|
||||
)
|
||||
|
||||
result = pipeline.run({"agent": {"messages": [message]}})
|
||||
|
||||
tool_messages = _agent_tool_messages(result)
|
||||
assert len(tool_messages) == 1
|
||||
tool_message = tool_messages[0]
|
||||
assert tool_message.is_from(ChatRole.TOOL)
|
||||
|
||||
def test_serde(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
# Create the component and tool
|
||||
tool = ComponentTool(component=SimpleComponent(), name="hello_tool", description="A simple greeting tool")
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[tool]))
|
||||
|
||||
# Serialize to dict and verify structure
|
||||
pipeline_dict = pipeline.to_dict()
|
||||
assert pipeline_dict["components"]["agent"]["type"] == "haystack.components.agents.agent.Agent"
|
||||
assert len(pipeline_dict["components"]["agent"]["init_parameters"]["tools"]) == 1
|
||||
|
||||
tool_dict = pipeline_dict["components"]["agent"]["init_parameters"]["tools"][0]
|
||||
assert tool_dict["type"] == "haystack.tools.component_tool.ComponentTool"
|
||||
assert tool_dict["data"]["name"] == "hello_tool"
|
||||
assert tool_dict["data"]["component"]["type"] == "test_component_tool.SimpleComponent"
|
||||
|
||||
# Test round-trip serialization
|
||||
pipeline_yaml = pipeline.dumps()
|
||||
new_pipeline = Pipeline.loads(pipeline_yaml)
|
||||
assert new_pipeline.to_dict() == pipeline_dict
|
||||
|
||||
def test_component_tool_serde(self):
|
||||
tool = ComponentTool(
|
||||
component=SimpleComponent(),
|
||||
name="simple_tool",
|
||||
description="A simple tool",
|
||||
outputs_to_string={"source": "reply", "handler": reply_formatter},
|
||||
inputs_from_state={"test": "text"},
|
||||
outputs_to_state={"output": {"source": "reply", "handler": output_handler}},
|
||||
)
|
||||
|
||||
# Test serialization
|
||||
expected_tool_dict = {
|
||||
"type": "haystack.tools.component_tool.ComponentTool",
|
||||
"data": {
|
||||
"component": {"type": "test_component_tool.SimpleComponent", "init_parameters": {}},
|
||||
"name": "simple_tool",
|
||||
"description": "A simple tool",
|
||||
"parameters": None,
|
||||
"outputs_to_string": {"source": "reply", "handler": "test_component_tool.reply_formatter"},
|
||||
"inputs_from_state": {"test": "text"},
|
||||
"outputs_to_state": {"output": {"source": "reply", "handler": "test_component_tool.output_handler"}},
|
||||
},
|
||||
}
|
||||
tool_dict = tool.to_dict()
|
||||
assert tool_dict == expected_tool_dict
|
||||
|
||||
# Test deserialization
|
||||
new_tool = ComponentTool.from_dict(expected_tool_dict)
|
||||
assert new_tool.name == tool.name
|
||||
assert new_tool.description == tool.description
|
||||
assert new_tool.parameters == tool.parameters
|
||||
assert new_tool.outputs_to_string == tool.outputs_to_string
|
||||
assert new_tool.inputs_from_state == tool.inputs_from_state
|
||||
assert new_tool.outputs_to_state == tool.outputs_to_state
|
||||
assert isinstance(new_tool._component, SimpleComponent)
|
||||
|
||||
def test_pipeline_component_fails(self):
|
||||
comp = SimpleComponent()
|
||||
|
||||
# Create a pipeline and add the component to it
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("simple", comp)
|
||||
|
||||
# Try to create a tool from the component and it should fail because the component has been added to a pipeline
|
||||
# and thus can't be used as tool
|
||||
with pytest.raises(ValueError, match="Component has been added to a pipeline"):
|
||||
ComponentTool(component=comp)
|
||||
|
||||
def test_deepcopy_with_jinja_based_component(self):
|
||||
builder = PromptBuilder("{{query}}")
|
||||
tool = ComponentTool(component=builder)
|
||||
assert tool.function is not None
|
||||
result = tool.function(query="Hello")
|
||||
tool_copy = _deepcopy_with_exceptions(tool)
|
||||
assert tool_copy.function is not None
|
||||
result_from_copy = tool_copy.function(query="Hello")
|
||||
assert "prompt" in result_from_copy
|
||||
assert result_from_copy["prompt"] == result["prompt"]
|
||||
|
||||
def test_jinja_based_component_tool(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
|
||||
mock_create.return_value = ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4o-mini",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="length",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(role="assistant", content="A response from the model"),
|
||||
)
|
||||
],
|
||||
created=1234567890,
|
||||
)
|
||||
|
||||
tool = ComponentTool(component=PromptBuilder("{{query}}"))
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("llm", OpenAIChatGenerator())
|
||||
result = pipeline.run({"llm": {"messages": [ChatMessage.from_user(text="Hello")], "tools": [tool]}})
|
||||
|
||||
assert result["llm"]["replies"][0].text == "A response from the model"
|
||||
|
||||
|
||||
@component
|
||||
class SyncOnlyComponent:
|
||||
@component.output_types(reply=str)
|
||||
def run(self, text: str) -> dict[str, str]:
|
||||
return {"reply": f"sync:{text}"}
|
||||
|
||||
|
||||
@component
|
||||
class DualModeComponent:
|
||||
@component.output_types(reply=str)
|
||||
def run(self, text: str) -> dict[str, str]:
|
||||
return {"reply": f"sync:{text}"}
|
||||
|
||||
@component.output_types(reply=str)
|
||||
async def run_async(self, text: str) -> dict[str, str]:
|
||||
return {"reply": f"async:{text}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_tool():
|
||||
return ComponentTool(component=SyncOnlyComponent())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dual_tool():
|
||||
return ComponentTool(component=DualModeComponent())
|
||||
|
||||
|
||||
class TestComponentToolAsync:
|
||||
def test_async_function_is_wired_only_when_component_has_run_async(self, sync_tool, dual_tool):
|
||||
assert sync_tool.function is not None
|
||||
assert sync_tool.async_function is None
|
||||
assert dual_tool.function is not None
|
||||
assert dual_tool.async_function is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async_uses_run_async_when_available(self, dual_tool):
|
||||
assert await dual_tool.invoke_async(text="hi") == {"reply": "async:hi"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async_falls_back_to_run_for_sync_only_component(self, sync_tool):
|
||||
assert await sync_tool.invoke_async(text="hi") == {"reply": "sync:hi"}
|
||||
|
||||
def test_invoke_uses_run_on_dual_mode_component(self, dual_tool):
|
||||
assert dual_tool.invoke(text="hi") == {"reply": "sync:hi"}
|
||||
@@ -0,0 +1,372 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.agents.state import State
|
||||
from haystack.tools.errors import SchemaGenerationError
|
||||
from haystack.tools.from_function import _remove_title_from_schema, create_tool_from_function, tool
|
||||
from haystack.tools.tool import Tool
|
||||
|
||||
|
||||
def function_with_docstring(city: str) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
def test_from_function_description_from_docstring():
|
||||
tool = create_tool_from_function(function=function_with_docstring)
|
||||
|
||||
assert tool.name == "function_with_docstring"
|
||||
assert tool.description == "Get weather report for a city."
|
||||
assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
assert tool.function == function_with_docstring
|
||||
|
||||
|
||||
def test_from_function_with_empty_description():
|
||||
tool = create_tool_from_function(function=function_with_docstring, description="")
|
||||
|
||||
assert tool.name == "function_with_docstring"
|
||||
assert tool.description == ""
|
||||
assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
assert tool.function == function_with_docstring
|
||||
|
||||
|
||||
def test_from_function_with_custom_description():
|
||||
tool = create_tool_from_function(function=function_with_docstring, description="custom description")
|
||||
|
||||
assert tool.name == "function_with_docstring"
|
||||
assert tool.description == "custom description"
|
||||
assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
assert tool.function == function_with_docstring
|
||||
|
||||
|
||||
def test_from_function_with_custom_name():
|
||||
tool = create_tool_from_function(function=function_with_docstring, name="custom_name")
|
||||
|
||||
assert tool.name == "custom_name"
|
||||
assert tool.description == "Get weather report for a city."
|
||||
assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
assert tool.function == function_with_docstring
|
||||
|
||||
|
||||
def test_from_function_annotated():
|
||||
def function_with_annotations(
|
||||
city: Annotated[str, "the city for which to get the weather"] = "Munich",
|
||||
unit: Annotated[Literal["Celsius", "Fahrenheit"], "the unit for the temperature"] = "Celsius",
|
||||
nullable_param: Annotated[str | None, "a nullable parameter"] = None,
|
||||
) -> str:
|
||||
"""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(function=function_with_annotations)
|
||||
|
||||
assert tool.name == "function_with_annotations"
|
||||
assert tool.description == "A simple function to get the current weather for a location."
|
||||
assert tool.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",
|
||||
},
|
||||
"nullable_param": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "a nullable parameter",
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_from_function_missing_type_hint():
|
||||
def function_missing_type_hint(city) -> str: # type: ignore[no-untyped-def]
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
create_tool_from_function(function=function_missing_type_hint)
|
||||
|
||||
|
||||
def test_from_function_schema_generation_error():
|
||||
def function_with_invalid_type_hint(city: "invalid") -> str: # type: ignore[name-defined] # noqa: F821
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
with pytest.raises(SchemaGenerationError):
|
||||
create_tool_from_function(function=function_with_invalid_type_hint)
|
||||
|
||||
|
||||
def test_from_function_with_callable_params_skipped():
|
||||
def function_with_callback(query: str, callback: Callable[[str], None] | None = None) -> str:
|
||||
"""A function with a callable parameter."""
|
||||
return query
|
||||
|
||||
tool = create_tool_from_function(function=function_with_callback)
|
||||
|
||||
assert tool.name == "function_with_callback"
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "callback" not in param_names
|
||||
assert "query" in param_names
|
||||
|
||||
|
||||
def test_from_function_state_param_excluded_from_schema():
|
||||
def function_with_state(city: str, state: State) -> str:
|
||||
"""Get weather for a city, with access to agent state."""
|
||||
return f"Weather in {city}: sunny"
|
||||
|
||||
tool = create_tool_from_function(function=function_with_state)
|
||||
|
||||
assert tool.name == "function_with_state"
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "state" not in param_names
|
||||
assert "city" in param_names
|
||||
assert tool.parameters == {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
|
||||
|
||||
def test_tool_decorator_state_param_excluded_from_schema():
|
||||
@tool
|
||||
def function_with_state(city: str, state: State) -> str:
|
||||
"""Get weather for a city, with access to agent state."""
|
||||
return f"Weather in {city}: sunny"
|
||||
|
||||
param_names = list(function_with_state.parameters.get("properties", {}).keys())
|
||||
assert "state" not in param_names
|
||||
assert "city" in param_names
|
||||
|
||||
|
||||
def test_from_function_optional_state_param_excluded_from_schema():
|
||||
def function_with_optional_state(city: str, state: State | None = None) -> str:
|
||||
"""Get weather for a city, optionally using agent state."""
|
||||
return f"Weather in {city}: sunny"
|
||||
|
||||
tool = create_tool_from_function(function=function_with_optional_state)
|
||||
|
||||
param_names = list(tool.parameters.get("properties", {}).keys())
|
||||
assert "state" not in param_names
|
||||
assert "city" in param_names
|
||||
|
||||
|
||||
def test_tool_decorator():
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
assert get_weather.name == "get_weather"
|
||||
assert get_weather.description == "Get weather report for a city."
|
||||
assert get_weather.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
}
|
||||
assert callable(get_weather.function)
|
||||
assert get_weather.function("Berlin") == "Weather report for Berlin: 20°C, sunny"
|
||||
|
||||
|
||||
# Test function for decorator deserialization
|
||||
@tool
|
||||
def weather_tool_with_decorator(city: str) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
def test_tool_decorator_deserialization():
|
||||
serialized = weather_tool_with_decorator.to_dict()
|
||||
deserialized = Tool.from_dict(serialized)
|
||||
assert deserialized.name == "weather_tool_with_decorator"
|
||||
assert deserialized.description == "Get weather report for a city."
|
||||
assert deserialized.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
}
|
||||
|
||||
|
||||
def test_tool_decorator_with_annotated_params():
|
||||
@tool
|
||||
def get_weather(
|
||||
city: Annotated[str, "The target city"] = "Berlin",
|
||||
output_format: Annotated[Literal["short", "long"], "Output format"] = "short",
|
||||
) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city} ({output_format} format): 20°C, sunny"
|
||||
|
||||
assert get_weather.name == "get_weather"
|
||||
assert get_weather.description == "Get weather report for a city."
|
||||
assert get_weather.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "The target city", "default": "Berlin"},
|
||||
"output_format": {
|
||||
"type": "string",
|
||||
"enum": ["short", "long"],
|
||||
"description": "Output format",
|
||||
"default": "short",
|
||||
},
|
||||
},
|
||||
}
|
||||
assert callable(get_weather.function)
|
||||
assert get_weather.function("Berlin", "short") == "Weather report for Berlin (short format): 20°C, sunny"
|
||||
|
||||
|
||||
def test_tool_decorator_with_parameters():
|
||||
@tool(name="fetch_weather", description="A tool to check the weather.")
|
||||
def get_weather(
|
||||
city: Annotated[str, "The target city"] = "Berlin",
|
||||
output_format: Annotated[Literal["short", "long"], "Output format"] = "short",
|
||||
) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city} ({output_format} format): 20°C, sunny"
|
||||
|
||||
assert get_weather.name == "fetch_weather"
|
||||
assert get_weather.description == "A tool to check the weather."
|
||||
|
||||
|
||||
def test_tool_decorator_with_inputs_and_outputs():
|
||||
@tool(inputs_from_state={"output_format": "output_format"}, outputs_to_state={"output": {"source": "output"}})
|
||||
def get_weather(
|
||||
city: Annotated[str, "The target city"] = "Berlin",
|
||||
output_format: Annotated[Literal["short", "long"], "Output format"] = "short",
|
||||
) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city} ({output_format} format): 20°C, sunny"
|
||||
|
||||
assert get_weather.name == "get_weather"
|
||||
assert get_weather.inputs_from_state == {"output_format": "output_format"}
|
||||
assert get_weather.outputs_to_state == {"output": {"source": "output"}}
|
||||
# Inputs should be excluded from auto-generated parameters
|
||||
assert get_weather.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string", "description": "The target city", "default": "Berlin"}},
|
||||
}
|
||||
|
||||
|
||||
def test_remove_title_from_schema():
|
||||
complex_schema = {
|
||||
"properties": {
|
||||
"parameter1": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}],
|
||||
"default": "default_value",
|
||||
"title": "Parameter1",
|
||||
},
|
||||
"parameter2": {
|
||||
"default": [1, 2, 3],
|
||||
"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
|
||||
"title": "Parameter2",
|
||||
"type": "array",
|
||||
},
|
||||
"parameter3": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
{"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array"},
|
||||
],
|
||||
"default": 42,
|
||||
"title": "Parameter3",
|
||||
},
|
||||
"parameter4": {
|
||||
"anyOf": [{"type": "string"}, {"items": {"type": "integer"}, "type": "array"}, {"type": "object"}],
|
||||
"default": {"key": "value"},
|
||||
"title": "Parameter4",
|
||||
},
|
||||
},
|
||||
"title": "complex_function",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
_remove_title_from_schema(complex_schema)
|
||||
|
||||
assert complex_schema == {
|
||||
"properties": {
|
||||
"parameter1": {"anyOf": [{"type": "string"}, {"type": "integer"}], "default": "default_value"},
|
||||
"parameter2": {
|
||||
"default": [1, 2, 3],
|
||||
"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
|
||||
"type": "array",
|
||||
},
|
||||
"parameter3": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
{"items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "type": "array"},
|
||||
],
|
||||
"default": 42,
|
||||
},
|
||||
"parameter4": {
|
||||
"anyOf": [{"type": "string"}, {"items": {"type": "integer"}, "type": "array"}, {"type": "object"}],
|
||||
"default": {"key": "value"},
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
|
||||
def test_remove_title_from_schema_do_not_remove_title_property():
|
||||
"""Test that the utility function only removes the 'title' keywords and not the 'title' property (if present)."""
|
||||
schema = {
|
||||
"properties": {
|
||||
"parameter1": {"type": "string", "title": "Parameter1"},
|
||||
"title": {"type": "string", "title": "Title"},
|
||||
},
|
||||
"title": "complex_function",
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
_remove_title_from_schema(schema)
|
||||
|
||||
assert schema == {"properties": {"parameter1": {"type": "string"}, "title": {"type": "string"}}, "type": "object"}
|
||||
|
||||
|
||||
def test_remove_title_from_schema_handle_no_title_in_top_level():
|
||||
schema = {
|
||||
"properties": {
|
||||
"parameter1": {"type": "string", "title": "Parameter1"},
|
||||
"parameter2": {"type": "integer", "title": "Parameter2"},
|
||||
},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
_remove_title_from_schema(schema)
|
||||
|
||||
assert schema == {
|
||||
"properties": {"parameter1": {"type": "string"}, "parameter2": {"type": "integer"}},
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
|
||||
async def async_function_with_docstring(city: str) -> str:
|
||||
"""Get weather report for a city."""
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
class TestFromFunctionAsync:
|
||||
def test_create_tool_from_async_function(self):
|
||||
tool_obj = create_tool_from_function(async_function_with_docstring)
|
||||
|
||||
assert tool_obj.function is None
|
||||
assert tool_obj.async_function is async_function_with_docstring
|
||||
assert tool_obj.name == "async_function_with_docstring"
|
||||
assert tool_obj.parameters == {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
}
|
||||
|
||||
def test_tool_decorator_on_async_function(self):
|
||||
decorated = tool(async_function_with_docstring)
|
||||
|
||||
assert decorated.function is None
|
||||
assert decorated.async_function is async_function_with_docstring
|
||||
assert decorated.name == "async_function_with_docstring"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async(self):
|
||||
decorated = tool(async_function_with_docstring)
|
||||
|
||||
assert await decorated.invoke_async(city="Berlin") == "Weather report for Berlin: 20°C, sunny"
|
||||
@@ -0,0 +1,399 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
|
||||
from typing import Union
|
||||
|
||||
import pytest
|
||||
from pydantic import Field, create_model
|
||||
|
||||
from haystack.dataclasses import ByteStream, ChatMessage, Document, TextContent, ToolCall, ToolCallResult
|
||||
from haystack.tools.from_function import _remove_title_from_schema
|
||||
from haystack.tools.parameters_schema_utils import _resolve_type
|
||||
|
||||
BYTE_STREAM_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string", "description": "The binary data stored in Bytestream.", "format": "binary"},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"description": "Additional metadata to be stored with the ByteStream.",
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"mime_type": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "The mime type of the binary data.",
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
|
||||
SPARSE_EMBEDDING_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"indices": {
|
||||
"type": "array",
|
||||
"description": "List of indices of non-zero elements in the embedding.",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "List of values of non-zero elements in the embedding.",
|
||||
"items": {"type": "number"},
|
||||
},
|
||||
},
|
||||
"required": ["indices", "values"],
|
||||
}
|
||||
|
||||
DOCUMENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the document. When not set, it's generated based on the Document "
|
||||
"fields' values.",
|
||||
"default": "",
|
||||
},
|
||||
"content": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Text of the document, if the document contains text.",
|
||||
},
|
||||
"blob": {
|
||||
"anyOf": [{"$ref": "#/$defs/ByteStream"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Binary data associated with the document, if the document has any binary data associated "
|
||||
"with it.",
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Additional custom metadata for the document. Must be JSON-serializable.",
|
||||
"default": {},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"score": {
|
||||
"anyOf": [{"type": "number"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Score of the document. Used for ranking, usually assigned by retrievers.",
|
||||
},
|
||||
"embedding": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "number"}}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "dense vector representation of the document.",
|
||||
},
|
||||
"sparse_embedding": {
|
||||
"anyOf": [{"$ref": "#/$defs/SparseEmbedding"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "sparse vector representation of the document.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TEXT_CONTENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string", "description": "The text content of the message."}},
|
||||
"required": ["text"],
|
||||
}
|
||||
|
||||
TOOL_CALL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_name": {"type": "string", "description": "The name of the Tool to call."},
|
||||
"arguments": {
|
||||
"type": "object",
|
||||
"description": "The arguments to call the Tool with.",
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"extra": {
|
||||
"anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Dictionary of extra information about the Tool call. Use to "
|
||||
"store provider-specific\n"
|
||||
"information. To avoid serialization issues, values should be "
|
||||
"JSON serializable.",
|
||||
},
|
||||
"id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "The ID of the Tool call.",
|
||||
},
|
||||
},
|
||||
"required": ["tool_name", "arguments"],
|
||||
}
|
||||
|
||||
TOOL_CALL_RESULT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/TextContent"},
|
||||
{"$ref": "#/$defs/ImageContent"},
|
||||
{"$ref": "#/$defs/FileContent"},
|
||||
]
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
],
|
||||
"description": "The result of the Tool invocation.",
|
||||
},
|
||||
"origin": {"$ref": "#/$defs/ToolCall", "description": "The Tool call that produced this result."},
|
||||
"error": {"type": "boolean", "description": "Whether the Tool invocation resulted in an error."},
|
||||
},
|
||||
"required": ["result", "origin", "error"],
|
||||
}
|
||||
|
||||
REASONING_CONTENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning_text": {"type": "string", "description": "The reasoning text produced by the model."},
|
||||
"extra": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"description": (
|
||||
"Dictionary of extra information about the reasoning content. Use to store "
|
||||
"provider-specific\ninformation. To avoid serialization issues, values should be JSON serializable."
|
||||
),
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"required": ["reasoning_text"],
|
||||
}
|
||||
|
||||
IMAGE_CONTENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base64_image": {"type": "string", "description": "A base64 string representing the image."},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"description": "Optional metadata for the image.",
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"mime_type": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": 'The MIME type of the image (e.g. "image/png", "image/jpeg").\n'
|
||||
"Providing this value is recommended, as most LLM providers require it.\n"
|
||||
"If not provided, the MIME type is guessed from the base64 string, "
|
||||
"which can be slow and not always reliable.",
|
||||
},
|
||||
"validation": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "If True (default), a validation process is performed:\n"
|
||||
"- Check whether the base64 string is valid;\n"
|
||||
"- Guess the MIME type if not provided;\n"
|
||||
"- Check if the MIME type is a valid image MIME type.\n"
|
||||
"Set to False to skip validation and speed up initialization.",
|
||||
},
|
||||
"detail": {
|
||||
"anyOf": [{"enum": ["auto", "high", "low"], "type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": (
|
||||
'Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".'
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["base64_image"],
|
||||
}
|
||||
|
||||
FILE_CONTENT_SCHEMA = {
|
||||
"properties": {
|
||||
"base64_data": {"description": "A base64 string representing the file.", "type": "string"},
|
||||
"extra": {
|
||||
"additionalProperties": True,
|
||||
"default": {},
|
||||
"description": "Dictionary of extra information about the file. Can be used "
|
||||
"to store provider-specific information.\n"
|
||||
"To avoid serialization issues, values should be JSON "
|
||||
"serializable.",
|
||||
"type": "object",
|
||||
},
|
||||
"filename": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Optional filename of the file. Some LLM providers use this information.",
|
||||
},
|
||||
"mime_type": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": 'The MIME type of the file (e.g. "application/pdf").\n'
|
||||
"Providing this value is recommended, as most LLM providers "
|
||||
"require it.\n"
|
||||
"If not provided, the MIME type is guessed from the base64 "
|
||||
"string, which can be slow and not always reliable.",
|
||||
},
|
||||
"validation": {
|
||||
"default": True,
|
||||
"description": "If True (default), a validation process is performed:\n"
|
||||
"- Check whether the base64 string is valid;\n"
|
||||
"- Guess the MIME type if not provided.\n"
|
||||
"Set to False to skip validation and speed up initialization.",
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"required": ["base64_data"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
CHAT_ROLE_SCHEMA = {
|
||||
"description": "Enumeration representing the roles within a chat.",
|
||||
"enum": ["user", "system", "assistant", "tool"],
|
||||
"type": "string",
|
||||
}
|
||||
|
||||
CHAT_MESSAGE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {"$ref": "#/$defs/ChatRole", "description": "Field 'role' of 'ChatMessage'."},
|
||||
"content": {
|
||||
"type": "array",
|
||||
"description": "Field 'content' of 'ChatMessage'.",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/TextContent"},
|
||||
{"$ref": "#/$defs/ToolCall"},
|
||||
{"$ref": "#/$defs/ToolCallResult"},
|
||||
{"$ref": "#/$defs/ImageContent"},
|
||||
{"$ref": "#/$defs/ReasoningContent"},
|
||||
{"$ref": "#/$defs/FileContent"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"default": None,
|
||||
"description": "Field 'name' of 'ChatMessage'.",
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Field 'meta' of 'ChatMessage'.",
|
||||
"default": {},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"required": ["role", "content"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"python_type, description, expected_schema, expected_defs_schema",
|
||||
[
|
||||
(
|
||||
ByteStream,
|
||||
"A byte stream",
|
||||
{"$ref": "#/$defs/ByteStream", "description": "A byte stream"},
|
||||
{"ByteStream": BYTE_STREAM_SCHEMA},
|
||||
),
|
||||
(
|
||||
Document,
|
||||
"A document",
|
||||
{"$ref": "#/$defs/Document", "description": "A document"},
|
||||
{"Document": DOCUMENT_SCHEMA, "SparseEmbedding": SPARSE_EMBEDDING_SCHEMA, "ByteStream": BYTE_STREAM_SCHEMA},
|
||||
),
|
||||
(
|
||||
TextContent,
|
||||
"A text content",
|
||||
{"$ref": "#/$defs/TextContent", "description": "A text content"},
|
||||
{"TextContent": TEXT_CONTENT_SCHEMA},
|
||||
),
|
||||
(
|
||||
ToolCall,
|
||||
"A tool call",
|
||||
{"$ref": "#/$defs/ToolCall", "description": "A tool call"},
|
||||
{"ToolCall": TOOL_CALL_SCHEMA},
|
||||
),
|
||||
(
|
||||
ToolCallResult,
|
||||
"A tool call result",
|
||||
{"$ref": "#/$defs/ToolCallResult", "description": "A tool call result"},
|
||||
{
|
||||
"ToolCallResult": TOOL_CALL_RESULT_SCHEMA,
|
||||
"ToolCall": TOOL_CALL_SCHEMA,
|
||||
"TextContent": TEXT_CONTENT_SCHEMA,
|
||||
"ImageContent": IMAGE_CONTENT_SCHEMA,
|
||||
"FileContent": FILE_CONTENT_SCHEMA,
|
||||
},
|
||||
),
|
||||
(
|
||||
ChatMessage,
|
||||
"A chat message",
|
||||
{"$ref": "#/$defs/ChatMessage", "description": "A chat message"},
|
||||
{
|
||||
"ChatMessage": CHAT_MESSAGE_SCHEMA,
|
||||
"TextContent": TEXT_CONTENT_SCHEMA,
|
||||
"ToolCall": TOOL_CALL_SCHEMA,
|
||||
"ToolCallResult": TOOL_CALL_RESULT_SCHEMA,
|
||||
"ChatRole": CHAT_ROLE_SCHEMA,
|
||||
"ImageContent": IMAGE_CONTENT_SCHEMA,
|
||||
"ReasoningContent": REASONING_CONTENT_SCHEMA,
|
||||
"FileContent": FILE_CONTENT_SCHEMA,
|
||||
},
|
||||
),
|
||||
(
|
||||
list[Document],
|
||||
"A list of documents",
|
||||
{"type": "array", "description": "A list of documents", "items": {"$ref": "#/$defs/Document"}},
|
||||
{"Document": DOCUMENT_SCHEMA, "SparseEmbedding": SPARSE_EMBEDDING_SCHEMA, "ByteStream": BYTE_STREAM_SCHEMA},
|
||||
),
|
||||
(
|
||||
list[ChatMessage],
|
||||
"A list of chat messages",
|
||||
{"type": "array", "description": "A list of chat messages", "items": {"$ref": "#/$defs/ChatMessage"}},
|
||||
{
|
||||
"ChatMessage": CHAT_MESSAGE_SCHEMA,
|
||||
"TextContent": TEXT_CONTENT_SCHEMA,
|
||||
"ToolCall": TOOL_CALL_SCHEMA,
|
||||
"ToolCallResult": TOOL_CALL_RESULT_SCHEMA,
|
||||
"ChatRole": CHAT_ROLE_SCHEMA,
|
||||
"ImageContent": IMAGE_CONTENT_SCHEMA,
|
||||
"ReasoningContent": REASONING_CONTENT_SCHEMA,
|
||||
"FileContent": FILE_CONTENT_SCHEMA,
|
||||
},
|
||||
),
|
||||
# PEP 604 union types (X | None syntax)
|
||||
(
|
||||
Document | None,
|
||||
"An optional document",
|
||||
{"anyOf": [{"$ref": "#/$defs/Document"}, {"type": "null"}], "description": "An optional document"},
|
||||
{"Document": DOCUMENT_SCHEMA, "SparseEmbedding": SPARSE_EMBEDDING_SCHEMA, "ByteStream": BYTE_STREAM_SCHEMA},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_parameters_schema_haystack_dataclasses(python_type, description, expected_schema, expected_defs_schema):
|
||||
resolved_type = _resolve_type(python_type)
|
||||
model = create_model(
|
||||
"run", __doc__="A test function", input_name=(resolved_type, Field(default=..., description=description))
|
||||
)
|
||||
parameters_schema = model.model_json_schema()
|
||||
_remove_title_from_schema(parameters_schema)
|
||||
|
||||
defs_schema = parameters_schema["$defs"]
|
||||
assert defs_schema == expected_defs_schema
|
||||
|
||||
property_schema = parameters_schema["properties"]["input_name"]
|
||||
assert property_schema == expected_schema
|
||||
|
||||
|
||||
def test_resolve_type_pep_604():
|
||||
resolved = _resolve_type(str | int)
|
||||
assert resolved == Union[str, int]
|
||||
|
||||
resolved = _resolve_type(str | None)
|
||||
assert resolved == Union[str, None]
|
||||
|
||||
resolved = _resolve_type(str | int | float)
|
||||
assert resolved == Union[str, int, float]
|
||||
|
||||
resolved = _resolve_type(list[str] | None)
|
||||
assert resolved == Union[list[str], None]
|
||||
|
||||
resolved = _resolve_type(dict[str, int] | list[str])
|
||||
assert resolved == Union[dict[str, int], list[str]]
|
||||
@@ -0,0 +1,381 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from unittest.mock import ANY
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document, Pipeline, component
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.embedders.openai_document_embedder import OpenAIDocumentEmbedder
|
||||
from haystack.components.embedders.openai_text_embedder import OpenAITextEmbedder
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever, InMemoryEmbeddingRetriever
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.tools import PipelineTool
|
||||
|
||||
|
||||
@component
|
||||
class MockSimilarityRanker:
|
||||
"""Mock ranker used to build a sample pipeline for tests."""
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
documents: list[Document],
|
||||
query: str,
|
||||
top_k: int | None = None,
|
||||
scale_score: bool | None = None,
|
||||
score_threshold: float | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Returns a list of documents ranked by their similarity to the given query.
|
||||
|
||||
:param documents: List of documents to rank.
|
||||
:param query: The input query to compare the documents to.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
:param scale_score: If `True`, scales the raw logit predictions using a Sigmoid activation function.
|
||||
If `False`, disables scaling of the raw logit predictions.
|
||||
If set, overrides the value set at initialization.
|
||||
:param score_threshold: Use it to return documents only with a score above this threshold.
|
||||
If set, overrides the value set at initialization.
|
||||
"""
|
||||
ranked = documents[:top_k] if top_k is not None else documents
|
||||
return {"documents": ranked}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pipeline():
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("bm25_retriever", InMemoryBM25Retriever(document_store=InMemoryDocumentStore()))
|
||||
pipeline.add_component("ranker", MockSimilarityRanker())
|
||||
pipeline.connect("bm25_retriever", "ranker")
|
||||
return pipeline
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pipeline_dict():
|
||||
return {
|
||||
"metadata": {},
|
||||
"max_runs_per_component": 100,
|
||||
"components": {
|
||||
"bm25_retriever": {
|
||||
"type": "haystack.components.retrievers.in_memory.bm25_retriever.InMemoryBM25Retriever",
|
||||
"init_parameters": {
|
||||
"document_store": {
|
||||
"type": "haystack.document_stores.in_memory.document_store.InMemoryDocumentStore",
|
||||
"init_parameters": {
|
||||
"bm25_tokenization_regex": "(?u)\\b\\w+\\b",
|
||||
"bm25_algorithm": "BM25L",
|
||||
"bm25_parameters": {},
|
||||
"embedding_similarity_function": "dot_product",
|
||||
"index": ANY,
|
||||
"shared": True,
|
||||
"return_embedding": True,
|
||||
},
|
||||
},
|
||||
"filters": None,
|
||||
"top_k": 10,
|
||||
"scale_score": False,
|
||||
"filter_policy": "replace",
|
||||
},
|
||||
},
|
||||
"ranker": {"type": "test_pipeline_tool.MockSimilarityRanker", "init_parameters": {}},
|
||||
},
|
||||
"connections": [{"sender": "bm25_retriever.documents", "receiver": "ranker.documents"}],
|
||||
"connection_type_validation": True,
|
||||
}
|
||||
|
||||
|
||||
class TestPipelineTool:
|
||||
def test_init_invalid_pipeline(self):
|
||||
with pytest.raises(TypeError, match="The 'pipeline' parameter must be an instance of Pipeline."):
|
||||
PipelineTool(pipeline="invalid_pipeline", name="test_tool", description="A test tool") # type: ignore[arg-type]
|
||||
|
||||
def test_to_dict(self, sample_pipeline, sample_pipeline_dict):
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
)
|
||||
|
||||
tool_dict = tool.to_dict()
|
||||
assert tool_dict == {
|
||||
"type": "haystack.tools.pipeline_tool.PipelineTool",
|
||||
"data": {
|
||||
"pipeline": sample_pipeline_dict,
|
||||
"name": "test_tool",
|
||||
"input_mapping": {"query": ["bm25_retriever.query"]},
|
||||
"output_mapping": {"ranker.documents": "documents"},
|
||||
"description": "A test tool",
|
||||
"parameters": None,
|
||||
"inputs_from_state": None,
|
||||
"outputs_to_state": None,
|
||||
"outputs_to_string": None,
|
||||
},
|
||||
}
|
||||
|
||||
def test_from_dict(self, sample_pipeline):
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
)
|
||||
|
||||
tool_dict = tool.to_dict()
|
||||
recreated_tool = PipelineTool.from_dict(tool_dict)
|
||||
|
||||
assert tool.name == recreated_tool.name
|
||||
assert tool.description == recreated_tool.description
|
||||
assert tool._input_mapping == recreated_tool._input_mapping
|
||||
assert tool._output_mapping == recreated_tool._output_mapping
|
||||
assert tool.parameters == recreated_tool.parameters
|
||||
assert isinstance(recreated_tool._pipeline, Pipeline)
|
||||
|
||||
def test_from_dict_ignores_legacy_is_pipeline_async(self, sample_pipeline):
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
)
|
||||
|
||||
tool_dict = tool.to_dict()
|
||||
tool_dict["data"]["is_pipeline_async"] = True
|
||||
|
||||
recreated_tool = PipelineTool.from_dict(tool_dict)
|
||||
assert isinstance(recreated_tool._pipeline, Pipeline)
|
||||
|
||||
def test_auto_generated_tool_params(self, sample_pipeline):
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query", "ranker.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
)
|
||||
|
||||
assert tool.parameters == {
|
||||
"properties": {
|
||||
"query": {
|
||||
"description": "Provided to the 'bm25_retriever' component as: 'The query string for the Retriever."
|
||||
"', and Provided to the 'ranker' component as: 'The input query to compare the "
|
||||
"documents to.'.",
|
||||
"type": "string",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
def test_auto_generated_tool_params_no_mappings(self, sample_pipeline):
|
||||
tool = PipelineTool(pipeline=sample_pipeline, name="test_tool", description="A test tool")
|
||||
assert tool.parameters == {
|
||||
"properties": {
|
||||
"query": {
|
||||
"description": "Provided to the 'bm25_retriever' component as: 'The query string for the "
|
||||
"Retriever.', and Provided to the 'ranker' component as: 'The input query to "
|
||||
"compare the documents to.'.",
|
||||
"type": "string",
|
||||
},
|
||||
"filters": {
|
||||
"anyOf": [{"additionalProperties": True, "type": "object"}, {"type": "null"}],
|
||||
"description": "Provided to the 'bm25_retriever' component as: 'A dictionary with filters to "
|
||||
"narrow down the search space when retrieving documents.'.",
|
||||
},
|
||||
"top_k": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Provided to the 'bm25_retriever' component as: 'The maximum number of documents "
|
||||
"to return.', and Provided to the 'ranker' component as: 'The maximum number "
|
||||
"of documents to return.'.",
|
||||
},
|
||||
"scale_score": {
|
||||
"description": "Provided to the 'bm25_retriever' component as: 'When `True`, scales the score "
|
||||
"of retrieved documents to a range of 0 to 1, where 1 means extremely relevant."
|
||||
"\nWhen `False`, uses raw similarity scores.', and Provided to the 'ranker' "
|
||||
"component as: 'If `True`, scales the raw logit predictions using a Sigmoid "
|
||||
"activation function.\nIf `False`, disables scaling of the raw logit predictions."
|
||||
"\nIf set, overrides the value set at initialization.'.",
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
},
|
||||
"score_threshold": {
|
||||
"anyOf": [{"type": "number"}, {"type": "null"}],
|
||||
"description": "Provided to the 'ranker' component as: 'Use it to return documents only with "
|
||||
"a score above this threshold.\nIf set, overrides the value set at initialization.'"
|
||||
".",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"type": "object",
|
||||
}
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
def test_live_pipeline_tool(self, in_memory_doc_store):
|
||||
# Initialize a document store and add some documents
|
||||
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"]
|
||||
in_memory_doc_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=in_memory_doc_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="This tool retrieves documents relevant to Nikola Tesla from the document store",
|
||||
)
|
||||
|
||||
# Create an Agent with the tool
|
||||
agent = Agent(
|
||||
chat_generator=OpenAIChatGenerator(model="gpt-4.1-mini"),
|
||||
system_prompt="For any questions about Nikola Tesla, always use the document_retriever.",
|
||||
tools=[retriever_tool],
|
||||
)
|
||||
|
||||
# Let the Agent handle a query
|
||||
result = agent.run([ChatMessage.from_user("Who was Nikola Tesla?")])
|
||||
|
||||
assert len(result["messages"]) == 5 # System msg, User msg, Agent msg, Tool call result, Agent mgs
|
||||
assert "nikola" in result["messages"][-1].text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
async def test_live_async_pipeline_tool(self, in_memory_doc_store):
|
||||
# Initialize a document store and add some documents
|
||||
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"]
|
||||
in_memory_doc_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=in_memory_doc_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"),
|
||||
system_prompt="For any questions about Nikola Tesla, always use the document_retriever.",
|
||||
tools=[retriever_tool],
|
||||
)
|
||||
|
||||
# Let the Agent handle a query
|
||||
result = await agent.run_async([ChatMessage.from_user("Who was Nikola Tesla?")])
|
||||
|
||||
assert len(result["messages"]) == 5 # System msg, User msg, Agent msg, Tool call result, Agent mgs
|
||||
assert "nikola" in result["messages"][-1].text.lower()
|
||||
|
||||
def test_pipeline_tool_with_valid_inputs_from_state(self, sample_pipeline):
|
||||
"""Test that PipelineTool accepts valid inputs_from_state mapping"""
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
inputs_from_state={"user_query": "query"},
|
||||
)
|
||||
assert tool.inputs_from_state == {"user_query": "query"}
|
||||
|
||||
def test_pipeline_tool_with_invalid_inputs_from_state(self, sample_pipeline):
|
||||
"""Test that PipelineTool validates inputs_from_state against pipeline inputs"""
|
||||
with pytest.raises(ValueError, match="unknown parameter 'nonexistent'"):
|
||||
PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
inputs_from_state={"user_query": "nonexistent"},
|
||||
)
|
||||
|
||||
def test_pipeline_tool_with_invalid_inputs_from_state_nested_dict(self, sample_pipeline):
|
||||
"""Test that PipelineTool rejects nested dict format for inputs_from_state"""
|
||||
with pytest.raises(TypeError, match="must be str, not dict"):
|
||||
PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
inputs_from_state={"user_query": {"source": "query"}}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
def test_pipeline_tool_with_valid_outputs_to_state(self, sample_pipeline):
|
||||
"""Test that PipelineTool accepts valid outputs_to_state mapping"""
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
outputs_to_state={"result_docs": {"source": "documents"}},
|
||||
)
|
||||
assert tool.outputs_to_state == {"result_docs": {"source": "documents"}}
|
||||
|
||||
def test_pipeline_tool_with_invalid_outputs_to_state(self, sample_pipeline):
|
||||
"""Test that PipelineTool validates outputs_to_state against pipeline outputs"""
|
||||
with pytest.raises(ValueError, match="unknown output"):
|
||||
PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
outputs_to_state={"result": {"source": "nonexistent"}},
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineToolAsync:
|
||||
def test_async_function_is_always_set(self, sample_pipeline):
|
||||
tool = PipelineTool(
|
||||
pipeline=sample_pipeline,
|
||||
input_mapping={"query": ["bm25_retriever.query"]},
|
||||
output_mapping={"ranker.documents": "documents"},
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
)
|
||||
|
||||
assert tool.function is not None
|
||||
assert tool.async_function is not None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.tools import Tool, Toolset, deserialize_tools_or_toolset_inplace, serialize_tools_or_toolset
|
||||
|
||||
|
||||
def get_weather_report(city: str) -> str:
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
def calculate(a: int, b: int, operation: str) -> int:
|
||||
if operation == "add":
|
||||
return a + b
|
||||
if operation == "multiply":
|
||||
return a * b
|
||||
return 0
|
||||
|
||||
|
||||
def translate_text(text: str, target_language: str) -> str:
|
||||
return f"Translated '{text}' to {target_language}"
|
||||
|
||||
|
||||
def summarize_text(text: str, max_length: int) -> str:
|
||||
return text[:max_length]
|
||||
|
||||
|
||||
def format_text(text: str, style: str) -> str:
|
||||
return f"Formatted text in {style} style: {text}"
|
||||
|
||||
|
||||
weather_parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
|
||||
calculator_parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "integer"},
|
||||
"b": {"type": "integer"},
|
||||
"operation": {"type": "string", "enum": ["add", "multiply"]},
|
||||
},
|
||||
"required": ["a", "b", "operation"],
|
||||
}
|
||||
|
||||
translator_parameters = {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}, "target_language": {"type": "string"}},
|
||||
"required": ["text", "target_language"],
|
||||
}
|
||||
|
||||
summarizer_parameters = {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}, "max_length": {"type": "integer"}},
|
||||
"required": ["text", "max_length"],
|
||||
}
|
||||
|
||||
formatter_parameters = {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}, "style": {"type": "string"}},
|
||||
"required": ["text", "style"],
|
||||
}
|
||||
|
||||
# Legacy name for backward compatibility with existing tests
|
||||
parameters = weather_parameters
|
||||
|
||||
|
||||
class TestToolSerdeUtils:
|
||||
def test_serialize_toolset(self):
|
||||
toolset = Toolset(
|
||||
tools=[
|
||||
Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
data = serialize_tools_or_toolset(toolset)
|
||||
assert data == toolset.to_dict()
|
||||
|
||||
def test_serialize_tool(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
data = serialize_tools_or_toolset([tool])
|
||||
assert data == [tool.to_dict()]
|
||||
|
||||
def test_deserialize_tools_inplace(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
data: dict[str, Any] = {"tools": [tool.to_dict()]}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
assert data["tools"] == [tool]
|
||||
|
||||
data = {"mytools": [tool.to_dict()]}
|
||||
deserialize_tools_or_toolset_inplace(data, key="mytools")
|
||||
assert data["mytools"] == [tool]
|
||||
|
||||
data = {"no_tools": 123}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
assert data == {"no_tools": 123}
|
||||
|
||||
def test_deserialize_tools_inplace_failures(self):
|
||||
data: dict[str, Any] = {"key": "value"}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
assert data == {"key": "value"}
|
||||
|
||||
data = {"tools": None}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
assert data == {"tools": None}
|
||||
|
||||
data = {"tools": "not a list"}
|
||||
with pytest.raises(TypeError):
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
data = {"tools": ["not a dictionary"]}
|
||||
with pytest.raises(TypeError):
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
# not a subclass of Tool
|
||||
data = {"tools": [{"type": "haystack.dataclasses.ChatMessage", "data": {"irrelevant": "irrelevant"}}]}
|
||||
with pytest.raises(TypeError):
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
def test_deserialize_toolset_inplace(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
toolset = Toolset(tools=[tool])
|
||||
|
||||
data = {"tools": toolset.to_dict()}
|
||||
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
assert data["tools"] == toolset
|
||||
assert isinstance(data["tools"], Toolset)
|
||||
assert data["tools"][0] == tool
|
||||
|
||||
def test_deserialize_toolset_inplace_failures(self):
|
||||
data = {"tools": {"key": "value"}}
|
||||
with pytest.raises(TypeError):
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
data = {"tools": {"type": "haystack.tools.Tool", "data": "some_data"}}
|
||||
with pytest.raises(TypeError):
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
def test_serialize_list_of_toolsets(self):
|
||||
"""Test serialization of a list of Toolset instances."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
toolset1 = Toolset([tool1])
|
||||
toolset2 = Toolset([tool2])
|
||||
|
||||
data = serialize_tools_or_toolset([toolset1, toolset2])
|
||||
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
assert data[0] == toolset1.to_dict()
|
||||
assert data[1] == toolset2.to_dict()
|
||||
assert data[0]["type"] == "haystack.tools.toolset.Toolset"
|
||||
assert data[1]["type"] == "haystack.tools.toolset.Toolset"
|
||||
|
||||
def test_deserialize_list_of_toolsets_inplace(self):
|
||||
"""Test deserialization of a list of Toolset instances."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
toolset1 = Toolset([tool1])
|
||||
toolset2 = Toolset([tool2])
|
||||
|
||||
data = {"tools": [toolset1.to_dict(), toolset2.to_dict()]}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
assert isinstance(data["tools"], list)
|
||||
assert len(data["tools"]) == 2
|
||||
assert isinstance(data["tools"][0], Toolset)
|
||||
assert isinstance(data["tools"][1], Toolset)
|
||||
assert data["tools"][0][0].name == "weather"
|
||||
assert data["tools"][1][0].name == "calculator"
|
||||
|
||||
def test_serialize_mixed_list_tools_and_toolsets(self):
|
||||
"""Test serialization of a mixed list of Tool and Toolset instances."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=weather_parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=calculator_parameters, function=calculate
|
||||
)
|
||||
|
||||
toolset = Toolset([tool2])
|
||||
|
||||
tools: list[Tool | Toolset] = [tool1, toolset]
|
||||
data = serialize_tools_or_toolset(tools)
|
||||
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
assert data[0] == tool1.to_dict()
|
||||
assert data[0]["type"] == "haystack.tools.tool.Tool"
|
||||
assert data[0]["data"]["parameters"] == weather_parameters
|
||||
assert data[1] == toolset.to_dict()
|
||||
assert data[1]["type"] == "haystack.tools.toolset.Toolset"
|
||||
assert data[1]["data"]["tools"][0]["data"]["parameters"] == calculator_parameters
|
||||
|
||||
def test_serialize_mixed_list_multiple_tools_and_toolsets(self):
|
||||
"""Test serialization of a mixed list with multiple Tools and a Toolset containing multiple tools."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=weather_parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=calculator_parameters, function=calculate
|
||||
)
|
||||
tool3 = Tool(
|
||||
name="translator", description="Translate text", parameters=translator_parameters, function=translate_text
|
||||
)
|
||||
tool4 = Tool(
|
||||
name="summarizer", description="Summarize text", parameters=summarizer_parameters, function=summarize_text
|
||||
)
|
||||
tool5 = Tool(name="formatter", description="Format text", parameters=formatter_parameters, function=format_text)
|
||||
|
||||
toolset = Toolset([tool4, tool5])
|
||||
|
||||
tools: list[Tool | Toolset] = [tool1, tool2, toolset, tool3]
|
||||
data = serialize_tools_or_toolset(tools)
|
||||
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 4
|
||||
|
||||
# Verify Tool 1 (weather)
|
||||
assert data[0] == tool1.to_dict()
|
||||
assert data[0]["type"] == "haystack.tools.tool.Tool"
|
||||
assert data[0]["data"]["name"] == "weather"
|
||||
assert data[0]["data"]["parameters"] == weather_parameters
|
||||
|
||||
# Verify Tool 2 (calculator)
|
||||
assert data[1] == tool2.to_dict()
|
||||
assert data[1]["type"] == "haystack.tools.tool.Tool"
|
||||
assert data[1]["data"]["name"] == "calculator"
|
||||
assert data[1]["data"]["parameters"] == calculator_parameters
|
||||
|
||||
# Verify Toolset (with summarizer and formatter)
|
||||
assert data[2] == toolset.to_dict()
|
||||
assert data[2]["type"] == "haystack.tools.toolset.Toolset"
|
||||
assert len(data[2]["data"]["tools"]) == 2
|
||||
assert data[2]["data"]["tools"][0]["data"]["name"] == "summarizer"
|
||||
assert data[2]["data"]["tools"][0]["data"]["parameters"] == summarizer_parameters
|
||||
assert data[2]["data"]["tools"][1]["data"]["name"] == "formatter"
|
||||
assert data[2]["data"]["tools"][1]["data"]["parameters"] == formatter_parameters
|
||||
|
||||
# Verify Tool 3 (translator)
|
||||
assert data[3] == tool3.to_dict()
|
||||
assert data[3]["type"] == "haystack.tools.tool.Tool"
|
||||
assert data[3]["data"]["name"] == "translator"
|
||||
assert data[3]["data"]["parameters"] == translator_parameters
|
||||
|
||||
def test_deserialize_mixed_list_tools_and_toolsets_inplace(self):
|
||||
"""Test deserialization of a mixed list of Tool and Toolset instances."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=weather_parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=calculator_parameters, function=calculate
|
||||
)
|
||||
|
||||
toolset = Toolset([tool2])
|
||||
|
||||
data = {"tools": [tool1.to_dict(), toolset.to_dict()]}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
assert isinstance(data["tools"], list)
|
||||
assert len(data["tools"]) == 2
|
||||
|
||||
# Verify Tool (weather)
|
||||
assert isinstance(data["tools"][0], Tool)
|
||||
assert data["tools"][0].name == "weather"
|
||||
assert data["tools"][0].parameters == weather_parameters
|
||||
assert data["tools"][0].function("Paris") == "Weather report for Paris: 20°C, sunny" # type: ignore[misc]
|
||||
|
||||
# Verify Toolset with calculator tool
|
||||
assert isinstance(data["tools"][1], Toolset)
|
||||
assert len(data["tools"][1]) == 1
|
||||
assert data["tools"][1][0].name == "calculator"
|
||||
assert data["tools"][1][0].parameters == calculator_parameters
|
||||
assert data["tools"][1][0].function(10, 5, "add") == 15
|
||||
assert data["tools"][1][0].function(10, 5, "multiply") == 50
|
||||
|
||||
def test_deserialize_mixed_list_multiple_tools_and_toolsets_inplace(self):
|
||||
"""Test deserialization of a mixed list with multiple Tools and a Toolset containing multiple tools."""
|
||||
tool1 = Tool(
|
||||
name="weather", description="Get weather report", parameters=weather_parameters, function=get_weather_report
|
||||
)
|
||||
tool2 = Tool(
|
||||
name="calculator", description="Calculate numbers", parameters=calculator_parameters, function=calculate
|
||||
)
|
||||
tool3 = Tool(
|
||||
name="translator", description="Translate text", parameters=translator_parameters, function=translate_text
|
||||
)
|
||||
tool4 = Tool(
|
||||
name="summarizer", description="Summarize text", parameters=summarizer_parameters, function=summarize_text
|
||||
)
|
||||
tool5 = Tool(name="formatter", description="Format text", parameters=formatter_parameters, function=format_text)
|
||||
|
||||
toolset = Toolset([tool4, tool5])
|
||||
|
||||
data = {"tools": [tool1.to_dict(), tool2.to_dict(), toolset.to_dict(), tool3.to_dict()]}
|
||||
deserialize_tools_or_toolset_inplace(data)
|
||||
|
||||
assert isinstance(data["tools"], list)
|
||||
assert len(data["tools"]) == 4
|
||||
|
||||
# Verify Tool 1 (weather)
|
||||
assert isinstance(data["tools"][0], Tool)
|
||||
assert data["tools"][0].name == "weather"
|
||||
assert data["tools"][0].parameters == weather_parameters
|
||||
assert data["tools"][0].function("Berlin") == "Weather report for Berlin: 20°C, sunny" # type: ignore[misc]
|
||||
|
||||
# Verify Tool 2 (calculator)
|
||||
assert isinstance(data["tools"][1], Tool)
|
||||
assert data["tools"][1].name == "calculator"
|
||||
assert data["tools"][1].parameters == calculator_parameters
|
||||
assert data["tools"][1].function(5, 3, "add") == 8 # type: ignore[misc]
|
||||
assert data["tools"][1].function(5, 3, "multiply") == 15 # type: ignore[misc]
|
||||
|
||||
# Verify Toolset (with summarizer and formatter)
|
||||
assert isinstance(data["tools"][2], Toolset)
|
||||
assert len(data["tools"][2]) == 2
|
||||
assert data["tools"][2][0].name == "summarizer"
|
||||
assert data["tools"][2][0].parameters == summarizer_parameters
|
||||
assert data["tools"][2][0].function("Hello World", 5) == "Hello"
|
||||
assert data["tools"][2][1].name == "formatter"
|
||||
assert data["tools"][2][1].parameters == formatter_parameters
|
||||
assert data["tools"][2][1].function("test", "bold") == "Formatted text in bold style: test"
|
||||
|
||||
# Verify Tool 3 (translator)
|
||||
assert isinstance(data["tools"][3], Tool)
|
||||
assert data["tools"][3].name == "translator"
|
||||
assert data["tools"][3].parameters == translator_parameters
|
||||
assert data["tools"][3].function("Hello", "Spanish") == "Translated 'Hello' to Spanish"
|
||||
|
||||
def test_serialize_none_returns_none(self):
|
||||
"""Test that serializing None returns None."""
|
||||
data = serialize_tools_or_toolset(None)
|
||||
assert data is None
|
||||
|
||||
def test_serialize_empty_list_of_toolsets(self):
|
||||
"""Test that serializing an empty list of Toolsets returns an empty list."""
|
||||
data = serialize_tools_or_toolset([])
|
||||
assert data == []
|
||||
@@ -0,0 +1,474 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.dataclasses import TextContent
|
||||
from haystack.tools import Tool, _check_duplicate_tool_names
|
||||
from haystack.tools.errors import ToolInvocationError
|
||||
from haystack.tools.tool import (
|
||||
_deserialize_outputs_to_state,
|
||||
_deserialize_outputs_to_string,
|
||||
_serialize_outputs_to_state,
|
||||
_serialize_outputs_to_string,
|
||||
)
|
||||
|
||||
|
||||
def get_weather_report(city: str) -> str:
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
def format_string(text: str) -> str:
|
||||
return f"Formatted: {text}"
|
||||
|
||||
|
||||
def outputs_to_result_handler(result):
|
||||
return [TextContent(text=result["text"])]
|
||||
|
||||
|
||||
parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
||||
|
||||
|
||||
async def async_get_weather(city: str) -> str:
|
||||
return f"Weather report for {city}: 20°C, sunny"
|
||||
|
||||
|
||||
class TestTool:
|
||||
def test_init(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
assert tool.name == "weather"
|
||||
assert tool.description == "Get weather report"
|
||||
assert tool.parameters == parameters
|
||||
assert tool.function == get_weather_report
|
||||
assert tool.inputs_from_state is None
|
||||
assert tool.outputs_to_state is None
|
||||
|
||||
def test_init_invalid_parameters(self):
|
||||
params = {"type": "invalid", "properties": {"city": {"type": "string"}}}
|
||||
with pytest.raises(ValueError):
|
||||
Tool(name="irrelevant", description="irrelevant", parameters=params, function=get_weather_report)
|
||||
|
||||
def test_init_async_function_passed_as_function_raises_error(self):
|
||||
with pytest.raises(ValueError, match="`function` must be a synchronous function"):
|
||||
Tool(name="weather", description="Get weather report", parameters=parameters, function=async_get_weather)
|
||||
|
||||
def test_init_requires_function_or_async_function(self):
|
||||
with pytest.raises(ValueError, match="requires at least one of `function` or `async_function`"):
|
||||
Tool(name="weather", description="Get weather report", parameters=parameters)
|
||||
|
||||
def test_init_sync_function_passed_as_async_function_raises_error(self):
|
||||
with pytest.raises(ValueError, match="`async_function` must be a coroutine function"):
|
||||
Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
async_function=get_weather_report,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outputs_to_state",
|
||||
[
|
||||
pytest.param({"documents": {"source": get_weather_report}}, id="source-not-a-string"),
|
||||
pytest.param({"documents": {"handler": "some_string", "source": "docs"}}, id="handler-not-callable"),
|
||||
],
|
||||
)
|
||||
def test_init_invalid_output_structure(self, outputs_to_state):
|
||||
with pytest.raises(ValueError):
|
||||
Tool(
|
||||
name="irrelevant",
|
||||
description="irrelevant",
|
||||
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
function=get_weather_report,
|
||||
outputs_to_state=outputs_to_state,
|
||||
)
|
||||
|
||||
def test_init_invalid_output_structure_config_not_dict(self):
|
||||
with pytest.raises(TypeError):
|
||||
Tool(
|
||||
name="irrelevant",
|
||||
description="irrelevant",
|
||||
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
function=get_weather_report,
|
||||
outputs_to_state={"documents": ["some_value"]}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outputs_to_string",
|
||||
[
|
||||
pytest.param({"source": get_weather_report}, id="source-not-a-string"),
|
||||
pytest.param({"handler": "some_string"}, id="handler-not-callable"),
|
||||
pytest.param({"raw_result": "not-a-bool"}, id="raw_result-not-a-bool"),
|
||||
pytest.param({"documents": {"source": get_weather_report}}, id="multi-value-source-not-a-string"),
|
||||
pytest.param({"documents": {"handler": "some_string"}}, id="multi-value-handler-not-callable"),
|
||||
pytest.param(
|
||||
{"documents": {"source": "docs", "raw_result": True}}, id="multi-value-raw_result-not-supported"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_init_invalid_outputs_to_string_structure(self, outputs_to_string):
|
||||
with pytest.raises(ValueError):
|
||||
Tool(
|
||||
name="irrelevant",
|
||||
description="irrelevant",
|
||||
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
function=get_weather_report,
|
||||
outputs_to_string=outputs_to_string,
|
||||
)
|
||||
|
||||
def test_init_invalid_outputs_to_string_structure_config_not_dict(self):
|
||||
with pytest.raises(TypeError):
|
||||
Tool(
|
||||
name="irrelevant",
|
||||
description="irrelevant",
|
||||
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
function=get_weather_report,
|
||||
outputs_to_string={"documents": ["some_value"]},
|
||||
)
|
||||
|
||||
def test_tool_spec(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
assert tool.tool_spec == {"name": "weather", "description": "Get weather report", "parameters": parameters}
|
||||
|
||||
def test_invoke(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
|
||||
assert tool.invoke(city="Berlin") == "Weather report for Berlin: 20°C, sunny"
|
||||
|
||||
def test_invoke_fail(self):
|
||||
tool = Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, function=get_weather_report
|
||||
)
|
||||
with pytest.raises(
|
||||
ToolInvocationError,
|
||||
match=re.escape(
|
||||
"Failed to invoke Tool `weather` with parameters {}. Error: get_weather_report() missing 1 required "
|
||||
"positional argument: 'city'"
|
||||
),
|
||||
):
|
||||
tool.invoke()
|
||||
|
||||
def test_to_dict(self):
|
||||
tool = Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
outputs_to_string={"handler": format_string},
|
||||
inputs_from_state={"location": "city"},
|
||||
outputs_to_state={"documents": {"handler": get_weather_report, "source": "docs"}},
|
||||
)
|
||||
|
||||
assert tool.to_dict() == {
|
||||
"type": "haystack.tools.tool.Tool",
|
||||
"data": {
|
||||
"name": "weather",
|
||||
"description": "Get weather report",
|
||||
"parameters": parameters,
|
||||
"function": "test_tool.get_weather_report",
|
||||
"async_function": None,
|
||||
"outputs_to_string": {"handler": "test_tool.format_string"},
|
||||
"inputs_from_state": {"location": "city"},
|
||||
"outputs_to_state": {"documents": {"source": "docs", "handler": "test_tool.get_weather_report"}},
|
||||
},
|
||||
}
|
||||
|
||||
def test_from_dict(self):
|
||||
tool_dict = {
|
||||
"type": "haystack.tools.tool.Tool",
|
||||
"data": {
|
||||
"name": "weather",
|
||||
"description": "Get weather report",
|
||||
"parameters": parameters,
|
||||
"function": "test_tool.get_weather_report",
|
||||
"outputs_to_string": {"handler": "test_tool.format_string"},
|
||||
"inputs_from_state": {"location": "city"},
|
||||
"outputs_to_state": {"documents": {"source": "docs", "handler": "test_tool.get_weather_report"}},
|
||||
},
|
||||
}
|
||||
|
||||
tool = Tool.from_dict(tool_dict)
|
||||
|
||||
assert tool.name == "weather"
|
||||
assert tool.description == "Get weather report"
|
||||
assert tool.parameters == parameters
|
||||
assert tool.function == get_weather_report
|
||||
assert tool.outputs_to_string == {"handler": format_string}
|
||||
assert tool.inputs_from_state == {"location": "city"}
|
||||
assert tool.outputs_to_state == {"documents": {"source": "docs", "handler": get_weather_report}}
|
||||
|
||||
def test_serialize_outputs_to_string(self):
|
||||
config = {"handler": format_string, "source": "result", "raw_result": False}
|
||||
serialized = _serialize_outputs_to_string(config)
|
||||
assert serialized == {"handler": "test_tool.format_string", "source": "result", "raw_result": False}
|
||||
|
||||
config = {"handler": format_string}
|
||||
serialized = _serialize_outputs_to_string(config)
|
||||
assert serialized == {"handler": "test_tool.format_string"}
|
||||
|
||||
config = {"handler": outputs_to_result_handler, "raw_result": True}
|
||||
serialized = _serialize_outputs_to_string(config)
|
||||
assert serialized == {"handler": "test_tool.outputs_to_result_handler", "raw_result": True}
|
||||
|
||||
config = {
|
||||
"report": {"source": "report", "handler": format_string},
|
||||
"temp": {"source": "temperature", "handler": format_string},
|
||||
}
|
||||
serialized = _serialize_outputs_to_string(config)
|
||||
assert serialized == {
|
||||
"report": {"source": "report", "handler": "test_tool.format_string"},
|
||||
"temp": {"source": "temperature", "handler": "test_tool.format_string"},
|
||||
}
|
||||
|
||||
def test_deserialize_outputs_to_string(self):
|
||||
serialized = {"handler": "test_tool.format_string", "source": "result", "raw_result": False}
|
||||
deserialized = _deserialize_outputs_to_string(serialized)
|
||||
assert deserialized == {"handler": format_string, "source": "result", "raw_result": False}
|
||||
|
||||
serialized = {"handler": "test_tool.format_string"}
|
||||
deserialized = _deserialize_outputs_to_string(serialized)
|
||||
assert deserialized == {"handler": format_string}
|
||||
|
||||
serialized = {"handler": "test_tool.outputs_to_result_handler", "raw_result": True}
|
||||
deserialized = _deserialize_outputs_to_string(serialized)
|
||||
assert deserialized == {"handler": outputs_to_result_handler, "raw_result": True}
|
||||
|
||||
serialized = {
|
||||
"report": {"source": "report", "handler": "test_tool.format_string"},
|
||||
"temp": {"source": "temperature", "handler": "test_tool.format_string"},
|
||||
}
|
||||
deserialized = _deserialize_outputs_to_string(serialized)
|
||||
assert deserialized == {
|
||||
"report": {"source": "report", "handler": format_string},
|
||||
"temp": {"source": "temperature", "handler": format_string},
|
||||
}
|
||||
|
||||
def test_serialize_outputs_to_state(self):
|
||||
config: dict[str, dict[str, Any]] = {
|
||||
"documents": {"source": "docs", "handler": format_string},
|
||||
"summary": {"source": "docs", "handler": get_weather_report},
|
||||
"raw_docs": {"source": "docs"},
|
||||
}
|
||||
serialized = _serialize_outputs_to_state(config)
|
||||
assert serialized == {
|
||||
"documents": {"source": "docs", "handler": "test_tool.format_string"},
|
||||
"summary": {"source": "docs", "handler": "test_tool.get_weather_report"},
|
||||
"raw_docs": {"source": "docs"},
|
||||
}
|
||||
|
||||
def test_deserialize_outputs_to_state(self):
|
||||
serialized = {
|
||||
"documents": {"source": "docs", "handler": "test_tool.format_string"},
|
||||
"summary": {"source": "docs", "handler": "test_tool.get_weather_report"},
|
||||
"raw_docs": {"source": "docs"},
|
||||
}
|
||||
deserialized = _deserialize_outputs_to_state(serialized)
|
||||
assert deserialized == {
|
||||
"documents": {"source": "docs", "handler": format_string},
|
||||
"summary": {"source": "docs", "handler": get_weather_report},
|
||||
"raw_docs": {"source": "docs"},
|
||||
}
|
||||
|
||||
def test_inputs_from_state_validation_with_invalid_parameter(self):
|
||||
"""Test that inputs_from_state is validated against the parameters schema"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"inputs_from_state maps 'state_key' to unknown parameter 'nonexistent'. Valid parameters are: {'city'}."
|
||||
),
|
||||
):
|
||||
Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
inputs_from_state={"state_key": "nonexistent"},
|
||||
)
|
||||
|
||||
def test_inputs_from_state_validation_with_non_string_value(self):
|
||||
"""Test that inputs_from_state values must be strings"""
|
||||
with pytest.raises(TypeError, match=re.escape("inputs_from_state values must be str, not dict")):
|
||||
Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
inputs_from_state={"state_key": {"source": "city"}}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
def test_inputs_from_state_validation_with_valid_parameter(self):
|
||||
"""Test that inputs_from_state works with valid parameter names"""
|
||||
tool = Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
inputs_from_state={"location": "city"},
|
||||
)
|
||||
assert tool.inputs_from_state == {"location": "city"}
|
||||
|
||||
def test_outputs_to_state_no_validation_when_get_valid_outputs_returns_none(self):
|
||||
"""Test that outputs_to_state is not validated when _get_valid_outputs returns None"""
|
||||
# This should not raise an error even though "nonexistent" is not a valid output
|
||||
# because the base Tool class returns None from _get_valid_outputs()
|
||||
tool = Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
outputs_to_state={"result": {"source": "nonexistent"}},
|
||||
)
|
||||
assert tool.outputs_to_state == {"result": {"source": "nonexistent"}}
|
||||
|
||||
def test_outputs_to_state_validation_when_subclass_provides_valid_outputs(self):
|
||||
"""Test that outputs_to_state is validated when subclass overrides _get_valid_outputs"""
|
||||
|
||||
class ToolWithOutputs(Tool):
|
||||
def _get_valid_outputs(self):
|
||||
return {"report", "temperature"}
|
||||
|
||||
# Valid output should work
|
||||
tool = ToolWithOutputs(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
outputs_to_state={"result": {"source": "report"}},
|
||||
)
|
||||
assert tool.outputs_to_state == {"result": {"source": "report"}}
|
||||
|
||||
# Invalid output should raise an error
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("outputs_to_state: 'weather' maps state key 'result' to unknown output 'nonexistent'"),
|
||||
):
|
||||
ToolWithOutputs(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=get_weather_report,
|
||||
outputs_to_state={"result": {"source": "nonexistent"}},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_tool():
|
||||
return Tool(
|
||||
name="weather", description="Get weather report", parameters=parameters, async_function=async_get_weather
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_tool():
|
||||
return Tool(name="weather", description="Get weather report", parameters=parameters, function=get_weather_report)
|
||||
|
||||
|
||||
class TestToolAsync:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async_awaits_async_function(self, async_tool):
|
||||
assert async_tool.function is None
|
||||
assert async_tool.async_function is async_get_weather
|
||||
assert await async_tool.invoke_async(city="Berlin") == "Weather report for Berlin: 20°C, sunny"
|
||||
|
||||
def test_invoke_on_async_only_tool_raises(self, async_tool):
|
||||
with pytest.raises(ToolInvocationError, match=re.escape("has no sync `function`")):
|
||||
async_tool.invoke(city="Berlin")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async_falls_back_to_sync_function(self, sync_tool):
|
||||
# Sync-only tool: invoke_async dispatches to a worker thread via asyncio.to_thread.
|
||||
assert await sync_tool.invoke_async(city="Berlin") == "Weather report for Berlin: 20°C, sunny"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function_is_preferred_when_both_set(self):
|
||||
async def from_async(city: str) -> str:
|
||||
return "async"
|
||||
|
||||
def from_sync(city: str) -> str:
|
||||
return "sync"
|
||||
|
||||
tool = Tool(
|
||||
name="weather",
|
||||
description="Get weather report",
|
||||
parameters=parameters,
|
||||
function=from_sync,
|
||||
async_function=from_async,
|
||||
)
|
||||
|
||||
assert await tool.invoke_async(city="Berlin") == "async"
|
||||
assert tool.invoke(city="Berlin") == "sync"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_async_wraps_exception(self):
|
||||
async def boom(city: str) -> str:
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
tool = Tool(name="weather", description="Get weather report", parameters=parameters, async_function=boom)
|
||||
|
||||
with pytest.raises(ToolInvocationError, match="kaboom"):
|
||||
await tool.invoke_async(city="Berlin")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, expected_function, expected_async_function",
|
||||
[
|
||||
pytest.param({"async_function": async_get_weather}, None, "test_tool.async_get_weather", id="async-only"),
|
||||
pytest.param(
|
||||
{"function": get_weather_report, "async_function": async_get_weather},
|
||||
"test_tool.get_weather_report",
|
||||
"test_tool.async_get_weather",
|
||||
id="both",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_serde_roundtrip(self, kwargs, expected_function, expected_async_function):
|
||||
tool = Tool(name="weather", description="Get weather report", parameters=parameters, **kwargs)
|
||||
|
||||
data = tool.to_dict()
|
||||
assert data["data"]["function"] == expected_function
|
||||
assert data["data"]["async_function"] == expected_async_function
|
||||
|
||||
restored = Tool.from_dict(data)
|
||||
assert restored.function == kwargs.get("function")
|
||||
assert restored.async_function == kwargs.get("async_function")
|
||||
|
||||
def test_from_dict_legacy_payload_without_async_function_key(self):
|
||||
# Payload produced before `async_function` existed.
|
||||
legacy = {
|
||||
"type": "haystack.tools.tool.Tool",
|
||||
"data": {
|
||||
"name": "weather",
|
||||
"description": "Get weather report",
|
||||
"parameters": parameters,
|
||||
"function": "test_tool.get_weather_report",
|
||||
},
|
||||
}
|
||||
|
||||
tool = Tool.from_dict(legacy)
|
||||
assert tool.function is get_weather_report
|
||||
assert tool.async_function is None
|
||||
|
||||
|
||||
def test_check_duplicate_tool_names():
|
||||
tools = [
|
||||
Tool(name="weather", description="Get weather report", parameters=parameters, function=get_weather_report),
|
||||
Tool(name="weather", description="A different description", parameters=parameters, function=get_weather_report),
|
||||
]
|
||||
with pytest.raises(ValueError):
|
||||
_check_duplicate_tool_names(tools)
|
||||
|
||||
tools = [
|
||||
Tool(name="weather", description="Get weather report", parameters=parameters, function=get_weather_report),
|
||||
Tool(name="weather2", description="Get weather report", parameters=parameters, function=get_weather_report),
|
||||
]
|
||||
_check_duplicate_tool_names(tools)
|
||||
@@ -0,0 +1,396 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.tools import Tool, Toolset, flatten_tools_or_toolsets, warm_up_tools
|
||||
|
||||
|
||||
def add_numbers(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
|
||||
def multiply_numbers(a: int, b: int) -> int:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
|
||||
def subtract_numbers(a: int, b: int) -> int:
|
||||
"""Subtract b from a."""
|
||||
return a - b
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_tool():
|
||||
return Tool(
|
||||
name="add",
|
||||
description="Add two numbers",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
function=add_numbers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiply_tool():
|
||||
return Tool(
|
||||
name="multiply",
|
||||
description="Multiply two numbers",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
function=multiply_numbers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subtract_tool():
|
||||
return Tool(
|
||||
name="subtract",
|
||||
description="Subtract two numbers",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
function=subtract_numbers,
|
||||
)
|
||||
|
||||
|
||||
class TestFlattenToolsOrToolsets:
|
||||
def test_flatten_none(self):
|
||||
"""Test that None returns an empty list."""
|
||||
result = flatten_tools_or_toolsets(None)
|
||||
assert result == []
|
||||
|
||||
def test_flatten_empty_list(self):
|
||||
"""Test that an empty list returns an empty list."""
|
||||
result = flatten_tools_or_toolsets([])
|
||||
assert result == []
|
||||
|
||||
def test_flatten_list_of_tools(self, add_tool, multiply_tool):
|
||||
"""Test that a list of Tool instances is returned as-is."""
|
||||
tools = [add_tool, multiply_tool]
|
||||
result = flatten_tools_or_toolsets(tools)
|
||||
assert result == tools
|
||||
assert len(result) == 2
|
||||
assert result[0].name == "add"
|
||||
assert result[1].name == "multiply"
|
||||
|
||||
def test_flatten_single_toolset(self, add_tool, multiply_tool):
|
||||
"""Test that a single Toolset is converted to a list of Tools."""
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
result = flatten_tools_or_toolsets(toolset)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(t, Tool) for t in result)
|
||||
assert result[0].name == "add"
|
||||
assert result[1].name == "multiply"
|
||||
|
||||
def test_flatten_list_of_toolsets(self, add_tool, multiply_tool, subtract_tool):
|
||||
"""Test that a list of Toolset instances is flattened to a single list of Tools."""
|
||||
toolset1 = Toolset([add_tool])
|
||||
toolset2 = Toolset([multiply_tool, subtract_tool])
|
||||
|
||||
result = flatten_tools_or_toolsets([toolset1, toolset2])
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 3
|
||||
assert all(isinstance(t, Tool) for t in result)
|
||||
assert result[0].name == "add"
|
||||
assert result[1].name == "multiply"
|
||||
assert result[2].name == "subtract"
|
||||
|
||||
def test_flatten_list_with_mixed_tools_and_toolsets(self, add_tool, multiply_tool, subtract_tool):
|
||||
"""Test that a mixed list of Tool and Toolset instances is flattened correctly."""
|
||||
toolset = Toolset([multiply_tool])
|
||||
mixed_list = [add_tool, toolset, subtract_tool]
|
||||
|
||||
result = flatten_tools_or_toolsets(mixed_list)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 3
|
||||
assert all(isinstance(t, Tool) for t in result)
|
||||
assert result[0].name == "add"
|
||||
assert result[1].name == "multiply"
|
||||
assert result[2].name == "subtract"
|
||||
|
||||
def test_flatten_empty_toolset(self):
|
||||
"""Test that an empty Toolset returns an empty list."""
|
||||
toolset = Toolset([])
|
||||
result = flatten_tools_or_toolsets(toolset)
|
||||
assert result == []
|
||||
|
||||
def test_flatten_list_with_empty_toolsets(self, add_tool):
|
||||
"""Test that a list with empty Toolsets handles correctly."""
|
||||
toolset1 = Toolset([])
|
||||
toolset2 = Toolset([add_tool])
|
||||
toolset3 = Toolset([])
|
||||
|
||||
result = flatten_tools_or_toolsets([toolset1, toolset2, toolset3])
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "add"
|
||||
|
||||
def test_flatten_invalid_type_in_list(self):
|
||||
"""Test that invalid types in the list raise TypeError."""
|
||||
with pytest.raises(TypeError, match="Items in the tools list must be Tool or Toolset instances"):
|
||||
flatten_tools_or_toolsets(["not_a_tool"]) # type: ignore[list-item]
|
||||
|
||||
with pytest.raises(TypeError, match="Items in the tools list must be Tool or Toolset instances"):
|
||||
flatten_tools_or_toolsets([123]) # type: ignore[list-item]
|
||||
|
||||
with pytest.raises(TypeError, match="Items in the tools list must be Tool or Toolset instances"):
|
||||
flatten_tools_or_toolsets([{"key": "value"}]) # type: ignore[list-item]
|
||||
|
||||
def test_flatten_invalid_type(self):
|
||||
"""Test that invalid root types raise TypeError."""
|
||||
with pytest.raises(TypeError, match="tools must be list\\[Union\\[Tool, Toolset\\]\\], Toolset, or None"):
|
||||
flatten_tools_or_toolsets("not_valid") # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(TypeError, match="tools must be list\\[Union\\[Tool, Toolset\\]\\], Toolset, or None"):
|
||||
flatten_tools_or_toolsets(123) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(TypeError, match="tools must be list\\[Union\\[Tool, Toolset\\]\\], Toolset, or None"):
|
||||
flatten_tools_or_toolsets({"key": "value"}) # type: ignore[arg-type]
|
||||
|
||||
def test_flatten_multiple_toolsets(self, add_tool, multiply_tool, subtract_tool):
|
||||
"""Test flattening a list of multiple Toolsets."""
|
||||
toolset1 = Toolset([add_tool])
|
||||
toolset2 = Toolset([multiply_tool])
|
||||
toolset3 = Toolset([subtract_tool])
|
||||
|
||||
# List of three separate toolsets
|
||||
result = flatten_tools_or_toolsets([toolset1, toolset2, toolset3])
|
||||
assert len(result) == 3
|
||||
assert result[0].name == "add"
|
||||
assert result[1].name == "multiply"
|
||||
assert result[2].name == "subtract"
|
||||
|
||||
|
||||
class WarmupTrackingTool(Tool):
|
||||
"""A tool that tracks whether warm_up was called."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.was_warmed_up = False
|
||||
|
||||
def warm_up(self):
|
||||
self.was_warmed_up = True
|
||||
|
||||
|
||||
class WarmupTrackingToolset(Toolset):
|
||||
"""A toolset that tracks whether warm_up was called."""
|
||||
|
||||
def __init__(self, tools):
|
||||
super().__init__(tools)
|
||||
self.was_warmed_up = False
|
||||
|
||||
def warm_up(self):
|
||||
self.was_warmed_up = True
|
||||
# Call parent to warm up individual tools
|
||||
super().warm_up()
|
||||
|
||||
|
||||
class TestWarmUpTools:
|
||||
"""Tests for the warm_up_tools() function"""
|
||||
|
||||
def test_warm_up_tools_with_none(self):
|
||||
"""Test that warm_up_tools with None does nothing."""
|
||||
# Should not raise any errors
|
||||
warm_up_tools(None)
|
||||
|
||||
def test_warm_up_tools_with_single_tool(self):
|
||||
"""Test that warm_up_tools works with a single tool in a list."""
|
||||
tool = WarmupTrackingTool(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "test",
|
||||
)
|
||||
|
||||
assert not tool.was_warmed_up
|
||||
warm_up_tools([tool])
|
||||
assert tool.was_warmed_up
|
||||
|
||||
def test_warm_up_tools_with_single_toolset(self):
|
||||
"""
|
||||
Test that when passing a single Toolset, both the Toolset.warm_up()
|
||||
and each individual tool's warm_up() are called.
|
||||
"""
|
||||
tool1 = WarmupTrackingTool(
|
||||
name="tool1",
|
||||
description="First tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool1",
|
||||
)
|
||||
tool2 = WarmupTrackingTool(
|
||||
name="tool2",
|
||||
description="Second tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool2",
|
||||
)
|
||||
|
||||
toolset = WarmupTrackingToolset([tool1, tool2])
|
||||
|
||||
assert not toolset.was_warmed_up
|
||||
assert not tool1.was_warmed_up
|
||||
assert not tool2.was_warmed_up
|
||||
|
||||
warm_up_tools(toolset)
|
||||
|
||||
# Both the toolset itself and individual tools should be warmed up
|
||||
assert toolset.was_warmed_up
|
||||
assert tool1.was_warmed_up
|
||||
assert tool2.was_warmed_up
|
||||
|
||||
def test_warm_up_tools_with_list_containing_toolset(self):
|
||||
"""Test that when a Toolset is in a list, individual tools inside get warmed up."""
|
||||
tool1 = WarmupTrackingTool(
|
||||
name="tool1",
|
||||
description="First tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool1",
|
||||
)
|
||||
tool2 = WarmupTrackingTool(
|
||||
name="tool2",
|
||||
description="Second tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool2",
|
||||
)
|
||||
|
||||
toolset = WarmupTrackingToolset([tool1, tool2])
|
||||
|
||||
assert not toolset.was_warmed_up
|
||||
assert not tool1.was_warmed_up
|
||||
assert not tool2.was_warmed_up
|
||||
|
||||
warm_up_tools([toolset])
|
||||
|
||||
# Both the toolset itself and individual tools should be warmed up
|
||||
assert toolset.was_warmed_up
|
||||
assert tool1.was_warmed_up
|
||||
assert tool2.was_warmed_up
|
||||
|
||||
def test_warm_up_tools_with_multiple_toolsets(self):
|
||||
"""Test multiple Toolsets in a list."""
|
||||
tool1 = WarmupTrackingTool(
|
||||
name="tool1",
|
||||
description="First tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool1",
|
||||
)
|
||||
tool2 = WarmupTrackingTool(
|
||||
name="tool2",
|
||||
description="Second tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool2",
|
||||
)
|
||||
tool3 = WarmupTrackingTool(
|
||||
name="tool3",
|
||||
description="Third tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "tool3",
|
||||
)
|
||||
|
||||
toolset1 = WarmupTrackingToolset([tool1])
|
||||
toolset2 = WarmupTrackingToolset([tool2, tool3])
|
||||
|
||||
assert not toolset1.was_warmed_up
|
||||
assert not toolset2.was_warmed_up
|
||||
assert not tool1.was_warmed_up
|
||||
assert not tool2.was_warmed_up
|
||||
assert not tool3.was_warmed_up
|
||||
|
||||
warm_up_tools([toolset1, toolset2])
|
||||
|
||||
# Both toolsets and all individual tools should be warmed up
|
||||
assert toolset1.was_warmed_up
|
||||
assert toolset2.was_warmed_up
|
||||
assert tool1.was_warmed_up
|
||||
assert tool2.was_warmed_up
|
||||
assert tool3.was_warmed_up
|
||||
|
||||
def test_warm_up_tools_with_mixed_tools_and_toolsets(self):
|
||||
"""Test list with both Tool objects and Toolsets."""
|
||||
standalone_tool = WarmupTrackingTool(
|
||||
name="standalone",
|
||||
description="Standalone tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "standalone",
|
||||
)
|
||||
toolset_tool1 = WarmupTrackingTool(
|
||||
name="toolset_tool1",
|
||||
description="Tool in toolset",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "toolset_tool1",
|
||||
)
|
||||
toolset_tool2 = WarmupTrackingTool(
|
||||
name="toolset_tool2",
|
||||
description="Another tool in toolset",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "toolset_tool2",
|
||||
)
|
||||
|
||||
toolset = WarmupTrackingToolset([toolset_tool1, toolset_tool2])
|
||||
|
||||
assert not standalone_tool.was_warmed_up
|
||||
assert not toolset.was_warmed_up
|
||||
assert not toolset_tool1.was_warmed_up
|
||||
assert not toolset_tool2.was_warmed_up
|
||||
|
||||
warm_up_tools([standalone_tool, toolset])
|
||||
|
||||
# All tools and the toolset should be warmed up
|
||||
assert standalone_tool.was_warmed_up
|
||||
assert toolset.was_warmed_up
|
||||
assert toolset_tool1.was_warmed_up
|
||||
assert toolset_tool2.was_warmed_up
|
||||
|
||||
def test_warm_up_tools_idempotency(self):
|
||||
"""Test that calling warm_up_tools() multiple times is safe."""
|
||||
|
||||
class WarmupCountingTool(Tool):
|
||||
"""A tool that counts how many times warm_up was called."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.warm_up_count = 0
|
||||
|
||||
def warm_up(self):
|
||||
self.warm_up_count += 1
|
||||
|
||||
class WarmupCountingToolset(Toolset):
|
||||
"""A toolset that counts how many times warm_up did real work."""
|
||||
|
||||
def __init__(self, tools):
|
||||
super().__init__(tools)
|
||||
self.warm_up_count = 0
|
||||
|
||||
def warm_up(self):
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
self.warm_up_count += 1
|
||||
super().warm_up() # Also warm up individual tools
|
||||
|
||||
tool = WarmupCountingTool(
|
||||
name="counting_tool",
|
||||
description="A counting tool",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
function=lambda: "test",
|
||||
)
|
||||
toolset = WarmupCountingToolset([tool])
|
||||
|
||||
# Call warm_up_tools multiple times
|
||||
warm_up_tools(toolset)
|
||||
warm_up_tools(toolset)
|
||||
warm_up_tools(toolset)
|
||||
|
||||
# warm_up is idempotent, so the toolset and its tools are only warmed up once
|
||||
assert toolset.warm_up_count == 1
|
||||
assert tool.warm_up_count == 1
|
||||
@@ -0,0 +1,545 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Pipeline, component
|
||||
from haystack.components.agents import Agent
|
||||
from haystack.components.agents.state import State
|
||||
from haystack.components.agents.tool_calling import _run_tool
|
||||
from haystack.components.generators.chat import OpenAIChatGenerator
|
||||
from haystack.core.serialization import generate_qualified_class_name
|
||||
from haystack.dataclasses import ChatMessage
|
||||
from haystack.dataclasses.chat_message import ToolCall
|
||||
from haystack.tools import Tool, Toolset, tool
|
||||
from haystack.tools.errors import ToolInvocationError
|
||||
|
||||
|
||||
def _run_tool_messages(messages: list[ChatMessage], tools: Toolset | list[Tool | Toolset]) -> list[ChatMessage]:
|
||||
tool_messages, _ = _run_tool(messages=messages, state=State(schema={}), tools=tools)
|
||||
return tool_messages
|
||||
|
||||
|
||||
class DynamicToolset(Toolset):
|
||||
"""A custom Toolset that recreates its tools dynamically on deserialization instead of serializing them."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__([add])
|
||||
|
||||
def to_dict(self):
|
||||
return {"type": generate_qualified_class_name(type(self)), "data": {}}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
return cls()
|
||||
|
||||
|
||||
@tool
|
||||
def weather(location: Annotated[str, "the location to get the weather for"]) -> dict:
|
||||
"""Provides weather information for a given location."""
|
||||
weather_info = {
|
||||
"Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
|
||||
"Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
|
||||
"Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
|
||||
}
|
||||
return weather_info.get(location, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def weather_tool():
|
||||
return weather
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def faulty_tool():
|
||||
def faulty_tool_func(location):
|
||||
raise Exception("This tool always fails.")
|
||||
|
||||
faulty_tool_parameters = {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
}
|
||||
|
||||
return Tool(
|
||||
name="faulty_tool",
|
||||
description="A tool that always fails when invoked.",
|
||||
parameters=faulty_tool_parameters,
|
||||
function=faulty_tool_func,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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 TestToolset:
|
||||
def test_toolset_with_multiple_tools(self, add_tool, multiply_tool):
|
||||
"""Test that a Toolset with multiple tools works properly."""
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
|
||||
assert len(toolset) == 2
|
||||
assert toolset[0].name == "add"
|
||||
assert toolset[1].name == "multiply"
|
||||
|
||||
add_message = ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="add", arguments={"a": 2, "b": 3})])
|
||||
multiply_message = ChatMessage.from_assistant(
|
||||
tool_calls=[ToolCall(tool_name="multiply", arguments={"a": 4, "b": 5})]
|
||||
)
|
||||
tool_messages = _run_tool_messages(messages=[add_message, multiply_message], tools=toolset)
|
||||
|
||||
assert len(tool_messages) == 2
|
||||
tool_results = [tcr.result for message in tool_messages for tcr in message.tool_call_results]
|
||||
assert "5" in tool_results
|
||||
assert "20" in tool_results
|
||||
|
||||
def test_toolset_add(self, add_tool):
|
||||
"""Test that tools can be added to a Toolset."""
|
||||
toolset = Toolset()
|
||||
assert len(toolset) == 0
|
||||
|
||||
toolset.add(add_tool)
|
||||
assert len(toolset) == 1
|
||||
assert toolset[0].name == "add"
|
||||
|
||||
message = ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="add", arguments={"a": 2, "b": 3})])
|
||||
tool_messages = _run_tool_messages(messages=[message], tools=toolset)
|
||||
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].tool_call_results[0].result == "5"
|
||||
|
||||
def test_toolset_contains(self, add_tool, multiply_tool):
|
||||
"""Test that the __contains__ method works correctly."""
|
||||
toolset = Toolset([add_tool])
|
||||
# Test with a tool instance
|
||||
assert add_tool in toolset
|
||||
assert multiply_tool not in toolset
|
||||
# Test with a tool name
|
||||
assert "add" in toolset
|
||||
assert "multiply" not in toolset
|
||||
assert "non_existent_tool" not in toolset
|
||||
|
||||
def test_toolset_addition(self, add_tool, multiply_tool, subtract_tool):
|
||||
"""Test that the __add__ method combines toolsets with various operand types."""
|
||||
base = Toolset([add_tool])
|
||||
|
||||
# Toolset + Tool
|
||||
result = base + multiply_tool
|
||||
assert isinstance(result, Toolset)
|
||||
assert [t.name for t in result] == ["add", "multiply"]
|
||||
|
||||
# Toolset + Toolset
|
||||
result = base + Toolset([subtract_tool])
|
||||
assert isinstance(result, Toolset)
|
||||
assert [t.name for t in result] == ["add", "subtract"]
|
||||
|
||||
# Toolset + list[Tool]
|
||||
result = base + [multiply_tool, subtract_tool]
|
||||
assert isinstance(result, Toolset)
|
||||
assert [t.name for t in result] == ["add", "multiply", "subtract"]
|
||||
|
||||
# Unsupported operand types raise TypeError
|
||||
with pytest.raises(TypeError):
|
||||
base + "not_a_tool" # type: ignore[operator]
|
||||
with pytest.raises(TypeError):
|
||||
base + 123 # type: ignore[operator]
|
||||
|
||||
# The combined tools remain invocable
|
||||
message = ChatMessage.from_assistant(
|
||||
tool_calls=[
|
||||
ToolCall(tool_name="add", arguments={"a": 10, "b": 5}),
|
||||
ToolCall(tool_name="multiply", arguments={"a": 10, "b": 5}),
|
||||
ToolCall(tool_name="subtract", arguments={"a": 10, "b": 5}),
|
||||
]
|
||||
)
|
||||
tool_messages = _run_tool_messages(messages=[message], tools=result)
|
||||
tool_results = [tcr.result for message in tool_messages for tcr in message.tool_call_results]
|
||||
assert tool_results == ["15", "50", "5"]
|
||||
|
||||
def test_toolset_serialization(self, add_tool):
|
||||
"""Test that a Toolset can be serialized and deserialized."""
|
||||
serialized = Toolset([add_tool]).to_dict()
|
||||
deserialized = Toolset.from_dict(serialized)
|
||||
|
||||
assert len(deserialized) == 1
|
||||
assert deserialized[0].name == "add"
|
||||
assert deserialized[0].description == "Add two numbers."
|
||||
|
||||
tool_call = ToolCall(tool_name="add", arguments={"a": 2, "b": 3})
|
||||
message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
||||
tool_messages = _run_tool_messages(messages=[message], tools=deserialized)
|
||||
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].tool_call_results[0].result == "5"
|
||||
|
||||
def test_toolset_duplicate_tool_names(self, add_tool):
|
||||
"""Test that a Toolset raises an error for duplicate tool names."""
|
||||
with pytest.raises(ValueError, match="Duplicate tool names found"):
|
||||
Toolset([add_tool, add_tool])
|
||||
|
||||
toolset = Toolset([add_tool])
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate tool names found"):
|
||||
toolset.add(add_tool)
|
||||
|
||||
toolset2 = Toolset([add_tool])
|
||||
with pytest.raises(ValueError, match="Duplicate tool names found"):
|
||||
_ = toolset + toolset2
|
||||
|
||||
|
||||
class TestToolsetWithAgent:
|
||||
def test_init_with_toolset(self, weather_tool, monkeypatch):
|
||||
"""Test initializing Agent with a Toolset."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
toolset = Toolset(tools=[weather_tool])
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
|
||||
assert agent.tools == toolset
|
||||
|
||||
def test_tool_invocation_error_with_toolset(self, faulty_tool):
|
||||
"""Test tool invocation errors with a Toolset."""
|
||||
toolset = Toolset(tools=[faulty_tool])
|
||||
tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
|
||||
tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
|
||||
with pytest.raises(ToolInvocationError):
|
||||
_run_tool(messages=[tool_call_message], state=State(schema={}), tools=toolset)
|
||||
|
||||
def test_custom_toolset_serde_in_agent(self, monkeypatch):
|
||||
"""Test serialization and deserialization of a custom toolset within an Agent."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=DynamicToolset())
|
||||
agent_dict = agent.to_dict()
|
||||
tools_dict = agent_dict["init_parameters"]["tools"]
|
||||
assert tools_dict["type"] == "test_toolset.DynamicToolset"
|
||||
assert len(tools_dict["data"]) == 0
|
||||
new_agent = Agent.from_dict(agent_dict)
|
||||
assert isinstance(new_agent.tools, DynamicToolset)
|
||||
|
||||
def test_serde_with_toolset(self, add_tool, multiply_tool, monkeypatch):
|
||||
"""Test serialization and deserialization of regular Toolsets within an Agent."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=toolset)
|
||||
agent_dict = agent.to_dict()
|
||||
tools_dict = agent_dict["init_parameters"]["tools"]
|
||||
assert tools_dict["type"] == "haystack.tools.toolset.Toolset"
|
||||
assert len(tools_dict["data"]["tools"]) == 2
|
||||
tool_names = [tool["data"]["name"] for tool in tools_dict["data"]["tools"]]
|
||||
assert "add" in tool_names
|
||||
assert "multiply" in tool_names
|
||||
new_agent = Agent.from_dict(agent_dict)
|
||||
assert isinstance(new_agent.tools, Toolset)
|
||||
assert [tool.name for tool in new_agent.tools] == ["add", "multiply"]
|
||||
|
||||
def test_agent_serde_with_list_of_toolsets(self, weather_tool, add_tool, monkeypatch):
|
||||
"""Test serialization and deserialization of Agent with a list of Toolsets."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[Toolset([weather_tool]), Toolset([add_tool])])
|
||||
data = agent.to_dict()
|
||||
|
||||
# Verify serialization preserves list[Toolset] structure
|
||||
tools_data = data["init_parameters"]["tools"]
|
||||
assert isinstance(tools_data, list)
|
||||
assert len(tools_data) == 2
|
||||
assert all(isinstance(ts, dict) for ts in tools_data)
|
||||
assert tools_data[0]["type"] == "haystack.tools.toolset.Toolset"
|
||||
assert tools_data[1]["type"] == "haystack.tools.toolset.Toolset"
|
||||
|
||||
# Deserialize and verify
|
||||
deserialized_agent = Agent.from_dict(data)
|
||||
assert isinstance(deserialized_agent.tools, list)
|
||||
assert len(deserialized_agent.tools) == 2
|
||||
assert all(isinstance(ts, Toolset) for ts in deserialized_agent.tools)
|
||||
|
||||
def test_list_of_toolsets_runtime_override(self, weather_tool, add_tool, multiply_tool):
|
||||
"""Test that list of Toolsets can be passed as runtime override to Agent.run()."""
|
||||
toolset2 = Toolset([add_tool])
|
||||
toolset3 = Toolset([multiply_tool])
|
||||
|
||||
@component
|
||||
class AddCallingChatGenerator:
|
||||
tool_invoked = False
|
||||
|
||||
@component.output_types(replies=list[ChatMessage])
|
||||
def run(
|
||||
self, messages: list[ChatMessage], tools: list[Tool | Toolset] | None = None, **kwargs: Any
|
||||
) -> dict[str, list[ChatMessage]]:
|
||||
# The Agent flattens toolsets before passing them to the chat generator.
|
||||
assert tools == [add_tool, multiply_tool]
|
||||
if self.tool_invoked:
|
||||
return {"replies": [ChatMessage.from_assistant("done")]}
|
||||
self.tool_invoked = True
|
||||
return {
|
||||
"replies": [
|
||||
ChatMessage.from_assistant(tool_calls=[ToolCall(tool_name="add", arguments={"a": 3, "b": 7})])
|
||||
]
|
||||
}
|
||||
|
||||
agent = Agent(chat_generator=AddCallingChatGenerator(), tools=Toolset([weather_tool]))
|
||||
result = agent.run(messages=[ChatMessage.from_user("Add numbers")], tools=[toolset2, toolset3])
|
||||
assert result["messages"][2].tool_call_result.result == "10"
|
||||
|
||||
def test_pipeline_with_list_of_toolsets(self, add_tool, multiply_tool, monkeypatch):
|
||||
"""Test that a Pipeline can serialize/deserialize an Agent with a list of Toolsets."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test")
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component(
|
||||
"agent", Agent(chat_generator=OpenAIChatGenerator(), tools=[Toolset([add_tool]), Toolset([multiply_tool])])
|
||||
)
|
||||
pipeline_dict = pipeline.to_dict()
|
||||
|
||||
# Verify the serialized structure
|
||||
agent_dict = pipeline_dict["components"]["agent"]
|
||||
tools_data = agent_dict["init_parameters"]["tools"]
|
||||
assert isinstance(tools_data, list)
|
||||
assert len(tools_data) == 2
|
||||
assert all(ts["type"] == "haystack.tools.toolset.Toolset" for ts in tools_data)
|
||||
|
||||
# Deserialize and verify functionality
|
||||
new_pipeline = Pipeline.from_dict(pipeline_dict)
|
||||
assert new_pipeline.to_dict() == pipeline_dict
|
||||
|
||||
|
||||
class TestToolsetWarmUp:
|
||||
"""Stress tests for Toolset warm_up behavior."""
|
||||
|
||||
def test_new_toolset_is_not_warmed_up(self):
|
||||
toolset = Toolset([WarmUpCountingTool("a")])
|
||||
assert toolset._is_warmed_up is False
|
||||
|
||||
def test_warm_up_warms_all_tools(self):
|
||||
t1, t2 = WarmUpCountingTool("a"), WarmUpCountingTool("b")
|
||||
toolset = Toolset([t1, t2])
|
||||
assert t1.warm_up_count == 0
|
||||
assert t2.warm_up_count == 0
|
||||
toolset.warm_up()
|
||||
assert t1.warm_up_count == 1
|
||||
assert t2.warm_up_count == 1
|
||||
assert toolset._is_warmed_up is True
|
||||
|
||||
def test_warm_up_is_idempotent(self):
|
||||
t1 = WarmUpCountingTool("a")
|
||||
toolset = Toolset([t1])
|
||||
toolset.warm_up()
|
||||
toolset.warm_up()
|
||||
toolset.warm_up()
|
||||
assert t1.warm_up_count == 1
|
||||
|
||||
def test_add_before_warm_up_does_not_warm_tools(self):
|
||||
existing = WarmUpCountingTool("a")
|
||||
toolset = Toolset([existing])
|
||||
new_tool = WarmUpCountingTool("b")
|
||||
toolset.add(new_tool)
|
||||
# Nothing is warmed until warm_up() is called explicitly.
|
||||
assert existing.warm_up_count == 0
|
||||
assert new_tool.warm_up_count == 0
|
||||
toolset.warm_up()
|
||||
assert existing.warm_up_count == 1
|
||||
assert new_tool.warm_up_count == 1
|
||||
|
||||
def test_add_tool_after_warm_up_warms_only_new_tool(self):
|
||||
existing = WarmUpCountingTool("a")
|
||||
toolset = Toolset([existing])
|
||||
toolset.warm_up()
|
||||
assert existing.warm_up_count == 1
|
||||
new_tool = WarmUpCountingTool("b")
|
||||
toolset.add(new_tool)
|
||||
# The new tool is warmed immediately, the already-warmed tool is not re-warmed.
|
||||
assert new_tool.warm_up_count == 1
|
||||
assert existing.warm_up_count == 1
|
||||
|
||||
def test_add_toolset_after_warm_up_warms_added_toolset(self):
|
||||
toolset = Toolset([WarmUpCountingTool("a")])
|
||||
toolset.warm_up()
|
||||
added_tools = [WarmUpCountingTool("b"), WarmUpCountingTool("c")]
|
||||
added = WarmUpCountingToolset(added_tools)
|
||||
toolset.add(added)
|
||||
# The added toolset's own warm_up() is invoked, warming its tools.
|
||||
assert added.warm_up_count == 1
|
||||
assert all(tool.warm_up_count == 1 for tool in added_tools)
|
||||
|
||||
def test_plus_returns_new_unwarmed_toolset(self):
|
||||
ts1 = Toolset([WarmUpCountingTool("a")])
|
||||
ts1.warm_up()
|
||||
assert ts1._is_warmed_up is True
|
||||
new_tool = WarmUpCountingTool("b")
|
||||
ts2 = ts1 + new_tool
|
||||
# `+` returns a brand new Toolset object that has not been warmed up yet.
|
||||
assert ts2 is not ts1
|
||||
assert ts2._is_warmed_up is False
|
||||
assert new_tool.warm_up_count == 0
|
||||
ts2.warm_up()
|
||||
assert new_tool.warm_up_count == 1
|
||||
|
||||
|
||||
class TestToolsetToolSelection:
|
||||
"""Tests for get_selectable_tools(), the name filter, and spawn()."""
|
||||
|
||||
def test_no_filter_yields_all_tools(self, add_tool, multiply_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
assert toolset._selected_tool_names is None
|
||||
assert [tool.name for tool in toolset] == ["add", "multiply"]
|
||||
assert len(toolset) == 2
|
||||
|
||||
def test_get_selectable_tools_returns_all_tools(self, add_tool, multiply_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
assert toolset.get_selectable_tools() == [add_tool, multiply_tool]
|
||||
|
||||
def test_get_selectable_tools_ignores_active_filter(self, add_tool, multiply_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
toolset._selected_tool_names = {"add"}
|
||||
# Iteration is filtered, but get_selectable_tools still returns the full set.
|
||||
assert [tool.name for tool in toolset] == ["add"]
|
||||
assert {tool.name for tool in toolset.get_selectable_tools()} == {"add", "multiply"}
|
||||
|
||||
def test_get_selectable_tools_warms_up_lazy_toolset(self, add_tool, multiply_tool):
|
||||
"""get_selectable_tools() warms up a lazy toolset so its lazily loaded tools are available for selection."""
|
||||
|
||||
class LazyToolset(Toolset):
|
||||
def __init__(self):
|
||||
super().__init__([]) # no tools until warm_up
|
||||
|
||||
def warm_up(self):
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
self.tools = [add_tool, multiply_tool]
|
||||
self._is_warmed_up = True
|
||||
|
||||
toolset = LazyToolset()
|
||||
assert toolset._is_warmed_up is False
|
||||
assert toolset.tools == [] # not loaded yet
|
||||
|
||||
selectable = toolset.get_selectable_tools()
|
||||
|
||||
assert toolset._is_warmed_up is True # get_selectable_tools triggered warm_up
|
||||
assert [tool.name for tool in selectable] == ["add", "multiply"]
|
||||
|
||||
def test_filter_restricts_iteration(self, add_tool, multiply_tool, subtract_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool, subtract_tool])
|
||||
toolset._selected_tool_names = {"add", "subtract"}
|
||||
assert [tool.name for tool in toolset] == ["add", "subtract"]
|
||||
|
||||
def test_filter_restricts_len(self, add_tool, multiply_tool, subtract_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool, subtract_tool])
|
||||
toolset._selected_tool_names = {"add"}
|
||||
assert len(toolset) == 1
|
||||
|
||||
def test_filter_restricts_getitem(self, add_tool, multiply_tool, subtract_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool, subtract_tool])
|
||||
toolset._selected_tool_names = {"subtract"}
|
||||
assert toolset[0].name == "subtract"
|
||||
|
||||
def test_filter_restricts_contains(self, add_tool, multiply_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
toolset._selected_tool_names = {"add"}
|
||||
assert "add" in toolset
|
||||
assert "multiply" not in toolset
|
||||
assert add_tool in toolset
|
||||
assert multiply_tool not in toolset
|
||||
|
||||
def test_spawn_returns_isolated_copy(self, add_tool, multiply_tool):
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
|
||||
spawned = toolset.spawn()
|
||||
|
||||
assert spawned is not toolset
|
||||
assert spawned._selected_tool_names is None
|
||||
# The copy shares the same (read-only) tools.
|
||||
assert list(spawned.tools) == list(toolset.tools)
|
||||
|
||||
def test_spawn_selection_does_not_leak_to_original(self, add_tool, multiply_tool):
|
||||
"""A per-run selection set on a spawn must not affect the configured toolset or other spawns."""
|
||||
toolset = Toolset([add_tool, multiply_tool])
|
||||
|
||||
spawn_a = toolset.spawn()
|
||||
spawn_b = toolset.spawn()
|
||||
spawn_a._selected_tool_names = {"add"}
|
||||
|
||||
# Each run sees only its own selection; the configured toolset stays unfiltered.
|
||||
assert [tool.name for tool in spawn_a] == ["add"]
|
||||
assert [tool.name for tool in spawn_b] == ["add", "multiply"]
|
||||
assert [tool.name for tool in toolset] == ["add", "multiply"]
|
||||
assert toolset._selected_tool_names is None
|
||||
|
||||
def test_spawn_warms_up_lazy_toolset(self, add_tool, multiply_tool):
|
||||
"""spawn() warms up a lazy toolset so the copy shares the warmed state."""
|
||||
|
||||
class LazyToolset(Toolset):
|
||||
def __init__(self):
|
||||
super().__init__([])
|
||||
|
||||
def warm_up(self):
|
||||
if self._is_warmed_up:
|
||||
return
|
||||
self.tools = [add_tool, multiply_tool]
|
||||
self._is_warmed_up = True
|
||||
|
||||
toolset = LazyToolset()
|
||||
assert toolset._is_warmed_up is False
|
||||
|
||||
spawned = toolset.spawn()
|
||||
|
||||
assert toolset._is_warmed_up is True # spawn triggered warm_up
|
||||
assert spawned._is_warmed_up is True
|
||||
assert [tool.name for tool in spawned] == ["add", "multiply"]
|
||||
@@ -0,0 +1,232 @@
|
||||
# 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"}
|
||||
Reference in New Issue
Block a user