Coverage for haystack/tools/agent_tool.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import json 

6from collections.abc import Callable 

7from typing import Any 

8 

9from haystack.components.agents import Agent 

10from haystack.components.agents.agent import _EXIT_REASON_MAX_STEPS 

11from haystack.tools.component_tool import ComponentTool 

12from haystack.tools.tool import _deserialize_outputs_to_state, _deserialize_outputs_to_string 

13from haystack.utils.deserialization import deserialize_component_inplace 

14 

15 

16def _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 

37def 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 

46class 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 )