Skip to content

Commit 692ebaf

Browse files
authored
refactor!: Remove ToolInvoker and move tool calling code into Agent (#11415)
1 parent 13d4138 commit 692ebaf

20 files changed

Lines changed: 2123 additions & 2768 deletions

File tree

MIGRATION.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,89 @@ from haystack.dataclasses import Document
7171
doc = Document(content="col\n1\n2\n3")
7272
```
7373

74+
### ToolInvoker component removed
75+
76+
**What changed:** The `ToolInvoker` component has been removed. Imports from `haystack.components.tools`
77+
and pipelines that use `ToolInvoker` as a standalone component are no longer supported.
78+
79+
**Why:** Tool execution is now owned by `Agent`, so the tool-calling loop, state handling, streaming callback
80+
passthrough, warm-up, and sync/async execution live in one place.
81+
82+
**How to migrate:** Pass tools directly to `Agent` instead of wiring a chat generator to `ToolInvoker`.
83+
The `Agent` will pass tool definitions to the chat generator, execute requested tool calls, append tool
84+
results to the conversation, and continue the loop until an exit condition is reached.
85+
86+
Before (v2.x):
87+
```python
88+
from typing import Annotated
89+
90+
from haystack.components.generators.chat import OpenAIChatGenerator
91+
from haystack.components.tools import ToolInvoker
92+
from haystack.dataclasses import ChatMessage
93+
from haystack.tools import tool
94+
95+
96+
@tool
97+
def weather(city: Annotated[str, "The name of the city"]) -> str:
98+
"""Get the weather for a city."""
99+
return f"The weather in {city} is sunny."
100+
101+
102+
chat_generator = OpenAIChatGenerator(model="gpt-4o-mini", tools=[weather])
103+
tool_invoker = ToolInvoker(tools=[weather])
104+
105+
llm_result = chat_generator.run(messages=[ChatMessage.from_user("What is the weather in Berlin?")])
106+
tool_result = tool_invoker.run(messages=llm_result["replies"])
107+
```
108+
109+
After (v3.0):
110+
```python
111+
from typing import Annotated
112+
113+
from haystack.components.agents import Agent
114+
from haystack.components.generators.chat import OpenAIChatGenerator
115+
from haystack.dataclasses import ChatMessage
116+
from haystack.tools import tool
117+
118+
119+
@tool
120+
def weather(city: Annotated[str, "The name of the city"]) -> str:
121+
"""Get the weather for a city."""
122+
return f"The weather in {city} is sunny."
123+
124+
125+
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"), tools=[weather])
126+
result = agent.run(messages=[ChatMessage.from_user("What is the weather in Berlin?")])
127+
```
128+
129+
The `tool_invoker_kwargs` parameter has been removed from `Agent`. Previously, `ToolInvoker` options were
130+
forwarded through this dictionary; the relevant options are now top-level `Agent` constructor parameters:
131+
132+
- `max_workers` is now the top-level `tool_concurrency_limit` parameter.
133+
- `enable_streaming_callback_passthrough` is now the top-level `tool_streaming_callback_passthrough` parameter.
134+
135+
Before (v2.x):
136+
```python
137+
agent = Agent(
138+
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
139+
tools=[weather],
140+
tool_invoker_kwargs={"max_workers": 4, "enable_streaming_callback_passthrough": True},
141+
)
142+
```
143+
144+
After (v3.0):
145+
```python
146+
agent = Agent(
147+
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
148+
tools=[weather],
149+
tool_concurrency_limit=4,
150+
tool_streaming_callback_passthrough=True,
151+
)
152+
```
153+
154+
The `convert_result_to_json_string` option (also previously set through `tool_invoker_kwargs`) has been removed.
155+
Non-string tool results are now always serialized with `json.dumps` rather than `str`, which changes their string form.
156+
74157
### Agent
75158

76159
#### Breakpoint and snapshot API removed

haystack/components/agents/agent.py

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
replace_values,
1919
)
2020
from haystack.components.agents.state.state_utils import merge_lists
21+
from haystack.components.agents.tool_calling import _run_tool, _run_tool_async
2122
from haystack.components.builders import ChatPromptBuilder
2223
from haystack.components.generators.chat.types import ChatGenerator
23-
from haystack.components.tools import ToolInvoker
2424
from haystack.core.serialization import component_to_dict, default_from_dict, default_to_dict
2525
from haystack.dataclasses import ChatMessage, ChatRole, StreamingCallbackT, select_streaming_callback
2626
from haystack.human_in_the_loop.strategies import (
@@ -36,6 +36,7 @@
3636
deserialize_tools_or_toolset_inplace,
3737
flatten_tools_or_toolsets,
3838
serialize_tools_or_toolset,
39+
warm_up_tools,
3940
)
4041
from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
4142
from haystack.utils.deserialization import deserialize_component_inplace
@@ -127,7 +128,7 @@ class _ExecutionContext:
127128
128129
:param state: The current state of the agent, including messages and any additional data.
129130
:param chat_generator_inputs: Runtime inputs to be passed to the chat generator.
130-
:param tool_invoker_inputs: Runtime inputs to be passed to the tool invoker.
131+
:param tool_execution_inputs: Runtime inputs to be passed to tool execution.
131132
:param counter: A counter to track the number of steps taken in the agent's run.
132133
:param confirmation_strategy_context: Optional dictionary for passing request-scoped resources
133134
to confirmation strategies. In web/server environments, this enables passing per-request
@@ -138,7 +139,7 @@ class _ExecutionContext:
138139

139140
state: State
140141
chat_generator_inputs: dict
141-
tool_invoker_inputs: dict
142+
tool_execution_inputs: dict
142143
counter: int = 0
143144
confirmation_strategy_context: dict[str, Any] | None = None
144145

@@ -264,7 +265,8 @@ def __init__(
264265
max_agent_steps: int = 100,
265266
streaming_callback: StreamingCallbackT | None = None,
266267
raise_on_tool_invocation_failure: bool = False,
267-
tool_invoker_kwargs: dict[str, Any] | None = None,
268+
tool_concurrency_limit: int = 4,
269+
tool_streaming_callback_passthrough: bool = False,
268270
confirmation_strategies: dict[str | tuple[str, ...], ConfirmationStrategy] | None = None,
269271
) -> None:
270272
"""
@@ -295,7 +297,9 @@ def __init__(
295297
The same callback can be configured to emit tool results when a tool is called.
296298
:param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
297299
If set to False, the exception will be turned into a chat message and passed to the LLM.
298-
:param tool_invoker_kwargs: Additional keyword arguments to pass to the ToolInvoker.
300+
:param tool_concurrency_limit: Maximum number of tool calls to execute at the same time.
301+
Defaults to 4. Set to 1 to disable parallel tool execution.
302+
:param tool_streaming_callback_passthrough: If True, pass the streaming callback to tools that accept it.
299303
:param confirmation_strategies: A dictionary mapping tool names to ConfirmationStrategy instances.
300304
:raises TypeError: If the chat_generator does not support tools parameter in its run method.
301305
:raises ValueError: If the exit_conditions are not valid.
@@ -322,6 +326,8 @@ def __init__(
322326
if state_schema is not None:
323327
_validate_schema(state_schema)
324328
_validate_prompt_message_blocks(user_prompt, system_prompt)
329+
if tool_concurrency_limit < 1:
330+
raise ValueError("tool_concurrency_limit must be greater than or equal to 1.")
325331

326332
# --- Attributes ---
327333
self.chat_generator = chat_generator
@@ -333,7 +339,8 @@ def __init__(
333339
self.max_agent_steps = max_agent_steps
334340
self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
335341
self.streaming_callback = streaming_callback
336-
self.tool_invoker_kwargs = tool_invoker_kwargs
342+
self.tool_concurrency_limit = tool_concurrency_limit
343+
self.tool_streaming_callback_passthrough = tool_streaming_callback_passthrough
337344
self._confirmation_strategies = confirmation_strategies or {}
338345
self._is_warmed_up = False
339346

@@ -368,15 +375,8 @@ def __init__(
368375
)
369376
self._register_prompt_variables()
370377

371-
# --- Tool invoker ---
372-
self._tool_invoker = None
373-
if self.tools:
374-
self._tool_invoker = ToolInvoker(
375-
tools=self.tools,
376-
raise_on_failure=self.raise_on_tool_invocation_failure,
377-
**(self.tool_invoker_kwargs or {}),
378-
)
379-
elif type(self).__name__ == "Agent":
378+
# --- No-tools warning ---
379+
if not self.tools and type(self).__name__ == "Agent":
380380
logger.warning(
381381
"No tools provided to the Agent. The Agent will behave like a ChatGenerator and only return text "
382382
"responses. To enable tool usage, pass tools directly to the Agent, not to the chat_generator."
@@ -440,8 +440,8 @@ def warm_up(self) -> None:
440440
if not self._is_warmed_up:
441441
if hasattr(self.chat_generator, "warm_up"):
442442
self.chat_generator.warm_up()
443-
if hasattr(self._tool_invoker, "warm_up") and self._tool_invoker is not None:
444-
self._tool_invoker.warm_up()
443+
if self.tools:
444+
warm_up_tools(self.tools)
445445
self._is_warmed_up = True
446446

447447
def to_dict(self) -> dict[str, Any]:
@@ -463,7 +463,8 @@ def to_dict(self) -> dict[str, Any]:
463463
max_agent_steps=self.max_agent_steps,
464464
streaming_callback=serialize_callable(self.streaming_callback) if self.streaming_callback else None,
465465
raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
466-
tool_invoker_kwargs=self.tool_invoker_kwargs,
466+
tool_concurrency_limit=self.tool_concurrency_limit,
467+
tool_streaming_callback_passthrough=self.tool_streaming_callback_passthrough,
467468
confirmation_strategies={
468469
(list(key) if isinstance(key, tuple) else key): component_to_dict(
469470
obj=strategy, name="confirmation_strategy"
@@ -577,26 +578,26 @@ def _initialize_fresh_execution(
577578
)
578579

579580
selected_tools = self._select_tools(tools)
580-
tool_invoker_inputs: dict[str, Any] = {"tools": selected_tools}
581581
generator_inputs: dict[str, Any] = {}
582582
if self._chat_generator_supports_tools:
583583
generator_inputs["tools"] = selected_tools
584584
if streaming_callback is not None:
585-
tool_invoker_inputs["streaming_callback"] = streaming_callback
586585
generator_inputs["streaming_callback"] = streaming_callback
587586
if generation_kwargs is not None:
588587
generator_inputs["generation_kwargs"] = generation_kwargs
589588

590-
# We add enable_streaming_callback_passthrough to the tool invoker inputs
591-
if self._tool_invoker:
592-
tool_invoker_inputs["enable_streaming_callback_passthrough"] = (
593-
self._tool_invoker.enable_streaming_callback_passthrough
594-
)
589+
tool_execution_inputs: dict[str, Any] = {
590+
"tools": selected_tools,
591+
"raise_on_failure": self.raise_on_tool_invocation_failure,
592+
"streaming_callback": streaming_callback,
593+
"max_workers": self.tool_concurrency_limit,
594+
"enable_streaming_callback_passthrough": self.tool_streaming_callback_passthrough,
595+
}
595596

596597
return _ExecutionContext(
597598
state=state,
598599
chat_generator_inputs=generator_inputs,
599-
tool_invoker_inputs=tool_invoker_inputs,
600+
tool_execution_inputs=tool_execution_inputs,
600601
confirmation_strategy_context=confirmation_strategy_context,
601602
)
602603

@@ -787,28 +788,32 @@ def _run_step(self, exe_context: _ExecutionContext, agent_span: tracing.Span) ->
787788
llm_messages = result["replies"]
788789
exe_context.state.set("messages", llm_messages)
789790

790-
if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
791+
if not any(msg.tool_call for msg in llm_messages) or not self.tools:
791792
exe_context.counter += 1
792793
return False
793794

794795
modified_tool_call_messages, new_chat_history = _process_confirmation_strategies(
795796
confirmation_strategies=self._confirmation_strategies,
796797
messages_with_tool_calls=llm_messages,
797-
execution_context=exe_context,
798+
state=exe_context.state,
799+
confirmation_strategy_context=exe_context.confirmation_strategy_context,
800+
tools=exe_context.tool_execution_inputs["tools"],
801+
streaming_callback=exe_context.tool_execution_inputs["streaming_callback"],
802+
enable_streaming_passthrough=exe_context.tool_execution_inputs["enable_streaming_callback_passthrough"],
798803
)
799804
exe_context.state.set(key="messages", value=new_chat_history, handler_override=replace_values)
800805

801-
tool_invoker_inputs = {
806+
tool_execution_inputs = {
802807
"messages": modified_tool_call_messages,
803808
"state": exe_context.state,
804-
**exe_context.tool_invoker_inputs,
809+
**exe_context.tool_execution_inputs,
805810
}
806811
with tracing.tracer.trace("haystack.agent.step.tool", parent_span=step_span) as tool_span:
807-
tool_span.set_content_tag("haystack.agent.step.tool.input", tool_invoker_inputs)
808-
tool_invoker_result = self._tool_invoker.run(**tool_invoker_inputs)
809-
tool_span.set_content_tag("haystack.agent.step.tool.output", tool_invoker_result)
810-
tool_messages = tool_invoker_result["tool_messages"]
811-
exe_context.state = tool_invoker_result["state"]
812+
tool_span.set_content_tag("haystack.agent.step.tool.input", tool_execution_inputs)
813+
tool_messages, exe_context.state = _run_tool(**tool_execution_inputs)
814+
tool_span.set_content_tag(
815+
"haystack.agent.step.tool.output", {"tool_messages": tool_messages, "state": exe_context.state}
816+
)
812817
exe_context.state.set("messages", tool_messages)
813818

814819
if self.exit_conditions != ["text"] and self._check_exit_conditions(llm_messages, tool_messages):
@@ -844,28 +849,32 @@ async def _run_step_async(self, exe_context: _ExecutionContext, agent_span: trac
844849
llm_messages = result["replies"]
845850
exe_context.state.set("messages", llm_messages)
846851

847-
if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
852+
if not any(msg.tool_call for msg in llm_messages) or not self.tools:
848853
exe_context.counter += 1
849854
return False
850855

851856
modified_tool_call_messages, new_chat_history = await _process_confirmation_strategies_async(
852857
confirmation_strategies=self._confirmation_strategies,
853858
messages_with_tool_calls=llm_messages,
854-
execution_context=exe_context,
859+
tools=exe_context.tool_execution_inputs["tools"],
860+
state=exe_context.state,
861+
streaming_callback=exe_context.tool_execution_inputs["streaming_callback"],
862+
enable_streaming_passthrough=exe_context.tool_execution_inputs["enable_streaming_callback_passthrough"],
863+
confirmation_strategy_context=exe_context.confirmation_strategy_context,
855864
)
856865
exe_context.state.set(key="messages", value=new_chat_history, handler_override=replace_values)
857866

858-
tool_invoker_inputs = {
867+
tool_execution_inputs = {
859868
"messages": modified_tool_call_messages,
860869
"state": exe_context.state,
861-
**exe_context.tool_invoker_inputs,
870+
**exe_context.tool_execution_inputs,
862871
}
863872
with tracing.tracer.trace("haystack.agent.step.tool", parent_span=step_span) as tool_span:
864-
tool_span.set_content_tag("haystack.agent.step.tool.input", tool_invoker_inputs)
865-
tool_invoker_result = await self._tool_invoker.run_async(**tool_invoker_inputs)
866-
tool_span.set_content_tag("haystack.agent.step.tool.output", tool_invoker_result)
867-
tool_messages = tool_invoker_result["tool_messages"]
868-
exe_context.state = tool_invoker_result["state"]
873+
tool_span.set_content_tag("haystack.agent.step.tool.input", tool_execution_inputs)
874+
tool_messages, exe_context.state = await _run_tool_async(**tool_execution_inputs)
875+
tool_span.set_content_tag(
876+
"haystack.agent.step.tool.output", {"tool_messages": tool_messages, "state": exe_context.state}
877+
)
869878
exe_context.state.set("messages", tool_messages)
870879

871880
if self.exit_conditions != ["text"] and self._check_exit_conditions(llm_messages, tool_messages):
@@ -880,7 +889,7 @@ def _check_exit_conditions(self, llm_messages: list[ChatMessage], tool_messages:
880889
Check if any of the LLM messages' tool calls match an exit condition and if there are no errors.
881890
882891
:param llm_messages: List of messages from the LLM
883-
:param tool_messages: List of messages from the tool invoker
892+
:param tool_messages: List of messages from tool execution.
884893
:return: True if an exit condition is met and there are no errors, False otherwise
885894
"""
886895
matched_exit_conditions = set()

0 commit comments

Comments
 (0)