Skip to content

Commit e3aa021

Browse files
authored
feat: add AgentTool (#12186)
1 parent 26eb902 commit e3aa021

6 files changed

Lines changed: 604 additions & 11 deletions

File tree

haystack/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@
2929
"skills": ["SkillToolset"],
3030
"component_tool": ["ComponentTool"],
3131
"pipeline_tool": ["PipelineTool"],
32+
"agent_tool": ["AgentTool"],
3233
"serde_utils": ["deserialize_tools_or_toolset_inplace", "serialize_tools_or_toolset"],
3334
"utils": ["flatten_tools_or_toolsets", "warm_up_tools"],
3435
"tool_types": ["ToolsType"],
3536
}
3637

3738
if TYPE_CHECKING:
39+
from haystack.tools.agent_tool import AgentTool as AgentTool
3840
from haystack.tools.component_tool import ComponentTool as ComponentTool
3941
from haystack.tools.pipeline_tool import PipelineTool as PipelineTool
4042
from haystack.tools.searchable_toolset import SearchableToolset as SearchableToolset

haystack/tools/agent_tool.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import json
6+
from collections.abc import Callable
7+
from typing import Any
8+
9+
from haystack.components.agents import Agent
10+
from haystack.components.agents.agent import _EXIT_REASON_MAX_STEPS
11+
from haystack.tools.component_tool import ComponentTool
12+
from haystack.tools.tool import _deserialize_outputs_to_state, _deserialize_outputs_to_string
13+
from haystack.utils.deserialization import deserialize_component_inplace
14+
15+
16+
def _required_tool_parameters(agent: Agent, inputs_from_state: dict[str, Any] | None) -> list[str]:
17+
"""
18+
Return the additional required Tool parameters for the wrapped Agent.
19+
20+
These are mandatory Agent inputs that are not supplied through `inputs_from_state`.
21+
`messages` is excluded because AgentTool always adds it to the generated schema.
22+
23+
:param agent: The wrapped Agent.
24+
:param inputs_from_state: Maps the calling Agent's state keys to Agent inputs.
25+
:returns: Additional required Tool parameters, sorted by name.
26+
"""
27+
covered = {"messages", *(inputs_from_state or {}).values()}
28+
# prompt variables are registered as input sockets in a non-deterministic order, so sort to keep the schema stable
29+
return sorted(
30+
name
31+
# __haystack_input__ is attached to the instance by the @component decorator, so mypy cannot see it
32+
for name, socket in agent.__haystack_input__._sockets_dict.items() # type: ignore[attr-defined]
33+
if socket.is_mandatory and name not in covered
34+
)
35+
36+
37+
def agent_result_to_string(result: dict[str, Any]) -> str:
38+
"""Default `outputs_to_string` handler"""
39+
last_message = result["last_message"]
40+
text = last_message.text or json.dumps(last_message.to_dict())
41+
if result["exit_reason"] == _EXIT_REASON_MAX_STEPS:
42+
text += "\n\n[The Agent reached max_agent_steps and stopped, so this result may be incomplete.]"
43+
return text
44+
45+
46+
class AgentTool(ComponentTool):
47+
"""
48+
A Tool that wraps a Haystack Agent, allowing it to be used as a tool by another Agent.
49+
50+
AgentTool is a building block for multi-agent systems: an Agent specialized in one task becomes a tool that
51+
other Agents can delegate to. The calling Agent only sees the final reply, so all the steps the wrapped Agent
52+
takes stay out of its context. Sensible defaults make this work out of the box: the task is delegated as a
53+
single user message and comes back as text.
54+
55+
To use AgentTool, you first need a Haystack Agent. Below is an example of creating an AgentTool from an Agent
56+
that searches the web with a SerperDevWebSearch component from the `serperdev-haystack` integration package
57+
(`pip install serperdev-haystack`).
58+
59+
## Usage Example:
60+
<!-- test-ignore -->
61+
```python
62+
from haystack.components.agents import Agent
63+
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
64+
from haystack.dataclasses import ChatMessage
65+
from haystack.tools import AgentTool, ComponentTool
66+
from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
67+
68+
researcher = Agent(
69+
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-mini"),
70+
system_prompt="You are a research specialist. Investigate the task and report your findings.",
71+
tools=[
72+
ComponentTool(
73+
component=SerperDevWebSearch(
74+
top_k=3,
75+
),
76+
name="web_search",
77+
description="Search the web for current information on any topic",
78+
),
79+
],
80+
)
81+
82+
research = AgentTool(
83+
agent=researcher,
84+
name="research",
85+
description="Research a question on the web and report the findings",
86+
)
87+
88+
coordinator = Agent(
89+
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4"),
90+
tools=[research],
91+
system_prompt="You coordinate specialists. Delegate research questions, then answer the user.",
92+
)
93+
94+
result = coordinator.run([ChatMessage.from_user("What are the latest developments in the Haystack framework?")])
95+
print(result["last_message"].text)
96+
```
97+
"""
98+
99+
def __init__(
100+
self,
101+
agent: Agent,
102+
*,
103+
name: str,
104+
description: str,
105+
parameters: dict[str, Any] | None = None,
106+
outputs_to_string: dict[str, str | Callable[[Any], str]] | None = None,
107+
inputs_from_state: dict[str, str] | None = None,
108+
outputs_to_state: dict[str, dict[str, str | Callable]] | None = None,
109+
) -> None:
110+
"""
111+
Create a Tool instance from a Haystack Agent.
112+
113+
:param agent: The Haystack Agent to wrap as a tool.
114+
:param name: Name of the tool.
115+
:param description: Description of the tool. It should tell the calling LLM what the Agent is specialized in
116+
and when to delegate to it.
117+
:param parameters:
118+
A JSON schema defining the parameters expected by the Tool.
119+
Will fall back to a schema with the task to delegate as a single user message, plus one string parameter
120+
for every other mandatory input of the Agent, if not provided.
121+
:param outputs_to_string:
122+
Optional dictionary defining how tool outputs should be converted into string(s) or results.
123+
If not provided, the tool result is the text of the Agent's final reply, or the serialized message if
124+
the reply has no text. A warning is appended if the Agent stopped because it reached `max_agent_steps`.
125+
126+
`outputs_to_string` supports two formats:
127+
128+
1. Single output format - use "source", "handler", and/or "raw_result" at the root level:
129+
```python
130+
{
131+
"source": "last_message", "handler": format_reply, "raw_result": False
132+
}
133+
```
134+
- `source`: If provided, only the specified output key is sent to the handler.
135+
- `handler`: A function that takes the tool output (or the extracted source value) and returns the
136+
final result.
137+
- `raw_result`: If `True`, the result is returned raw without string conversion, but applying the
138+
`handler` if provided. This is intended for tools that return images. In this mode, the `handler`
139+
is required, since the Agent returns a dictionary, and it must return a list of
140+
`TextContent`/`ImageContent` objects to ensure compatibility with Chat Generators.
141+
142+
2. Multiple output format - map keys to individual configurations:
143+
```python
144+
{
145+
"reply": {"source": "last_message", "handler": format_reply},
146+
"steps": {"source": "step_count", "handler": str}
147+
}
148+
```
149+
Each key maps to a dictionary that can contain "source" and/or "handler".
150+
Note that `raw_result` is not supported in the multiple output format.
151+
:param inputs_from_state:
152+
Optional dictionary mapping the calling Agent's state keys to Agent input names.
153+
Example: `{"subject": "topic"}` maps state's "subject" to the Agent's "topic" input.
154+
Inputs mapped this way are not added to the generated `parameters` schema, since the calling Agent
155+
provides them.
156+
:param outputs_to_state:
157+
Optional dictionary defining how tool outputs map to keys within state as well as optional handlers.
158+
The keys must be declared in the `state_schema` of the calling Agent.
159+
Handlers merge the tool output into the state and are called as `handler(current_value, tool_output)`.
160+
If the source is provided only the specified output key is sent to the handler.
161+
Example:
162+
```python
163+
{
164+
"notes": {"source": "last_message", "handler": custom_handler}
165+
}
166+
```
167+
If the source is omitted the whole tool result is sent to the handler.
168+
Example:
169+
```python
170+
{
171+
"notes": {"handler": custom_handler}
172+
}
173+
```
174+
:raises TypeError: If the object passed is not a Haystack Agent instance.
175+
:raises ValueError: If `parameters` is provided but does not cover all the mandatory inputs of the Agent.
176+
"""
177+
if not isinstance(agent, Agent):
178+
raise TypeError(f"The 'agent' parameter must be an instance of Agent. Got {type(agent)} instead.")
179+
180+
if parameters is not None:
181+
required_tool_parameters = _required_tool_parameters(agent=agent, inputs_from_state=inputs_from_state)
182+
missing_required_parameters = [
183+
name for name in required_tool_parameters if name not in parameters.get("properties", {})
184+
]
185+
if missing_required_parameters:
186+
raise ValueError(
187+
f"The Agent wrapped by this tool requires the inputs {missing_required_parameters}, but this tool "
188+
f"does not supply them, so it can never run. Add them to 'parameters', the schema that the calling "
189+
f"LLM fills in, or to 'inputs_from_state', which takes them from the calling Agent's state."
190+
)
191+
192+
super().__init__(
193+
component=agent,
194+
name=name,
195+
description=description,
196+
parameters=parameters,
197+
outputs_to_string=outputs_to_string or {"handler": agent_result_to_string},
198+
inputs_from_state=inputs_from_state,
199+
outputs_to_state=outputs_to_state,
200+
)
201+
202+
def _create_tool_parameters_schema(self, component: Any, inputs_from_state: dict[str, Any]) -> dict[str, Any]:
203+
"""
204+
Override ComponentTool schema generation for AgentTool defaults.
205+
206+
ComponentTool calls this when users do not provide an explicit `parameters` schema. The generated schema always
207+
includes `messages` for the delegated task, plus one string parameter for each mandatory Agent input not
208+
supplied through `inputs_from_state`.
209+
"""
210+
additional_required_parameters = _required_tool_parameters(agent=component, inputs_from_state=inputs_from_state)
211+
additional_properties = {name: {"type": "string"} for name in additional_required_parameters}
212+
return {
213+
"type": "object",
214+
"properties": {
215+
"messages": {
216+
"type": "array",
217+
"description": "Exactly one user message.",
218+
"minItems": 1,
219+
"maxItems": 1,
220+
"items": {
221+
"type": "object",
222+
"properties": {
223+
"role": {"type": "string", "enum": ["user"]},
224+
"content": {"type": "string", "description": "The task to delegate to this tool."},
225+
},
226+
"required": ["role", "content"],
227+
},
228+
},
229+
**additional_properties,
230+
},
231+
"required": ["messages", *additional_required_parameters],
232+
}
233+
234+
def to_dict(self) -> dict[str, Any]:
235+
"""
236+
Serializes the AgentTool to a dictionary.
237+
238+
:returns:
239+
The serialized dictionary representation of AgentTool.
240+
"""
241+
serialized = super().to_dict()
242+
serialized["data"]["agent"] = serialized["data"].pop("component")
243+
return serialized
244+
245+
@classmethod
246+
def from_dict(cls, data: dict[str, Any]) -> "AgentTool":
247+
"""
248+
Deserializes the AgentTool from a dictionary.
249+
250+
:param data: The dictionary representation of AgentTool.
251+
:returns:
252+
The deserialized AgentTool instance.
253+
"""
254+
inner_data = data["data"]
255+
deserialize_component_inplace(data=inner_data, key="agent")
256+
257+
outputs_to_state = inner_data.get("outputs_to_state")
258+
if outputs_to_state:
259+
outputs_to_state = _deserialize_outputs_to_state(outputs_to_state=outputs_to_state)
260+
261+
outputs_to_string = inner_data.get("outputs_to_string")
262+
if outputs_to_string is not None:
263+
outputs_to_string = _deserialize_outputs_to_string(outputs_to_string=outputs_to_string)
264+
265+
return cls(
266+
agent=inner_data["agent"],
267+
name=inner_data["name"],
268+
description=inner_data["description"],
269+
parameters=inner_data.get("parameters"),
270+
outputs_to_string=outputs_to_string,
271+
inputs_from_state=inner_data.get("inputs_from_state"),
272+
outputs_to_state=outputs_to_state,
273+
)

haystack/tools/component_tool.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,7 @@
1010
from haystack import logging
1111
from haystack.components.agents.state.state import State
1212
from haystack.core.component import Component
13-
from haystack.core.serialization import (
14-
component_from_dict,
15-
component_to_dict,
16-
generate_qualified_class_name,
17-
import_class_by_name,
18-
)
13+
from haystack.core.serialization import component_to_dict, generate_qualified_class_name
1914
from haystack.tools import Tool
2015
from haystack.tools.errors import SchemaGenerationError
2116
from haystack.tools.from_function import _remove_title_from_schema
@@ -31,6 +26,7 @@
3126
_serialize_outputs_to_state,
3227
_serialize_outputs_to_string,
3328
)
29+
from haystack.utils.deserialization import deserialize_component_inplace
3430
from haystack.utils.type_serialization import _is_union_type
3531

3632
logger = logging.getLogger(__name__)
@@ -310,8 +306,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ComponentTool":
310306
Deserializes the ComponentTool from a dictionary.
311307
"""
312308
inner_data = data["data"]
313-
component_class = import_class_by_name(inner_data["component"]["type"])
314-
component = component_from_dict(cls=component_class, data=inner_data["component"], name=inner_data["name"])
309+
deserialize_component_inplace(data=inner_data, key="component")
315310

316311
if "outputs_to_state" in inner_data and inner_data["outputs_to_state"]:
317312
inner_data["outputs_to_state"] = _deserialize_outputs_to_state(inner_data["outputs_to_state"])
@@ -320,7 +315,7 @@ def from_dict(cls, data: dict[str, Any]) -> "ComponentTool":
320315
inner_data["outputs_to_string"] = _deserialize_outputs_to_string(inner_data["outputs_to_string"])
321316

322317
return cls(
323-
component=component,
318+
component=inner_data["component"],
324319
name=inner_data["name"],
325320
description=inner_data["description"],
326321
parameters=inner_data.get("parameters", None),

pydoc/tools_api.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
loaders:
22
- search_path: [../haystack/tools]
3-
modules: ["tool", "from_function", "component_tool", "pipeline_tool", "searchable_toolset", "toolset",
4-
"skills/skill_toolset"]
3+
modules: ["tool", "from_function", "component_tool", "pipeline_tool", "agent_tool", "searchable_toolset",
4+
"toolset", "skills/skill_toolset"]
55
processors:
66
- type: filter
77
documented_only: true
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
features:
3+
- |
4+
Added ``AgentTool``, a Tool that wraps a Haystack ``Agent``, allowing it to be used as a tool by another
5+
``Agent``. It is a building block for multi-agent systems: an ``Agent`` specialized in one task becomes a tool
6+
that another ``Agent`` can delegate to. The calling ``Agent`` only sees the final reply, so all the steps the
7+
wrapped ``Agent`` takes stay out of its context. Sensible defaults make this work out of the box: the task is
8+
delegated as a single user message and comes back as text.
9+
10+
Example:
11+
12+
.. code-block:: python
13+
14+
from haystack.components.agents import Agent
15+
from haystack.components.generators.chat import OpenAIResponsesChatGenerator
16+
from haystack.dataclasses import ChatMessage
17+
from haystack.tools import AgentTool, ComponentTool
18+
from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch
19+
20+
researcher = Agent(
21+
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4-mini"),
22+
system_prompt="You are a research specialist. Investigate the task and report your findings.",
23+
tools=[
24+
ComponentTool(
25+
component=SerperDevWebSearch(
26+
top_k=3,
27+
),
28+
name="web_search",
29+
description="Search the web for current information on any topic",
30+
),
31+
],
32+
)
33+
34+
research = AgentTool(
35+
agent=researcher,
36+
name="research",
37+
description="Research a question on the web and report the findings",
38+
)
39+
40+
coordinator = Agent(
41+
chat_generator=OpenAIResponsesChatGenerator(model="gpt-5.4"),
42+
tools=[research],
43+
system_prompt="You coordinate specialists. Delegate research questions, then answer the user.",
44+
)
45+
46+
result = coordinator.run([ChatMessage.from_user("What are the latest developments in the Haystack framework?")])
47+
print(result["last_message"].text)

0 commit comments

Comments
 (0)