Repository: deepset-ai/haystack

Issue:
Add runtime tool injection support to ToolInvoker via `tools` parameter in run() method
### Problem
`ToolInvoker` currently requires tools at initialization time, unlike chat generators which support runtime tool injection via a `tools` parameter. This prevents:

- **Dynamic tool loading** - Cannot add tools, MCP tools etc without recreating pipelines
- **Serialized pipeline flexibility** - Empty ToolInvokers can't be serialized and populated later
- **Clean architecture** - Forces users to create custom workaround components

### Current User Workarounds
From Discord discussion, users are implementing:
- Custom `PlaceholderToolInvoker` components for empty initialization
- Complex pipeline switching mechanisms
- Custom `ToolLoader` components

### Proposed Solution
Add `tools` parameter to `ToolInvoker.run()` and `run_async()` methods:

```python
def run(
    self,
    messages: List[ChatMessage],
    state: Optional[State] = None,
    streaming_callback: Optional[StreamingCallbackT] = None,
    *,
    tools: Optional[Union[List[Tool], Toolset]] = None,  # NEW
    enable_streaming_callback_passthrough: Optional[bool] = None,
) -> Dict[str, Any]:
```

**Behavior**:
- Runtime `tools` parameter overrides/adds to initialization tools
- If `tools=None`, use initialization tools
- Support both `List[Tool]` and `Toolset` types
- Full backward compatibility

### Existing Precedent
This pattern is already established:
- All chat generators (`OpenAIChatGenerator`, `HuggingFaceAPIChatGenerator`, etc.) support runtime tools
- `Agent` component passes tools to generators at runtime via `_prepare_generator_inputs()`

### Implementation
- Update `run()` and `run_async()` method signatures
- Modify `_prepare_tool_call_params()` to use runtime tools when provided
- Add validation for duplicate tool names
- Maintain backward compatibility

### Benefits
- **API consistency** with chat generators
- **Dynamic tool scenarios** without workarounds
- **Better serialization** support for empty ToolInvokers
- **Improved developer experience** for MCP and dynamic tool use cases

This enhancement provides a clean solution for runtime tool injection while maintaining full backward compatibility.


Discussion:

Patch:
diff --git a/haystack/components/tools/tool_invoker.py b/haystack/components/tools/tool_invoker.py
index 558f9dd600..11d73c0789 100644
--- a/haystack/components/tools/tool_invoker.py
+++ b/haystack/components/tools/tool_invoker.py
@@ -200,16 +200,27 @@ def __init__(
         :raises ValueError:
             If no tools are provided or if duplicate tool names are found.
         """
-        if not tools:
-            raise ValueError("ToolInvoker requires at least one tool.")
-
-        # could be a Toolset instance or a list of Tools
         self.tools = tools
         self.streaming_callback = streaming_callback
         self.enable_streaming_callback_passthrough = enable_streaming_callback_passthrough
         self.max_workers = max_workers
+        self.raise_on_failure = raise_on_failure
+        self.convert_result_to_json_string = convert_result_to_json_string
+
+        self._tools_with_names = self._validate_and_prepare_tools(tools)
+
+    @staticmethod
+    def _validate_and_prepare_tools(tools: Union[list[Tool], Toolset]) -> dict[str, Tool]:
+        """
+        Validates and prepares tools for use by the ToolInvoker.
+
+        :param tools: A list of tools or a Toolset instance.
+        :returns: A dictionary mapping tool names to Tool instances.
+        :raises ValueError: If no tools are provided or if duplicate tool names are found.
+        """
+        if not tools:
+            raise ValueError("ToolInvoker requires at least one tool.")
 
-        # Convert Toolset to list for internal use
         if isinstance(tools, Toolset):
             converted_tools = list(tools)
         else:
@@ -221,9 +232,7 @@ def __init__(
         if duplicates:
             raise ValueError(f"Duplicate tool names found: {duplicates}")
 
-        self._tools_with_names = dict(zip(tool_names, converted_tools))
-        self.raise_on_failure = raise_on_failure
-        self.convert_result_to_json_string = convert_result_to_json_string
+        return dict(zip(tool_names, converted_tools))
 
     def _handle_error(self, error: Exception) -> str:
         """
@@ -261,8 +270,7 @@ def _default_output_to_string_handler(self, result: Any) -> str:
                 # If the result is not JSON serializable, we fall back to str
                 logger.warning(
                     "Tool result is not JSON serializable. Falling back to str conversion. "
-                    "Result: {result}\n"
-                    "Error: {error}",
+                    "Result: {result}\nError: {error}",
                     result=result,
                     err=error,
                 )
@@ -287,23 +295,14 @@ def _prepare_tool_result_message(self, result: Any, tool_call: ToolCall, tool_to
             StringConversionError: If the conversion of the tool result to a string fails
             and `raise_on_failure` is True.
         """
-        source_key = None
-        output_to_string_handler = None
-        if tool_to_invoke.outputs_to_string is not None:
-            if tool_to_invoke.outputs_to_string.get("source"):
-                source_key = tool_to_invoke.outputs_to_string["source"]
-            if tool_to_invoke.outputs_to_string.get("handler"):
-                output_to_string_handler = tool_to_invoke.outputs_to_string["handler"]
-
-        # If a source key is provided, we extract the result from the source key
-        if source_key is not None:
-            result_to_convert = result.get(source_key)
-        else:
-            result_to_convert = result
+        outputs_config = tool_to_invoke.outputs_to_string or {}
+        source_key = outputs_config.get("source")
 
         # If no handler is provided, we use the default handler
-        if output_to_string_handler is None:
-            output_to_string_handler = self._default_output_to_string_handler
+        output_to_string_handler = outputs_config.get("handler", self._default_output_to_string_handler)
+
+        # If a source key is provided, we extract the result from the source key
+        result_to_convert = result.get(source_key) if source_key is not None else result
 
         error = False
         try:
@@ -319,7 +318,8 @@ def _prepare_tool_result_message(self, result: Any, tool_call: ToolCall, tool_to
                 raise conversion_error from e
         return ChatMessage.from_tool(tool_result=tool_result_str, error=error, origin=tool_call)
 
-    def _get_func_params(self, tool: Tool) -> set:
+    @staticmethod
+    def _get_func_params(tool: Tool) -> set:
         """
         Returns the function parameters of the tool's invoke method.
 
@@ -338,7 +338,8 @@ def _get_func_params(self, tool: Tool) -> set:
 
         return func_params
 
-    def _inject_state_args(self, tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]:
+    @staticmethod
+    def _inject_state_args(tool: Tool, llm_args: dict[str, Any], state: State) -> dict[str, Any]:
         """
         Combine LLM-provided arguments (llm_args) with state-based arguments.
 
@@ -348,7 +349,7 @@ def _inject_state_args(self, tool: Tool, llm_args: dict[str, Any], state: State)
           - function signature name matching
         """
         final_args = dict(llm_args)  # start with LLM-provided
-        func_params = self._get_func_params(tool)
+        func_params = ToolInvoker._get_func_params(tool)
 
         # Determine the source of parameter mappings (explicit tool inputs or direct function parameters)
         # Typically, a "Tool" might have .inputs_from_state = {"state_key": "tool_param_name"}
@@ -401,21 +402,31 @@ def _merge_tool_outputs(tool: Tool, result: Any, state: State) -> None:
         for state_key, config in tool.outputs_to_state.items():
             # Get the source key from the output config, otherwise use the entire result
             source_key = config.get("source", None)
-            output_value = result if source_key is None else result.get(source_key)
-
-            # Get the handler function, if any
-            handler = config.get("handler", None)
+            output_value = result.get(source_key) if source_key else result
 
             # Merge other outputs into the state
-            state.set(state_key, output_value, handler_override=handler)
+            state.set(state_key, output_value, handler_override=config.get("handler"))
+
+    @staticmethod
+    def _create_tool_result_streaming_chunk(tool_messages: list[ChatMessage], tool_call: ToolCall) -> StreamingChunk:
+        """Create a streaming chunk for a tool result."""
+        return StreamingChunk(
+            content="",
+            index=len(tool_messages) - 1,
+            tool_call_result=tool_messages[-1].tool_call_results[0],
+            start=True,
+            meta={"tool_result": tool_messages[-1].tool_call_results[0].result, "tool_call": tool_call},
+        )
 
     def _prepare_tool_call_params(
         self,
+        *,
         messages_with_tool_calls: list[ChatMessage],
         state: State,
         streaming_callback: Optional[StreamingCallbackT],
         enable_streaming_passthrough: bool,
-    ) -> tuple[list[dict[str, Any]], list[ChatMessage]]:
+        tools_with_names: dict[str, Tool],
+    ) -> tuple[list[ToolCall], list[dict[str, Any]], list[ChatMessage]]:
         """
         Prepare tool call parameters for execution and collect any error messages.
 
@@ -423,26 +434,25 @@ def _prepare_tool_call_params(
         :param state: The current state for argument injection
         :param streaming_callback: Optional streaming callback to inject
         :param enable_streaming_passthrough: Whether to pass streaming callback to tools
-        :returns: Tuple of (tool_call_params, error_messages)
+        :returns: Tuple of (tool_calls, tool_call_params, error_messages)
         """
         tool_call_params = []
         error_messages = []
+        tool_calls = []
 
         for message in messages_with_tool_calls:
             for tool_call in message.tool_calls:
                 tool_name = tool_call.tool_name
 
                 # Check if the tool is available, otherwise return an error message
-                if tool_name not in self._tools_with_names:
-                    error_message = self._handle_error(
-                        ToolNotFoundException(tool_name, list(self._tools_with_names.keys()))
-                    )
+                if tool_name not in tools_with_names:
+                    error_message = self._handle_error(ToolNotFoundException(tool_name, list(tools_with_names.keys())))
                     error_messages.append(
                         ChatMessage.from_tool(tool_result=error_message, origin=tool_call, error=True)
                     )
                     continue
 
-                tool_to_invoke = self._tools_with_names[tool_name]
+                tool_to_invoke = tools_with_names[tool_name]
 
                 # Combine user + state inputs
                 llm_args = tool_call.arguments.copy()
@@ -453,16 +463,14 @@ def _prepare_tool_call_params(
                     enable_streaming_passthrough
                     and streaming_callback is not None
                     and "streaming_callback" not in final_args
+                    and "streaming_callback" in self._get_func_params(tool_to_invoke)
                 ):
-                    invoke_params = self._get_func_params(tool_to_invoke)
-                    if "streaming_callback" in invoke_params:
-                        final_args["streaming_callback"] = streaming_callback
+                    final_args["streaming_callback"] = streaming_callback
 
-                tool_call_params.append(
-                    {"tool_call": tool_call, "tool_to_invoke": tool_to_invoke, "final_args": final_args}
-                )
+                tool_call_params.append({"tool_to_invoke": tool_to_invoke, "final_args": final_args})
+                tool_calls.append(tool_call)
 
-        return tool_call_params, error_messages
+        return tool_calls, tool_call_params, error_messages
 
     @component.output_types(tool_messages=list[ChatMessage], state=State)
     def run(
@@ -472,6 +480,7 @@ def run(
         streaming_callback: Optional[StreamingCallbackT] = None,
         *,
         enable_streaming_callback_passthrough: Optional[bool] = None,
+        tools: Optional[Union[list[Tool], Toolset]] = None,
     ) -> dict[str, Any]:
         """
         Processes ChatMessage objects containing tool calls and invokes the corresponding tools, if available.
@@ -488,6 +497,8 @@ def run(
             Note that this requires the tool to have a `streaming_callback` parameter in its `invoke` method signature.
             If False, the `streaming_callback` will not be passed to the tool invocation.
             If None, the value from the constructor will be used.
+        :param tools:
+            A list of tools to use for the tool invoker. If set, overrides the tools set in the constructor.
         :returns:
             A dictionary with the key `tool_messages` containing a list of ChatMessage objects with tool role.
             Each ChatMessage objects wraps the result of a tool invocation.
@@ -501,6 +512,13 @@ def run(
         :raises ToolOutputMergeError:
             If merging tool outputs into state fails and `raise_on_failure` is True.
         """
+        tools_with_names = self._tools_with_names
+        if tools is not None:
+            tools_with_names = self._validate_and_prepare_tools(tools)
+            logger.debug(
+                f"For this invocation, overriding constructor tools with: {', '.join(tools_with_names.keys())}"
+            )
+
         if state is None:
             state = State(schema={})
 
@@ -519,8 +537,12 @@ def run(
         tool_messages = []
 
         # 1) Collect all tool calls and their parameters for parallel execution
-        tool_call_params, error_messages = self._prepare_tool_call_params(
-            messages_with_tool_calls, state, streaming_callback, resolved_enable_streaming_passthrough
+        tool_calls, tool_call_params, error_messages = self._prepare_tool_call_params(
+            messages_with_tool_calls=messages_with_tool_calls,
+            state=state,
+            streaming_callback=streaming_callback,
+            enable_streaming_passthrough=resolved_enable_streaming_passthrough,
+            tools_with_names=tools_with_names,
         )
         tool_messages.extend(error_messages)
 
@@ -532,23 +554,28 @@ def run(
                     future = executor.submit(self._execute_single_tool_call, **params)
                     futures.append(future)
 
-                # 3) Process results in the order they are submitted
-                for future in futures:
+                # 3) Gather and process results: handle errors and merge outputs into state
+                for future, tool_call in zip(futures, tool_calls):
                     result = future.result()
-                    if isinstance(result, ChatMessage):
-                        tool_messages.append(result)
+
+                    if isinstance(result, ToolInvocationError):
+                        # a) This is an error, create error Tool message
+                        error_message = self._handle_error(result)
+                        error_chat_message = ChatMessage.from_tool(
+                            tool_result=error_message, origin=tool_call, error=True
+                        )
+                        tool_messages.append(error_chat_message)
                     else:
-                        # Handle state merging and prepare tool result message
-                        tool_call, tool_to_invoke, tool_result = result
+                        # b) In case of success, merge outputs into state
+                        tool_to_invoke = tools_with_names[tool_call.tool_name]
 
-                        # 4) Merge outputs into state
                         try:
-                            self._merge_tool_outputs(tool_to_invoke, tool_result, state)
+                            self._merge_tool_outputs(tool=tool_to_invoke, result=result, state=state)
                         except Exception as e:
                             try:
                                 error_message = self._handle_error(
                                     ToolOutputMergeError(
-                                        f"Failed to merge tool outputs fromtool {tool_call.tool_name} into State: {e}"
+                                        f"Failed to merge tool outputs from tool {tool_call.tool_name} into State: {e}"
                                     )
                                 )
                                 tool_messages.append(
@@ -558,28 +585,17 @@ def run(
                             except ToolOutputMergeError as propagated_e:
                                 # Re-raise with proper error chain
                                 raise propagated_e from e
-
-                        # 5) Prepare the tool result ChatMessage message
                         tool_messages.append(
                             self._prepare_tool_result_message(
-                                result=tool_result, tool_call=tool_call, tool_to_invoke=tool_to_invoke
+                                result=result, tool_call=tool_call, tool_to_invoke=tool_to_invoke
                             )
                         )
 
-                        # 6) Handle streaming callback
-                        if streaming_callback is not None:
-                            streaming_callback(
-                                StreamingChunk(
-                                    content="",
-                                    index=len(tool_messages) - 1,
-                                    tool_call_result=tool_messages[-1].tool_call_results[0],
-                                    start=True,
-                                    meta={
-                                        "tool_result": tool_messages[-1].tool_call_results[0].result,
-                                        "tool_call": tool_call,
-                                    },
-                                )
-                            )
+                    # c) Handle streaming callback
+                    if streaming_callback is not None:
+                        streaming_callback(
+                            self._create_tool_result_streaming_chunk(tool_messages=tool_messages, tool_call=tool_call)
+                        )
 
         # We stream one more chunk that contains a finish_reason if tool_messages were generated
         if len(tool_messages) > 0 and streaming_callback is not None:
@@ -591,39 +607,42 @@ def run(
 
         return {"tool_messages": tool_messages, "state": state}
 
-    def _execute_single_tool_call(self, tool_call: ToolCall, tool_to_invoke: Tool, final_args: dict[str, Any]):
+    @staticmethod
+    def _execute_single_tool_call(tool_to_invoke: Tool, final_args: dict[str, Any]) -> Union[ToolInvocationError, Any]:
         """
         Execute a single tool call. This method is designed to be run in a thread pool.
 
-        :param tool_call: The ToolCall object containing the tool name and arguments.
         :param tool_to_invoke: The Tool object that should be invoked.
         :param final_args: The final arguments to pass to the tool.
-        :returns: Either a ChatMessage with error or a tuple of (tool_call, tool_to_invoke, tool_result)
+        :returns: Either a ToolInvocationError or the actual tool result.
         """
         try:
             tool_result = tool_to_invoke.invoke(**final_args)
-            return (tool_call, tool_to_invoke, tool_result)
+            return tool_result
         except ToolInvocationError as e:
-            error_message = self._handle_error(e)
-            return ChatMessage.from_tool(tool_result=error_message, origin=tool_call, error=True)
+            return e
 
     @staticmethod
-    async def invoke_tool_safely(executor: ThreadPoolExecutor, tool_to_invoke: Tool, final_args: dict[str, Any]) -> Any:
+    async def invoke_tool_safely(
+        executor: ThreadPoolExecutor, tool_to_invoke: Tool, final_args: dict[str, Any]
+    ) -> Union[ToolInvocationError, Any]:
         """Safely invoke a tool with proper exception handling."""
         loop = asyncio.get_running_loop()
         try:
-            return await loop.run_in_executor(executor, partial(tool_to_invoke.invoke, **final_args))
+            result = await loop.run_in_executor(executor, partial(tool_to_invoke.invoke, **final_args))
+            return result
         except ToolInvocationError as e:
             return e
 
     @component.output_types(tool_messages=list[ChatMessage], state=State)
-    async def run_async(  # noqa: PLR0915
+    async def run_async(
         self,
         messages: list[ChatMessage],
         state: Optional[State] = None,
         streaming_callback: Optional[StreamingCallbackT] = None,
         *,
         enable_streaming_callback_passthrough: Optional[bool] = None,
+        tools: Optional[Union[list[Tool], Toolset]] = None,
     ) -> dict[str, Any]:
         """
         Asynchronously processes ChatMessage objects containing tool calls.
@@ -641,6 +660,8 @@ async def run_async(  # noqa: PLR0915
             Note that this requires the tool to have a `streaming_callback` parameter in its `invoke` method signature.
             If False, the `streaming_callback` will not be passed to the tool invocation.
             If None, the value from the constructor will be used.
+        :param tools:
+            A list of tools to use for the tool invoker. If set, overrides the tools set in the constructor.
         :returns:
             A dictionary with the key `tool_messages` containing a list of ChatMessage objects with tool role.
             Each ChatMessage objects wraps the result of a tool invocation.
@@ -655,6 +676,13 @@ async def run_async(  # noqa: PLR0915
             If merging tool outputs into state fails and `raise_on_failure` is True.
         """
 
+        tools_with_names = self._tools_with_names
+        if tools is not None:
+            tools_with_names = self._validate_and_prepare_tools(tools)
+            logger.debug(
+                f"For this invocation, overriding constructor tools with: {', '.join(tools_with_names.keys())}"
+            )
+
         if state is None:
             state = State(schema={})
 
@@ -672,79 +700,70 @@ async def run_async(  # noqa: PLR0915
 
         tool_messages = []
 
-        # 1) Prepare tool call parameters for execution
-        tool_call_params, error_messages = self._prepare_tool_call_params(
-            messages_with_tool_calls, state, streaming_callback, resolved_enable_streaming_passthrough
+        # 1) Collect all tool calls and their parameters for parallel execution
+        tool_calls, tool_call_params, error_messages = self._prepare_tool_call_params(
+            messages_with_tool_calls=messages_with_tool_calls,
+            state=state,
+            streaming_callback=streaming_callback,
+            enable_streaming_passthrough=resolved_enable_streaming_passthrough,
+            tools_with_names=tools_with_names,
         )
         tool_messages.extend(error_messages)
 
         # 2) Execute valid tool calls in parallel
         if tool_call_params:
             tool_call_tasks = []
-            valid_tool_calls = []
-
             with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
-                # 3) Create async tasks for valid tool calls
                 for params in tool_call_params:
-                    task = ToolInvoker.invoke_tool_safely(executor, params["tool_to_invoke"], params["final_args"])
+                    task = self.invoke_tool_safely(executor, **params)
                     tool_call_tasks.append(task)
-                    valid_tool_calls.append((params["tool_call"], params["tool_to_invoke"]))
 
+                # 3) Gather and process results: handle errors and merge outputs into state
                 if tool_call_tasks:
-                    # 4) Gather results from all tool calls
                     tool_results = await asyncio.gather(*tool_call_tasks)
-
-                    # 5) Process results
-                    for i, ((tool_call, tool_to_invoke), tool_result) in enumerate(zip(valid_tool_calls, tool_results)):
-                        # Check if the tool_result is a ToolInvocationError (caught by our wrapper)
+                    for tool_result, tool_call in zip(tool_results, tool_calls):
                         if isinstance(tool_result, ToolInvocationError):
+                            # a) This is an error, create error Tool message
                             error_message = self._handle_error(tool_result)
-                            tool_messages.append(
-                                ChatMessage.from_tool(tool_result=error_message, origin=tool_call, error=True)
+                            error_chat_message = ChatMessage.from_tool(
+                                tool_result=error_message, origin=tool_call, error=True
                             )
-                            continue
-
-                        # 6) Merge outputs into state
-                        try:
-                            self._merge_tool_outputs(tool_to_invoke, tool_result, state)
-                        except Exception as e:
+                            tool_messages.append(error_chat_message)
+                        else:
+                            # b) In case of success, merge outputs into state
+                            tool_to_invoke = tools_with_names[tool_call.tool_name]
                             try:
-                                error_message = self._handle_error(
-                                    ToolOutputMergeError(
-                                        f"Failed to merge tool outputs from tool {tool_call.tool_name} into State: {e}"
+                                self._merge_tool_outputs(tool=tool_to_invoke, result=tool_result, state=state)
+                            except Exception as e:
+                                try:
+                                    error_message = self._handle_error(
+                                        ToolOutputMergeError(
+                                            f"Failed to merge tool outputs from tool {tool_call.tool_name} "
+                                            f"into State: {e}"
+                                        )
                                     )
+                                    tool_messages.append(
+                                        ChatMessage.from_tool(tool_result=error_message, origin=tool_call, error=True)
+                                    )
+                                    continue
+                                except ToolOutputMergeError as propagated_e:
+                                    # Re-raise with proper error chain
+                                    raise propagated_e from e
+                            tool_messages.append(
+                                self._prepare_tool_result_message(
+                                    result=tool_result, tool_call=tool_call, tool_to_invoke=tool_to_invoke
                                 )
-                                tool_messages.append(
-                                    ChatMessage.from_tool(tool_result=error_message, origin=tool_call, error=True)
-                                )
-                                continue
-                            except ToolOutputMergeError as propagated_e:
-                                # Re-raise with proper error chain
-                                raise propagated_e from e
-
-                        # 7) Prepare the tool result ChatMessage message
-                        tool_messages.append(
-                            self._prepare_tool_result_message(
-                                result=tool_result, tool_call=tool_call, tool_to_invoke=tool_to_invoke
                             )
-                        )
 
-                        # 8) Handle streaming callback
+                        # c) Handle streaming callback
                         if streaming_callback is not None:
                             await streaming_callback(
-                                StreamingChunk(
-                                    content="",
-                                    index=i,
-                                    tool_call_result=tool_messages[-1].tool_call_results[0],
-                                    start=True,
-                                    meta={
-                                        "tool_result": tool_messages[-1].tool_call_results[0].result,
-                                        "tool_call": tool_call,
-                                    },
+                                self._create_tool_result_streaming_chunk(
+                                    tool_messages=tool_messages, tool_call=tool_call
                                 )
                             )
 
-        # We stream one more chunk that contains a finish_reason if tool_messages were generated
+        # 4) We stream one more chunk that contains a finish_reason if tool_messages were generated
         if len(tool_messages) > 0 and streaming_callback is not None:
             await streaming_callback(
                 StreamingChunk(
diff --git a/releasenotes/notes/tool-invoker-tools-in-run-ff346a1eb510d377.yaml b/releasenotes/notes/tool-invoker-tools-in-run-ff346a1eb510d377.yaml
new file mode 100644
index 0000000000..c796269450
--- /dev/null
+++ b/releasenotes/notes/tool-invoker-tools-in-run-ff346a1eb510d377.yaml
@@ -0,0 +1,5 @@
+---
+enhancements:
+  - |
+    The `ToolInvoker` `run` method now accepts a list of tools. When provided, this list overrides the tools set
+    in the constructor, allowing you to switch tools at runtime in previously built pipelines.


Test patch:
diff --git a/test/components/tools/test_tool_invoker.py b/test/components/tools/test_tool_invoker.py
index b5ba749fd9..dec4dbaaba 100644
--- a/test/components/tools/test_tool_invoker.py
+++ b/test/components/tools/test_tool_invoker.py
@@ -5,7 +5,7 @@
 import datetime
 import json
 import time
-from concurrent.futures import ThreadPoolExecutor
+from typing import Any
 from unittest.mock import patch
 
 import pytest
@@ -15,7 +15,12 @@
 from haystack.components.builders.prompt_builder import PromptBuilder
 from haystack.components.generators.chat.openai import OpenAIChatGenerator
 from haystack.components.generators.utils import print_streaming_chunk
-from haystack.components.tools.tool_invoker import StringConversionError, ToolInvoker, ToolNotFoundException
+from haystack.components.tools.tool_invoker import (
+    StringConversionError,
+    ToolInvoker,
+    ToolNotFoundException,
+    ToolOutputMergeError,
+)
 from haystack.dataclasses import ChatMessage, ChatRole, StreamingChunk, ToolCall, ToolCallResult
 from haystack.tools import ComponentTool, Tool, Toolset
 from haystack.tools.errors import ToolInvocationError
@@ -43,6 +48,17 @@ def weather_tool():
     )
 
 
+@pytest.fixture
+def weather_tool_with_outputs_to_state():
+    return Tool(
+        name="weather_tool",
+        description="Provides weather information for a given location.",
+        parameters=weather_parameters,
+        function=weather_function,
+        outputs_to_state={"weather": {"source": "weather"}},
+    )
+
+
 @pytest.fixture
 def faulty_tool():
     def faulty_tool_func(location):
@@ -100,7 +116,7 @@ def faulty_invoker(faulty_tool):
     return ToolInvoker(tools=[faulty_tool], raise_on_failure=True, convert_result_to_json_string=False)
 
 
-class TestToolInvoker:
+class TestToolInvokerCore:
     def test_init(self, weather_tool):
         invoker = ToolInvoker(tools=[weather_tool])
 
@@ -109,23 +125,20 @@ def test_init(self, weather_tool):
         assert invoker.raise_on_failure
         assert not invoker.convert_result_to_json_string
 
-    def test_init_with_toolset(self, tool_set):
-        tool_invoker = ToolInvoker(tools=tool_set)
-        assert tool_invoker.tools == tool_set
-        assert tool_invoker._tools_with_names == {"weather_tool": tool_set.tools[0], "addition_tool": tool_set.tools[1]}
+    def test_validate_and_prepare_tools(self, weather_tool, faulty_tool):
+        result = ToolInvoker._validate_and_prepare_tools([weather_tool, faulty_tool])
+        assert result == {"weather_tool": weather_tool, "faulty_tool": faulty_tool}
 
-    def test_init_fails_wo_tools(self):
-        with pytest.raises(ValueError):
-            ToolInvoker(tools=[])
+        toolset = Toolset([weather_tool, faulty_tool])
+        result = ToolInvoker._validate_and_prepare_tools(toolset)
+        assert result == {"weather_tool": weather_tool, "faulty_tool": faulty_tool}
 
-    def test_init_fails_with_duplicate_tool_names(self, weather_tool, faulty_tool):
+    def test_validate_and_prepare_tools_validation_failures(self, weather_tool):
         with pytest.raises(ValueError):
-            ToolInvoker(tools=[weather_tool, weather_tool])
+            ToolInvoker._validate_and_prepare_tools([])
 
-        new_tool = faulty_tool
-        new_tool.name = "weather_tool"
         with pytest.raises(ValueError):
-            ToolInvoker(tools=[weather_tool, new_tool])
+            ToolInvoker._validate_and_prepare_tools([weather_tool, weather_tool])
 
     def test_inject_state_args_no_tool_inputs(self, invoker):
         weather_tool = Tool(
@@ -162,310 +175,16 @@ def test_inject_state_args_with_tool_inputs(self, invoker):
     def test_inject_state_args_param_in_state_and_llm(self, invoker):
         weather_tool = Tool(
             name="weather_tool",
-            description="Provides weather information for a given location.",
-            parameters=weather_parameters,
-            function=weather_function,
-        )
-        state = State(schema={"location": {"type": str}}, data={"location": "Berlin"})
-        args = invoker._inject_state_args(tool=weather_tool, llm_args={"location": "Paris"}, state=state)
-        assert args == {"location": "Paris"}
-
-    def test_run_with_streaming_callback(self, invoker):
-        streaming_callback_called = False
-
-        def streaming_callback(chunk: StreamingChunk) -> None:
-            nonlocal streaming_callback_called
-            streaming_callback_called = True
-
-        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
-        message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = invoker.run(messages=[message], streaming_callback=streaming_callback)
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 1
-
-        # check we called the streaming callback
-        assert streaming_callback_called
-
-        tool_message = result["tool_messages"][0]
-        assert isinstance(tool_message, ChatMessage)
-        assert tool_message.is_from(ChatRole.TOOL)
-
-        assert tool_message.tool_call_results
-        tool_call_result = tool_message.tool_call_result
-
-        assert isinstance(tool_call_result, ToolCallResult)
-        assert tool_call_result.result == str({"weather": "mostly sunny", "temperature": 7, "unit": "celsius"})
-        assert tool_call_result.origin == tool_call
-        assert not tool_call_result.error
-
-    def test_run_with_streaming_callback_finish_reason(self, invoker):
-        streaming_chunks = []
-
-        def streaming_callback(chunk: StreamingChunk) -> None:
-            streaming_chunks.append(chunk)
-
-        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
-        message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = invoker.run(messages=[message], streaming_callback=streaming_callback)
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 1
-
-        # Check that we received streaming chunks
-        assert len(streaming_chunks) >= 2  # At least one for tool result and one for finish reason
-
-        # The last chunk should have finish_reason set to "tool_call_results"
-        final_chunk = streaming_chunks[-1]
-        assert final_chunk.finish_reason == "tool_call_results"
-        assert final_chunk.meta["finish_reason"] == "tool_call_results"
-        assert final_chunk.content == ""
-
-    @pytest.mark.asyncio
-    async def test_run_async_with_streaming_callback(self, weather_tool):
-        streaming_callback_called = False
-
-        async def streaming_callback(chunk: StreamingChunk) -> None:
-            print(f"Streaming callback called with chunk: {chunk}")
-            nonlocal streaming_callback_called
-            streaming_callback_called = True
-
-        tool_invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True, convert_result_to_json_string=False)
-
-        tool_calls = [
-            ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
-            ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
-            ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
-        ]
-
-        message = ChatMessage.from_assistant(tool_calls=tool_calls)
-
-        result = await tool_invoker.run_async(messages=[message], streaming_callback=streaming_callback)
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 3
-
-        for i, tool_message in enumerate(result["tool_messages"]):
-            assert isinstance(tool_message, ChatMessage)
-            assert tool_message.is_from(ChatRole.TOOL)
-
-            assert tool_message.tool_call_results
-            tool_call_result = tool_message.tool_call_result
-
-            assert isinstance(tool_call_result, ToolCallResult)
-            assert not tool_call_result.error
-            assert tool_call_result.origin == tool_calls[i]
-
-        # check we called the streaming callback
-        assert streaming_callback_called
-
-    @pytest.mark.asyncio
-    async def test_run_async_with_streaming_callback_finish_reason(self, weather_tool):
-        streaming_chunks = []
-
-        async def streaming_callback(chunk: StreamingChunk) -> None:
-            streaming_chunks.append(chunk)
-
-        tool_invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True, convert_result_to_json_string=False)
-
-        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
-        message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = await tool_invoker.run_async(messages=[message], streaming_callback=streaming_callback)
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 1
-
-        # Check that we received streaming chunks
-        assert len(streaming_chunks) >= 2  # At least one for tool result and one for finish reason
-
-        # The last chunk should have finish_reason set to "tool_call_results"
-        final_chunk = streaming_chunks[-1]
-        assert final_chunk.finish_reason == "tool_call_results"
-        assert final_chunk.meta["finish_reason"] == "tool_call_results"
-        assert final_chunk.content == ""
-
-    def test_run_with_toolset(self, tool_set):
-        tool_invoker = ToolInvoker(tools=tool_set, raise_on_failure=True, convert_result_to_json_string=False)
-        tool_call = ToolCall(tool_name="addition_tool", arguments={"num1": 5, "num2": 3})
-        message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = tool_invoker.run(messages=[message])
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 1
-
-        tool_message = result["tool_messages"][0]
-        assert isinstance(tool_message, ChatMessage)
-        assert tool_message.is_from(ChatRole.TOOL)
-        assert tool_message.tool_call_results
-
-        tool_call_result = tool_message.tool_call_result
-        assert isinstance(tool_call_result, ToolCallResult)
-        assert tool_call_result.result == str(8)
-        assert tool_call_result.origin == tool_call
-        assert not tool_call_result.error
-
-    @pytest.mark.asyncio
-    async def test_run_async_with_toolset(self, tool_set):
-        tool_invoker = ToolInvoker(tools=tool_set, raise_on_failure=True, convert_result_to_json_string=False)
-        tool_calls = [
-            ToolCall(tool_name="addition_tool", arguments={"num1": 5, "num2": 3}),
-            ToolCall(tool_name="addition_tool", arguments={"num1": 5, "num2": 3}),
-            ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
-        ]
-        message = ChatMessage.from_assistant(tool_calls=tool_calls)
-
-        result = await tool_invoker.run_async(messages=[message])
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 3
-
-        for i, tool_message in enumerate(result["tool_messages"]):
-            assert isinstance(tool_message, ChatMessage)
-            assert tool_message.is_from(ChatRole.TOOL)
-
-            assert tool_message.tool_call_results
-            tool_call_result = tool_message.tool_call_result
-
-            assert isinstance(tool_call_result, ToolCallResult)
-            assert not tool_call_result.error
-            assert tool_call_result.origin == tool_calls[i]
-        assert not tool_call_result.error
-
-    def test_run_no_messages(self, invoker):
-        result = invoker.run(messages=[])
-        assert result["tool_messages"] == []
-
-    def test_run_no_tool_calls(self, invoker):
-        user_message = ChatMessage.from_user(text="Hello!")
-        assistant_message = ChatMessage.from_assistant(text="How can I help you?")
-
-        result = invoker.run(messages=[user_message, assistant_message])
-        assert result["tool_messages"] == []
-
-    def test_run_multiple_tool_calls(self, invoker):
-        tool_calls = [
-            ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
-            ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
-            ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
-        ]
-        message = ChatMessage.from_assistant(tool_calls=tool_calls)
-
-        result = invoker.run(messages=[message])
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 3
-
-        for i, tool_message in enumerate(result["tool_messages"]):
-            assert isinstance(tool_message, ChatMessage)
-            assert tool_message.is_from(ChatRole.TOOL)
-
-            assert tool_message.tool_call_results
-            tool_call_result = tool_message.tool_call_result
-
-            assert isinstance(tool_call_result, ToolCallResult)
-            assert not tool_call_result.error
-            assert tool_call_result.origin == tool_calls[i]
-
-    def test_run_tool_calls_with_empty_args(self):
-        hello_world_tool = Tool(
-            name="hello_world",
-            description="A tool that returns a greeting.",
-            parameters={"type": "object", "properties": {}},
-            function=lambda: "Hello, world!",
-        )
-        invoker = ToolInvoker(tools=[hello_world_tool])
-
-        tool_call = ToolCall(tool_name="hello_world", arguments={})
-        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = invoker.run(messages=[tool_call_message])
-        assert "tool_messages" in result
-        assert len(result["tool_messages"]) == 1
-
-        tool_message = result["tool_messages"][0]
-        assert isinstance(tool_message, ChatMessage)
-        assert tool_message.is_from(ChatRole.TOOL)
-
-        assert tool_message.tool_call_results
-        tool_call_result = tool_message.tool_call_result
-
-        assert isinstance(tool_call_result, ToolCallResult)
-        assert not tool_call_result.error
-
-        assert tool_call_result.result == "Hello, world!"
-
-    def test_tool_not_found_error(self, invoker):
-        tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
-        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        with pytest.raises(ToolNotFoundException):
-            invoker.run(messages=[tool_call_message])
-
-    def test_tool_not_found_does_not_raise_exception(self, weather_tool):
-        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=False, convert_result_to_json_string=False)
-
-        tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
-        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = invoker.run(messages=[tool_call_message])
-        tool_message = result["tool_messages"][0]
-
-        assert tool_message.tool_call_results[0].error
-        assert "not found" in tool_message.tool_call_results[0].result
-
-    def test_tool_invocation_error(self, faulty_invoker):
-        tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
-        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        with pytest.raises(ToolInvocationError):
-            faulty_invoker.run(messages=[tool_call_message])
-
-    def test_tool_invocation_error_does_not_raise_exception(self, faulty_tool):
-        faulty_invoker = ToolInvoker(tools=[faulty_tool], raise_on_failure=False, convert_result_to_json_string=False)
-
-        tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
-        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
-
-        result = faulty_invoker.run(messages=[tool_call_message])
-        tool_message = result["tool_messages"][0]
-        assert tool_message.tool_call_results[0].error
-        assert "Failed to invoke" in tool_message.tool_call_results[0].result
-
-    def test_string_conversion_error(self):
-        weather_tool = Tool(
-            name="weather_tool",
-            description="Provides weather information for a given location.",
-            parameters=weather_parameters,
-            function=weather_function,
-            # Pass custom handler that will throw an error when trying to convert tool_result
-            outputs_to_string={"handler": lambda x: json.dumps(x)},
-        )
-        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True)
-
-        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
-
-        tool_result = datetime.datetime.now()
-        with pytest.raises(StringConversionError):
-            invoker._prepare_tool_result_message(result=tool_result, tool_call=tool_call, tool_to_invoke=weather_tool)
-
-    def test_string_conversion_error_does_not_raise_exception(self):
-        weather_tool = Tool(
-            name="weather_tool",
-            description="Provides weather information for a given location.",
-            parameters=weather_parameters,
-            function=weather_function,
-            # Pass custom handler that will throw an error when trying to convert tool_result
-            outputs_to_string={"handler": lambda x: json.dumps(x)},
-        )
-        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=False)
-
-        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
-
-        tool_result = datetime.datetime.now()
-        tool_message = invoker._prepare_tool_result_message(
-            result=tool_result, tool_call=tool_call, tool_to_invoke=weather_tool
+            description="Provides weather information for a given location.",
+            parameters=weather_parameters,
+            function=weather_function,
         )
+        state = State(schema={"location": {"type": str}}, data={"location": "Berlin"})
+        args = invoker._inject_state_args(tool=weather_tool, llm_args={"location": "Paris"}, state=state)
+        assert args == {"location": "Paris"}
 
-        assert tool_message.tool_call_results[0].error
-        assert "Failed to convert" in tool_message.tool_call_results[0].result
 
+class TestToolInvokerSerde:
     def test_to_dict(self, invoker, weather_tool):
         data = invoker.to_dict()
         assert data == {
@@ -603,6 +322,55 @@ def test_serde_in_pipeline(self, invoker, monkeypatch):
         new_pipeline = Pipeline.loads(pipeline_yaml)
         assert new_pipeline == pipeline
 
+
+class TestToolInvokerRun:
+    def test_run_with_streaming_callback_finish_reason(self, invoker):
+        streaming_chunks = []
+
+        def streaming_callback(chunk: StreamingChunk) -> None:
+            streaming_chunks.append(chunk)
+
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = invoker.run(messages=[message], streaming_callback=streaming_callback)
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 1
+
+        # Check that we received streaming chunks
+        assert len(streaming_chunks) >= 2  # At least one for tool result and one for finish reason
+
+        # The last chunk should have finish_reason set to "tool_call_results"
+        final_chunk = streaming_chunks[-1]
+        assert final_chunk.finish_reason == "tool_call_results"
+        assert final_chunk.meta["finish_reason"] == "tool_call_results"
+        assert final_chunk.content == ""
+
+    @pytest.mark.asyncio
+    async def test_run_async_with_streaming_callback_finish_reason(self, weather_tool):
+        streaming_chunks = []
+
+        async def streaming_callback(chunk: StreamingChunk) -> None:
+            streaming_chunks.append(chunk)
+
+        tool_invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True, convert_result_to_json_string=False)
+
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = await tool_invoker.run_async(messages=[message], streaming_callback=streaming_callback)
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 1
+
+        # Check that we received streaming chunks
+        assert len(streaming_chunks) >= 2  # At least one for tool result and one for finish reason
+
+        # The last chunk should have finish_reason set to "tool_call_results"
+        final_chunk = streaming_chunks[-1]
+        assert final_chunk.finish_reason == "tool_call_results"
+        assert final_chunk.meta["finish_reason"] == "tool_call_results"
+        assert final_chunk.content == ""
+
     def test_enable_streaming_callback_passthrough(self, monkeypatch):
         monkeypatch.setenv("OPENAI_API_KEY", "test-key")
         llm_tool = ComponentTool(
@@ -660,23 +428,115 @@ def test_enable_streaming_callback_passthrough_runtime(self, monkeypatch):
             )
             mock_run.assert_called_once_with(messages=[ChatMessage.from_user(text="Hello!")])
 
+    def test_run_no_messages(self, invoker):
+        result = invoker.run(messages=[])
+        assert result["tool_messages"] == []
+
+    def test_run_no_tool_calls(self, invoker):
+        user_message = ChatMessage.from_user(text="Hello!")
+        assistant_message = ChatMessage.from_assistant(text="How can I help you?")
+
+        result = invoker.run(messages=[user_message, assistant_message])
+        assert result["tool_messages"] == []
+
+    def test_run_multiple_tool_calls(self, invoker):
+        tool_calls = [
+            ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"}),
+            ToolCall(tool_name="weather_tool", arguments={"location": "Paris"}),
+            ToolCall(tool_name="weather_tool", arguments={"location": "Rome"}),
+        ]
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        result = invoker.run(messages=[message])
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 3
+
+        for i, tool_message in enumerate(result["tool_messages"]):
+            assert isinstance(tool_message, ChatMessage)
+            assert tool_message.is_from(ChatRole.TOOL)
+
+            assert tool_message.tool_call_results
+            tool_call_result = tool_message.tool_call_result
+
+            assert isinstance(tool_call_result, ToolCallResult)
+            assert not tool_call_result.error
+            assert tool_call_result.origin == tool_calls[i]
+
+    def test_run_tool_calls_with_empty_args(self):
+        hello_world_tool = Tool(
+            name="hello_world",
+            description="A tool that returns a greeting.",
+            parameters={"type": "object", "properties": {}},
+            function=lambda: "Hello, world!",
+        )
+        invoker = ToolInvoker(tools=[hello_world_tool])
+
+        tool_call = ToolCall(tool_name="hello_world", arguments={})
+        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = invoker.run(messages=[tool_call_message])
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 1
+
+        tool_message = result["tool_messages"][0]
+        assert isinstance(tool_message, ChatMessage)
+        assert tool_message.is_from(ChatRole.TOOL)
+
+        assert tool_message.tool_call_results
+        tool_call_result = tool_message.tool_call_result
+
+        assert isinstance(tool_call_result, ToolCallResult)
+        assert not tool_call_result.error
+
+        assert tool_call_result.result == "Hello, world!"
+
+    def test_run_with_tools_override(self, weather_tool, faulty_tool):
+        """Tests that tools passed to run override the tools passed in init"""
+        invoker = ToolInvoker(tools=[faulty_tool])
+        assert invoker._tools_with_names == {"faulty_tool": faulty_tool}
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = invoker.run(messages=[message], tools=[weather_tool])
+
+        tool_message = result["tool_messages"][0]
+        tool_call_result = tool_message.tool_call_result
+        assert not tool_call_result.error
+        assert tool_call_result.result == str({"weather": "mostly sunny", "temperature": 7, "unit": "celsius"})
+        assert tool_call_result.origin == tool_call
+
+    @pytest.mark.asyncio
+    async def test_run_async_with_tools_override(self, weather_tool, faulty_tool):
+        """Tests that tools passed to run_async override the tools passed in init"""
+        invoker = ToolInvoker(tools=[faulty_tool])
+        assert invoker._tools_with_names == {"faulty_tool": faulty_tool}
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+        message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = await invoker.run_async(messages=[message], tools=[weather_tool])
+        tool_message = result["tool_messages"][0]
+        tool_call_result = tool_message.tool_call_result
+        assert not tool_call_result.error
+        assert tool_call_result.result == str({"weather": "mostly sunny", "temperature": 7, "unit": "celsius"})
+        assert tool_call_result.origin == tool_call
+
     def test_parallel_tool_calling_with_state_updates(self):
         """Test that parallel tool execution with state updates works correctly with the state lock."""
         # Create a shared counter variable to simulate a state value that gets updated
         execution_log = []
 
         def function_1():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_1_executed")
             return {"counter": 1, "tool_name": "tool_1"}
 
         def function_2():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_2_executed")
             return {"counter": 2, "tool_name": "tool_2"}
 
         def function_3():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_3_executed")
             return {"counter": 3, "tool_name": "tool_3"}
 
@@ -737,17 +597,17 @@ async def test_async_parallel_tool_calling_with_state_updates(self):
         execution_log = []
 
         def function_1():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_1_executed")
             return {"counter": 1, "tool_name": "tool_1"}
 
         def function_2():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_2_executed")
             return {"counter": 2, "tool_name": "tool_2"}
 
         def function_3():
-            time.sleep(0.1)
+            time.sleep(0.01)
             execution_log.append("tool_3_executed")
             return {"counter": 3, "tool_name": "tool_3"}
 
@@ -851,7 +711,203 @@ async def streaming_callback(chunk: StreamingChunk) -> None:
         assert len(result_2["tool_messages"]) == 3
 
 
-class TestMergeToolOutputs:
+class TestToolInvokerErrorHandling:
+    def test_tool_not_found_error(self, invoker):
+        tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
+        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        with pytest.raises(ToolNotFoundException):
+            invoker.run(messages=[tool_call_message])
+
+    def test_tool_not_found_does_not_raise_exception(self, weather_tool):
+        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=False, convert_result_to_json_string=False)
+
+        tool_call = ToolCall(tool_name="non_existent_tool", arguments={"location": "Berlin"})
+        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = invoker.run(messages=[tool_call_message])
+        tool_message = result["tool_messages"][0]
+
+        assert tool_message.tool_call_results[0].error
+        assert "not found" in tool_message.tool_call_results[0].result
+
+    def test_tool_invocation_error(self, faulty_invoker):
+        tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
+        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        with pytest.raises(ToolInvocationError):
+            faulty_invoker.run(messages=[tool_call_message])
+
+    def test_tool_invocation_error_does_not_raise_exception(self, faulty_tool):
+        faulty_invoker = ToolInvoker(tools=[faulty_tool], raise_on_failure=False, convert_result_to_json_string=False)
+
+        tool_call = ToolCall(tool_name="faulty_tool", arguments={"location": "Berlin"})
+        tool_call_message = ChatMessage.from_assistant(tool_calls=[tool_call])
+
+        result = faulty_invoker.run(messages=[tool_call_message])
+        tool_message = result["tool_messages"][0]
+        assert tool_message.tool_call_results[0].error
+        assert "Failed to invoke" in tool_message.tool_call_results[0].result
+
+    def test_string_conversion_error(self):
+        weather_tool = Tool(
+            name="weather_tool",
+            description="Provides weather information for a given location.",
+            parameters=weather_parameters,
+            function=weather_function,
+            # Pass custom handler that will throw an error when trying to convert tool_result
+            outputs_to_string={"handler": lambda x: json.dumps(x)},
+        )
+        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=True)
+
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+
+        tool_result = datetime.datetime.now()
+        with pytest.raises(StringConversionError):
+            invoker._prepare_tool_result_message(result=tool_result, tool_call=tool_call, tool_to_invoke=weather_tool)
+
+    def test_string_conversion_error_does_not_raise_exception(self):
+        weather_tool = Tool(
+            name="weather_tool",
+            description="Provides weather information for a given location.",
+            parameters=weather_parameters,
+            function=weather_function,
+            # Pass custom handler that will throw an error when trying to convert tool_result
+            outputs_to_string={"handler": lambda x: json.dumps(x)},
+        )
+        invoker = ToolInvoker(tools=[weather_tool], raise_on_failure=False)
+
+        tool_call = ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})
+
+        tool_result = datetime.datetime.now()
+        tool_message = invoker._prepare_tool_result_message(
+            result=tool_result, tool_call=tool_call, tool_to_invoke=weather_tool
+        )
+
+        assert tool_message.tool_call_results[0].error
+        assert "Failed to convert" in tool_message.tool_call_results[0].result
+
+    def test_run_state_merge_error_handled_gracefully(self, weather_tool_with_outputs_to_state):
+        class ProblematicState(State):
+            def set(self, key: str, value: Any, handler_override=None):
+                # Simulate a State error during merging
+                raise ValueError("State set operation failed")
+
+        state = ProblematicState(schema={"test_key": {"type": str}})
+        invoker = ToolInvoker(tools=[weather_tool_with_outputs_to_state], raise_on_failure=False)
+
+        tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        result = invoker.run(messages=[message], state=state)
+
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 1
+        assert result["tool_messages"][0].tool_call_results[0].error is True
+        assert (
+            "Failed to merge tool outputs from tool weather_tool into State"
+            in result["tool_messages"][0].tool_call_results[0].result
+        )
+
+    def test_run_state_merge_error_raises_when_configured(self, weather_tool_with_outputs_to_state):
+        class ProblematicState(State):
+            def set(self, key: str, value: Any, handler_override=None):
+                # Simulate a State error during merging
+                raise ValueError("State set operation failed")
+
+        state = ProblematicState(schema={"test_key": {"type": str}})
+        invoker = ToolInvoker(tools=[weather_tool_with_outputs_to_state], raise_on_failure=True)
+
+        tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        with pytest.raises(ToolOutputMergeError, match="Failed to merge"):
+            invoker.run(messages=[message], state=state)
+
+    @pytest.mark.asyncio
+    async def test_run_async_state_merge_error_handled_gracefully(self, weather_tool_with_outputs_to_state):
+        class ProblematicState(State):
+            def set(self, key: str, value: Any, handler_override=None):
+                # Simulate a State error during merging
+                raise ValueError("State set operation failed")
+
+        state = ProblematicState(schema={"test_key": {"type": str}})
+        invoker = ToolInvoker(tools=[weather_tool_with_outputs_to_state], raise_on_failure=False)
+
+        tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        result = await invoker.run_async(messages=[message], state=state)
+
+        assert "tool_messages" in result
+        assert len(result["tool_messages"]) == 1
+        assert result["tool_messages"][0].tool_call_results[0].error is True
+        assert (
+            "Failed to merge tool outputs from tool weather_tool into State"
+            in result["tool_messages"][0].tool_call_results[0].result
+        )
+
+    @pytest.mark.asyncio
+    async def test_run_async_state_merge_error_raises_when_configured(self, weather_tool_with_outputs_to_state):
+        class ProblematicState(State):
+            def set(self, key: str, value: Any, handler_override=None):
+                # Simulate a State error during merging
+                raise ValueError("State set operation failed")
+
+        state = ProblematicState(schema={"test_key": {"type": str}})
+        invoker = ToolInvoker(tools=[weather_tool_with_outputs_to_state], raise_on_failure=True)
+
+        tool_calls = [ToolCall(tool_name="weather_tool", arguments={"location": "Berlin"})]
+        message = ChatMessage.from_assistant(tool_calls=tool_calls)
+
+        with pytest.raises(ToolOutputMergeError, match="Failed to merge"):
+            await invoker.run_async(messages=[message], state=state)
+
+
+class TestToolInvokerUtilities:
+    def test_default_output_to_string_handler_basic_types(self, weather_tool):
+        invoker = ToolInvoker(tools=[weather_tool], convert_result_to_json_string=False)
+
+        assert invoker._default_output_to_string_handler("hello") == "hello"
+        assert invoker._default_output_to_string_handler(42) == "42"
+        assert invoker._default_output_to_string_handler(3.14) == "3.14"
+        assert invoker._default_output_to_string_handler(True) == "True"
+        assert invoker._default_output_to_string_handler(None) == "None"
+
+        assert invoker._default_output_to_string_handler([1, 2, 3]) == "[1, 2, 3]"
+        assert invoker._default_output_to_string_handler({"key": "value"}) == "{'key': 'value'}"
+
+    def test_default_output_to_string_handler_json_string_mode(self, weather_tool):
+        invoker = ToolInvoker(tools=[weather_tool], convert_result_to_json_string=True)
+
+        assert invoker._default_output_to_string_handler("hello") == '"hello"'
+        assert invoker._default_output_to_string_handler(42) == "42"
+        assert invoker._default_output_to_string_handler(True) == "true"
+        assert invoker._default_output_to_string_handler(None) == "null"
+
+        assert invoker._default_output_to_string_handler([1, 2, 3]) == "[1, 2, 3]"
+        assert invoker._default_output_to_string_handler({"key": "value"}) == '{"key": "value"}'
+
+        assert invoker._default_output_to_string_handler("Hello 🌍") == '"Hello 🌍"'
+
+    def test_default_output_to_string_handler_with_serializable_objects(self, weather_tool):
+        invoker = ToolInvoker(tools=[weather_tool], convert_result_to_json_string=False)
+
+        # Create a mock object with to_dict method
+        class MockObject:
+            def __init__(self, value):
+                self.value = value
+
+            def to_dict(self):
+                return {"value": self.value}
+
+        mock_obj = MockObject("test_value")
+        result = invoker._default_output_to_string_handler(mock_obj)
+
+        # Should convert to string representation of the dict
+        assert "test_value" in result
+        assert "value" in result
+
     def test_merge_tool_outputs_result_not_a_dict(self, weather_tool):
         invoker = ToolInvoker(tools=[weather_tool])
         state = State(schema={"weather": {"type": str}})