Repository: deepset-ai/haystack

Issue:
Bug: Issue deserializing an Agent with ComponentTool in tools
**Describe the bug**

I created an agent with 2 tools using ComponentTool with SerperDevWebSearch components. I'm able to run the agent and dump the whole pipeline into a string representation using dumps function.

However, when I try to load it back using the string representaton. It is throwing an error.

**Error message**
```python
Traceback (most recent call last):
  File "/env/lib/python3.10/site-packages/haystack/core/pipeline/base.py", line 225, in from_dict
    instance = component_from_dict(component_class, component_data, name, callbacks)
  File "/env/lib/python3.10/site-packages/haystack/core/serialization.py", line 166, in component_from_dict
    return do_from_dict()
  File "/env/lib/python3.10/site-packages/haystack/core/serialization.py", line 161, in do_from_dict
    return cls.from_dict(data)
  File "/env/lib/python3.10/site-packages/haystack/components/agents/agent.py", line 200, in from_dict
    deserialize_tools_or_toolset_inplace(init_params, key="tools")
  File "/env/lib/python3.10/site-packages/haystack/tools/serde_utils.py", line 71, in deserialize_tools_or_toolset_inplace
    deserialized_tools.append(tool_class.from_dict(tool))
  File "/env/lib/python3.10/site-packages/haystack/tools/component_tool.py", line 253, in from_dict
    and inner_data["outputs_to_string"].get("handler") is not None
AttributeError: 'str' object has no attribute 'get'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/research_agent_serialize.py", line 67, in <module>
    load_pipeline = Pipeline.loads(pipeline_dumps)
  File "/env/lib/python3.10/site-packages/haystack/core/pipeline/base.py", line 297, in loads
    return cls.from_dict(deserialized_data, callbacks)
  File "/env/lib/python3.10/site-packages/haystack/core/pipeline/base.py", line 234, in from_dict
    raise DeserializationError(msg) from e
haystack.core.errors.DeserializationError: Couldn't deserialize component 'research_agent' of class 'Agent' with the following data: {'init_parameters': {'chat_generator': <haystack.components.generators.chat.openai.OpenAIChatGenerator object at 0x747690f10310>
Inputs:
  - messages: List[ChatMessage]
  - streaming_callback: Union[Callable[], Callable[, Awaitable[]]]
  - generation_kwargs: Optional[Dict[str, Any]]
  - tools: Union[List[Tool], Toolset]
  - tools_strict: Optional[bool]
Outputs:
  - replies: List[ChatMessage], 'exit_conditions': ['text'], 'max_agent_steps': 100, 'raise_on_tool_invocation_failure': False, 'state_schema': {}, 'streaming_callback': <function print_streaming_chunk at 0x7476b4efed40>, 'system_prompt': '\n    You are a research agent that can find information on web or specifically on wikipedia.\n    Use wiki_search tool if you need facts and use web_search tool for latest news on topics.\n    Use one tool at a time. Try different queries if you need more information.\n    Only use the retrieved context, do not use your own knowledge.\n    Summarize the all retrieved information before returning response to the user.\n    ', 'tools': [{'data': {'component': {'init_parameters': {'allowed_domains': None, 'api_key': EnvVarSecret(_env_vars=('SERPERDEV_API_KEY',), _strict=True, _type=<SecretType.ENV_VAR: 'env_var'>), 'search_params': {}, 'top_k': 5}, 'type': 'haystack.components.websearch.serper_dev.SerperDevWebSearch'}, 'description': 'Search the web', 'inputs_from_state': None, 'name': 'web_search', 'outputs_to_state': None, 'outputs_to_string': '__main__.doc_to_string', 'parameters': None}, 'type': 'haystack.tools.component_tool.ComponentTool'}, {'data': {'component': {'init_parameters': {'allowed_domains': ['https://www.wikipedia.org/', 'https://en.wikipedia.org'], 'api_key': {'env_vars': ['SERPERDEV_API_KEY'], 'strict': True, 'type': 'env_var'}, 'search_params': {}, 'top_k': 5}, 'type': 'haystack.components.websearch.serper_dev.SerperDevWebSearch'}, 'description': 'Search Wikipedia', 'inputs_from_state': None, 'name': 'wiki_search', 'outputs_to_state': None, 'outputs_to_string': '__main__.doc_to_string', 'parameters': None}, 'type': 'haystack.tools.component_tool.ComponentTool'}]}, 'type': 'haystack.components.agents.agent.Agent'}. Possible reasons include malformed serialized data, mismatch between the serialized component and the loaded one (due to a breaking change, see https://github.com/deepset-ai/haystack/releases), etc.
```

**Expected behavior**
It should be able to serialize and deserialize an agent with ComponentTool in its tools without any error

**To Reproduce**
**_Attached below is the code:_**
```python
from haystack.components.agents import Agent
from haystack.dataclasses import ChatMessage
from haystack.components.generators.utils import print_streaming_chunk
from haystack.components.generators.chat import OpenAIChatGenerator

from haystack import Pipeline
from haystack.tools import ComponentTool

from haystack.components.websearch import SerperDevWebSearch

def doc_to_string(documents) -> str:
    """
    Handles the tool output before conversion to ChatMessage.
    """
    result_str = ""
    for document in documents:
        result_str += f"File Content for {document.meta['link']}\n\n {document.content}"

    if len(result_str) > 150_000:  # trim if the content is too large
        result_str = result_str[:150_000] + "...(large file can't be fully displayed)"

    return result_str

web_search = ComponentTool(
    component=SerperDevWebSearch(top_k=5),
    name="web_search",
    description="Search the web",
    outputs_to_string={"source": "documents", "handler": doc_to_string},
)

wiki_search = ComponentTool(
    component=SerperDevWebSearch(top_k=5, allowed_domains=["https://www.wikipedia.org/", "https://en.wikipedia.org"]),
    name="wiki_search",
    description="Search Wikipedia",
    outputs_to_string={"source": "documents", "handler": doc_to_string},
)

research_agent = Agent(
    chat_generator=OpenAIChatGenerator(
        model="gpt-4.1",
    ),
    system_prompt="""
    You are a research agent that can find information on web or specifically on wikipedia.
    Use wiki_search tool if you need facts and use web_search tool for latest news on topics.
    Use one tool at a time. Try different queries if you need more information.
    Only use the retrieved context, do not use your own knowledge.
    Summarize the all retrieved information before returning response to the user.
    """,
    tools=[web_search, wiki_search],
    streaming_callback=print_streaming_chunk,
)

if __name__ == "__main__":
    result = research_agent.run(
        messages=[ChatMessage.from_user("Can you tell me about Florence Nightingale's contributions to nursery?")]
    )

    print("Final Answer:", result["last_message"].text)

    pipeline = Pipeline()
    pipeline.add_component("research_agent", research_agent)

    pipeline_dumps = pipeline.dumps()
    print(f" pipeline dumps: {pipeline_dumps}")

    load_pipeline = Pipeline.loads(pipeline_dumps)
```

**FAQ Check**
- [v] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?

**System:**
 - OS: **Linux**
 - GPU/CPU:
 - Haystack version (commit or version number): **2.14.1**
 - DocumentStore:
 - Reader:
 - Retriever:



Discussion:

Patch:
diff --git a/haystack/tools/component_tool.py b/haystack/tools/component_tool.py
index 5198ff0daa..01a0851f18 100644
--- a/haystack/tools/component_tool.py
+++ b/haystack/tools/component_tool.py
@@ -206,14 +206,14 @@ def to_dict(self) -> Dict[str, Any]:
         """
         serialized_component = component_to_dict(obj=self._component, name=self.name)
 
-        serialized = {
+        serialized: Dict[str, Any] = {
             "component": serialized_component,
             "name": self.name,
             "description": self.description,
             "parameters": self._unresolved_parameters,
-            "outputs_to_string": self.outputs_to_string,
             "inputs_from_state": self.inputs_from_state,
-            "outputs_to_state": self.outputs_to_state,
+            # This is soft-copied as to not modify the attributes in place
+            "outputs_to_state": self.outputs_to_state.copy() if self.outputs_to_state else None,
         }
 
         if self.outputs_to_state is not None:
@@ -226,7 +226,11 @@ def to_dict(self) -> Dict[str, Any]:
             serialized["outputs_to_state"] = serialized_outputs
 
         if self.outputs_to_string is not None and self.outputs_to_string.get("handler") is not None:
-            serialized["outputs_to_string"] = serialize_callable(self.outputs_to_string["handler"])
+            # This is soft-copied as to not modify the attributes in place
+            serialized["outputs_to_string"] = self.outputs_to_string.copy()
+            serialized["outputs_to_string"]["handler"] = serialize_callable(self.outputs_to_string["handler"])
+        else:
+            serialized["outputs_to_string"] = None
 
         return {"type": generate_qualified_class_name(type(self)), "data": serialized}
 
diff --git a/haystack/tools/tool.py b/haystack/tools/tool.py
index c1d7670448..39e3cc1431 100644
--- a/haystack/tools/tool.py
+++ b/haystack/tools/tool.py
@@ -122,7 +122,7 @@ def to_dict(self) -> Dict[str, Any]:
             data["outputs_to_state"] = serialized_outputs
 
         if self.outputs_to_string is not None and self.outputs_to_string.get("handler") is not None:
-            data["outputs_to_string"] = serialize_callable(self.outputs_to_string["handler"])
+            data["outputs_to_string"]["handler"] = serialize_callable(self.outputs_to_string["handler"])
 
         return {"type": generate_qualified_class_name(type(self)), "data": data}
 
diff --git a/releasenotes/notes/fix-serialization-tool-and-comp-tool-017caac6bb56e744.yaml b/releasenotes/notes/fix-serialization-tool-and-comp-tool-017caac6bb56e744.yaml
new file mode 100644
index 0000000000..55f86ec310
--- /dev/null
+++ b/releasenotes/notes/fix-serialization-tool-and-comp-tool-017caac6bb56e744.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+  - |
+    Fix the serialization of ComponentTool and Tool when specifying outputs_to_string. Previously an error occurred on deserialization right after serializing if outputs_to_string is not None.


Test patch:
diff --git a/test/tools/test_component_tool.py b/test/tools/test_component_tool.py
index 470891349d..2db721c442 100644
--- a/test/tools/test_component_tool.py
+++ b/test/tools/test_component_tool.py
@@ -60,6 +60,10 @@ def run(self, text: str) -> Dict[str, str]:
         return {"reply": f"Hello, {text}!"}
 
 
+def reply_formatter(input_text: str) -> str:
+    return f"Formatted reply: {input_text}"
+
+
 @dataclass
 class User:
     """A simple user dataclass."""
@@ -593,24 +597,33 @@ def test_component_tool_serde(self):
             component=SimpleComponent(),
             name="simple_tool",
             description="A simple tool",
+            outputs_to_string={"source": "reply", "handler": reply_formatter},
             inputs_from_state={"test": "input"},
             outputs_to_state={"output": {"source": "out", "handler": output_handler}},
         )
 
         # Test serialization
+        expected_tool_dict = {
+            "type": "haystack.tools.component_tool.ComponentTool",
+            "data": {
+                "component": {"type": "test_component_tool.SimpleComponent", "init_parameters": {}},
+                "name": "simple_tool",
+                "description": "A simple tool",
+                "parameters": None,
+                "outputs_to_string": {"source": "reply", "handler": "test_component_tool.reply_formatter"},
+                "inputs_from_state": {"test": "input"},
+                "outputs_to_state": {"output": {"source": "out", "handler": "test_component_tool.output_handler"}},
+            },
+        }
         tool_dict = tool.to_dict()
-        assert tool_dict["type"] == "haystack.tools.component_tool.ComponentTool"
-        assert tool_dict["data"]["name"] == "simple_tool"
-        assert tool_dict["data"]["description"] == "A simple tool"
-        assert "component" in tool_dict["data"]
-        assert tool_dict["data"]["inputs_from_state"] == {"test": "input"}
-        assert tool_dict["data"]["outputs_to_state"]["output"]["handler"] == "test_component_tool.output_handler"
+        assert tool_dict == expected_tool_dict
 
         # Test deserialization
-        new_tool = ComponentTool.from_dict(tool_dict)
+        new_tool = ComponentTool.from_dict(expected_tool_dict)
         assert new_tool.name == tool.name
         assert new_tool.description == tool.description
         assert new_tool.parameters == tool.parameters
+        assert new_tool.outputs_to_string == tool.outputs_to_string
         assert new_tool.inputs_from_state == tool.inputs_from_state
         assert new_tool.outputs_to_state == tool.outputs_to_state
         assert isinstance(new_tool._component, SimpleComponent)
diff --git a/test/tools/test_tool.py b/test/tools/test_tool.py
index 49d01aa0e8..352cd58719 100644
--- a/test/tools/test_tool.py
+++ b/test/tools/test_tool.py
@@ -13,6 +13,10 @@ def get_weather_report(city: str) -> str:
     return f"Weather report for {city}: 20°C, sunny"
 
 
+def format_string(text: str) -> str:
+    return f"Formatted: {text}"
+
+
 parameters = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
 
 
@@ -84,6 +88,8 @@ def test_to_dict(self):
             description="Get weather report",
             parameters=parameters,
             function=get_weather_report,
+            outputs_to_string={"handler": format_string},
+            inputs_from_state={"state_key": "tool_input_key"},
             outputs_to_state={"documents": {"handler": get_weather_report, "source": "docs"}},
         )
 
@@ -94,8 +100,8 @@ def test_to_dict(self):
                 "description": "Get weather report",
                 "parameters": parameters,
                 "function": "test_tool.get_weather_report",
-                "outputs_to_string": None,
-                "inputs_from_state": None,
+                "outputs_to_string": {"handler": "test_tool.format_string"},
+                "inputs_from_state": {"state_key": "tool_input_key"},
                 "outputs_to_state": {"documents": {"source": "docs", "handler": "test_tool.get_weather_report"}},
             },
         }
@@ -108,6 +114,8 @@ def test_from_dict(self):
                 "description": "Get weather report",
                 "parameters": parameters,
                 "function": "test_tool.get_weather_report",
+                "outputs_to_string": {"handler": "test_tool.format_string"},
+                "inputs_from_state": {"state_key": "tool_input_key"},
                 "outputs_to_state": {"documents": {"source": "docs", "handler": "test_tool.get_weather_report"}},
             },
         }
@@ -118,8 +126,9 @@ def test_from_dict(self):
         assert tool.description == "Get weather report"
         assert tool.parameters == parameters
         assert tool.function == get_weather_report
-        assert tool.outputs_to_state["documents"]["source"] == "docs"
-        assert tool.outputs_to_state["documents"]["handler"] == get_weather_report
+        assert tool.outputs_to_string == {"handler": format_string}
+        assert tool.inputs_from_state == {"state_key": "tool_input_key"}
+        assert tool.outputs_to_state == {"documents": {"source": "docs", "handler": get_weather_report}}
 
 
 def test_check_duplicate_tool_names():