Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions haystack/components/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,12 @@ def __init__( # noqa: PLR0913
"""
# --- Validation ---
self._chat_generator_supports_tools: bool = "tools" in inspect.signature(chat_generator.run).parameters
# We use an explicit None check for tools b/c testing for truthiness calls __len__, which for SearchableToolset
# would iterate and prematurely warm it up at init.
if tools is not None and not self._chat_generator_supports_tools:
# An empty list carries no tools, so it must not trip this check: `tools` is normalized to `[]` below, and
# both `clone()` and `to_dict()` feed that normalized value straight back into `__init__`. This mirrors the
# equivalent check in `run()`. Only a list is measured; a Toolset is never tested for truthiness here b/c
# that calls __len__, which for SearchableToolset would iterate and prematurely warm it up at init.
tools_provided = tools is not None and (not isinstance(tools, list) or len(tools) > 0)
if tools_provided and not self._chat_generator_supports_tools:
raise TypeError(
f"{type(chat_generator).__name__} does not accept tools parameter in its run method. "
"The Agent component requires a chat generator that supports tools when tools are provided."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
fixes:
- |
Fix ``Agent.clone()`` and ``Agent.to_dict()``/``from_dict()`` raising ``TypeError`` for an
``Agent`` built on a chat generator that does not accept a ``tools`` parameter. ``tools`` is
normalized to an empty list at init, and both round-trip paths passed that empty list back to
the constructor, where it was treated as "tools were provided". An empty list now carries no
tools, matching the equivalent check already applied in ``Agent.run()``. Passing ``tools=[]``
explicitly to such an ``Agent`` is accepted for the same reason. A non-empty ``tools`` value
still raises, and a ``Toolset`` is still never tested for truthiness at init.
33 changes: 32 additions & 1 deletion test/components/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class MockChatGeneratorWithoutTools:
"""A mock chat generator that implements ChatGenerator protocol but doesn't support tools."""

def to_dict(self) -> dict[str, Any]:
return {"type": "MockChatGeneratorWithoutTools", "data": {}}
return {"type": "test.components.agents.test_agent.MockChatGeneratorWithoutTools", "init_parameters": {}}

@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MockChatGeneratorWithoutTools":
Expand Down Expand Up @@ -285,6 +285,13 @@ def test_chat_generator_must_support_tools(self, weather_tool):
with pytest.raises(TypeError, match="MockChatGeneratorWithoutTools does not accept tools"):
Agent(chat_generator=chat_generator, tools=[weather_tool])

def test_empty_tools_list_with_chat_generator_without_tools_support(self):
# An empty list carries no tools, so it must be accepted just like `tools=None`. `run()` already
# treats it that way, and `clone()`/`to_dict()` both feed the normalized `[]` back into `__init__`.
agent = Agent(chat_generator=MockChatGeneratorWithoutTools(), tools=[])

assert agent.tools == []


class TestAgentSerialization:
def test_to_dict(self, weather_tool, component_tool, monkeypatch):
Expand Down Expand Up @@ -512,8 +519,32 @@ def test_serde_with_list_of_toolsets(self, weather_tool, component_tool, monkeyp
assert all(isinstance(ts, Toolset) for ts in restored.tools)
assert restored.tools[0][0].function is weather_function

def test_to_dict_from_dict_without_tools(self):
# `to_dict` serializes the normalized `self.tools`, which is `[]` when no tools were given.
# `from_dict` hands that `[]` straight back to `__init__`, so the round trip must survive it.
agent = Agent(chat_generator=MockChatGeneratorWithoutTools(), max_agent_steps=3)

data = agent.to_dict()
assert data["init_parameters"]["tools"] == []

restored = Agent.from_dict(data)

assert restored.tools == []
assert restored.max_agent_steps == 3
assert type(restored.chat_generator).__name__ == "MockChatGeneratorWithoutTools"


class TestAgentClone:
def test_clone_without_tools(self):
# `clone()` reads back the normalized `self.tools` (`[]`) and passes it to `__init__`.
agent = Agent(chat_generator=MockChatGeneratorWithoutTools(), system_prompt="You are helpful")

clone = agent.clone()

assert clone is not agent
assert clone.tools == []
assert clone.to_dict() == agent.to_dict()

def test_clone(self, weather_tool):
agent = Agent(
chat_generator=MockChatGenerator("Hello"),
Expand Down