|
| 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 | + ) |
0 commit comments