Repository: deepset-ai/haystack
Pull request #9112: feat: integrate Agent from haystack-experimental
Author: login: julian-risch
Labels: name: type:documentation
description: Improvements on the docs
name: topic:tests
Created: 2025-03-25T13:53:37Z | Merged: 2025-03-28T13:23:39Z

### Related Issues

- Related to https://github.com/deepset-ai/haystack-experimental/issues/250#issue-2938631195

### Proposed Changes:

- Copying over Agent respective tests from haystack-experimental without any changes except for:
- Fixing a module path in a serialization test. The function defined for the test is in the test file itself and the module path we check for in the serialization test needs to reflect that.
- Add Agent to pydoc: https://github.com/deepset-ai/haystack/tree/move-agent/docs/pydoc/config
- Use ChatGenerator protocol instead of limiting to Union of OpenAIChatGenerator and AnthropicChatGenerator
- Update from_dict similar to how it was done in LLMMetadataExtractor https://github.com/deepset-ai/haystack/pull/9099

### How did you test it?

CI

### Notes for the reviewer

State and changes to Tools were merged first in a separate PR: https://github.com/deepset-ai/haystack/pull/9113

The reason why I decided to add the usage of ChatGenerator protocol directly in this PR is because mypy errors came up that we don't actually need to fix if we use the ChatGenerator protocol right away.

I thought about adding a check and raising a TypeError in the init in case the chat generator doesn't accept tools as parameter. We could do that in a separate PR if you agree.

### Checklist

- I have read the [contributors guidelines](https://github.com/deepset-ai/haystack/blob/main/CONTRIBUTING.md) and the [code of conduct](https://github.com/deepset-ai/haystack/blob/main/code_of_conduct.txt)
- I have updated the related issue with new insights and changes
- I added unit tests and updated the docstrings
- I've used one of the [conventional commit types](https://www.conventionalcommits.org/en/v1.0.0/) for my PR title: `fix:`, `feat:`, `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:` and added `!` in case the PR includes breaking changes.
- I documented my code
- I ran [pre-commit hooks](https://github.com/deepset-ai/haystack/blob/main/CONTRIBUTING.md#installation) and fixed any issue



Diff:
diff --git a/docs/pydoc/config/agents_api.yml b/docs/pydoc/config/agents_api.yml
new file mode 100644
index 0000000000..5d10534b83
--- /dev/null
+++ b/docs/pydoc/config/agents_api.yml
@@ -0,0 +1,27 @@
+loaders:
+  - type: haystack_pydoc_tools.loaders.CustomPythonLoader
+    search_path: [../../../haystack/components/agents]
+    modules: ["agent"]
+    ignore_when_discovered: ["__init__"]
+processors:
+  - type: filter
+    expression:
+    documented_only: true
+    do_not_filter_modules: false
+    skip_empty_modules: true
+  - type: smart
+  - type: crossref
+renderer:
+  type: haystack_pydoc_tools.renderers.ReadmeCoreRenderer
+  excerpt: Tool-using agents with provider-agnostic chat model support.
+  category_slug: haystack-api
+  title: Agents
+  slug: agents-api
+  order: 2
+  markdown:
+    descriptive_class_title: false
+    classdef_code_block: false
+    descriptive_module_title: true
+    add_method_class_prefix: true
+    add_member_class_prefix: false
+    filename: agents_api.md
diff --git a/haystack/components/agents/__init__.py b/haystack/components/agents/__init__.py
new file mode 100644
index 0000000000..e93e5ef8cb
--- /dev/null
+++ b/haystack/components/agents/__init__.py
@@ -0,0 +1,16 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import sys
+from typing import TYPE_CHECKING
+
+from lazy_imports import LazyImporter
+
+_import_structure = {"agent": ["Agent"]}
+
+if TYPE_CHECKING:
+    from .agent import Agent
+
+else:
+    sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
diff --git a/haystack/components/agents/agent.py b/haystack/components/agents/agent.py
new file mode 100644
index 0000000000..eb6366474c
--- /dev/null
+++ b/haystack/components/agents/agent.py
@@ -0,0 +1,222 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.serialization import import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use. It must support tools.
+        :param tools: List of Tool objects available to the agent
+        :param system_prompt: System prompt for the agent.
+        :param exit_condition: Either "text" if the agent should return when it generates a message without tool calls
+            or the name of a tool that will cause the agent to return once the tool was executed
+        :param state_schema: The schema for the runtime state used by the tools.
+        :param max_runs_per_component: Maximum number of runs per component. Agent will raise an exception if a
+            component exceeds the maximum number of runs per component.
+        :param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
+            If set to False, the exception will be turned into a chat message and passed to the LLM.
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        """
+        valid_exits = ["text"] + [tool.name for tool in tools or []]
+        if exit_condition not in valid_exits:
+            raise ValueError(f"Exit condition must be one of {valid_exits}")
+
+        if state_schema is not None:
+            _validate_schema(state_schema)
+        self.state_schema = state_schema or {}
+
+        self.chat_generator = chat_generator
+        self.tools = tools or []
+        self.system_prompt = system_prompt
+        self.exit_condition = exit_condition
+        self.max_runs_per_component = max_runs_per_component
+        self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
+        self.streaming_callback = streaming_callback
+
+        output_types = {"messages": List[ChatMessage]}
+        for param, config in self.state_schema.items():
+            component.set_input_type(self, name=param, type=config["type"], default=None)
+            output_types[param] = config["type"]
+        component.set_output_types(self, **output_types)
+
+        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+
+        self._is_warmed_up = False
+
+    def warm_up(self) -> None:
+        """
+        Warm up the Agent.
+        """
+        if not self._is_warmed_up:
+            if hasattr(self.chat_generator, "warm_up"):
+                self.chat_generator.warm_up()
+            self._is_warmed_up = True
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serialize the component to a dictionary.
+
+        :return: Dictionary with serialized data
+        """
+        if self.streaming_callback is not None:
+            streaming_callback = serialize_callable(self.streaming_callback)
+        else:
+            streaming_callback = None
+
+        return default_to_dict(
+            self,
+            chat_generator=self.chat_generator.to_dict(),
+            tools=[t.to_dict() for t in self.tools],
+            system_prompt=self.system_prompt,
+            exit_condition=self.exit_condition,
+            state_schema=_schema_to_dict(self.state_schema),
+            max_runs_per_component=self.max_runs_per_component,
+            raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
+            streaming_callback=streaming_callback,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Agent":
+        """
+        Deserialize the agent from a dictionary.
+
+        :param data: Dictionary to deserialize from
+        :return: Deserialized agent
+        """
+        init_params = data.get("init_parameters", {})
+
+        chat_generator_class = import_class_by_name(init_params["chat_generator"]["type"])
+        assert hasattr(chat_generator_class, "from_dict")  # we know but mypy doesn't
+        chat_generator_instance = chat_generator_class.from_dict(init_params["chat_generator"])
+        data["init_parameters"]["chat_generator"] = chat_generator_instance
+
+        if "state_schema" in init_params:
+            init_params["state_schema"] = _schema_from_dict(init_params["state_schema"])
+
+        if init_params.get("streaming_callback") is not None:
+            init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
+
+        deserialize_tools_inplace(init_params, key="tools")
+
+        return default_from_dict(cls, data)
+
+    def run(
+        self, messages: List[ChatMessage], streaming_callback: Optional[SyncStreamingCallbackT] = None, **kwargs
+    ) -> Dict[str, Any]:
+        """
+        Process messages and execute tools until the exit condition is met.
+
+        :param messages: List of chat messages to process
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        :param kwargs: Additional data to pass to the State schema used by the Agent.
+            The keys must match the schema defined in the Agent's `state_schema`.
+        :return: Dictionary containing messages and outputs matching the defined output types
+        """
+        if not self._is_warmed_up and hasattr(self.chat_generator, "warm_up"):
+            raise RuntimeError("The component Agent wasn't warmed up. Run 'warm_up()' before calling 'run()'.")
+
+        state = State(schema=self.state_schema, data=kwargs)
+
+        if self.system_prompt is not None:
+            messages = [ChatMessage.from_system(self.system_prompt)] + messages
+
+        generator_inputs: Dict[str, Any] = {"tools": self.tools}
+
+        selected_callback = streaming_callback or self.streaming_callback
+        if selected_callback is not None:
+            generator_inputs["streaming_callback"] = selected_callback
+
+        # Repeat until the exit condition is met
+        counter = 0
+        while counter < self.max_runs_per_component:
+            # 1. Call the ChatGenerator
+            llm_messages = self.chat_generator.run(messages=messages, **generator_inputs)["replies"]
+
+            # TODO Possible for LLM to return multiple messages (e.g. multiple tool calls)
+            #      Would a better check be to see if any of the messages contain a tool call?
+            # 2. Check if the LLM response contains a tool call
+            if llm_messages[0].tool_call is None:
+                return {"messages": messages + llm_messages, **state.data}
+
+            # 3. Call the ToolInvoker
+            # We only send the messages from the LLM to the tool invoker
+            tool_invoker_result = self._tool_invoker.run(messages=llm_messages, state=state)
+            tool_messages = tool_invoker_result["messages"]
+            state = tool_invoker_result["state"]
+
+            # 4. Check the LLM and Tool response for the exit condition, if exit_condition is a tool name
+            # TODO Possible for LLM to return multiple messages (e.g. multiple tool calls)
+            #      So exit condition could be missed if it's not the first message
+            if self.exit_condition != "text" and (
+                llm_messages[0].tool_call.tool_name == self.exit_condition
+                and not tool_messages[0].tool_call_result.error
+            ):
+                return {"messages": messages + llm_messages + tool_messages, **state.data}
+
+            # 5. Combine messages, llm_messages and tool_messages and send to the ChatGenerator
+            messages = messages + llm_messages + tool_messages
+            counter += 1
+
+        logger.warning(
+            "Agent exceeded maximum runs per component ({max_loops}), stopping.", max_loops=self.max_runs_per_component
+        )
+        return {"messages": messages, **state.data}
diff --git a/releasenotes/notes/move-agent-2d7f3145921fdec2.yaml b/releasenotes/notes/move-agent-2d7f3145921fdec2.yaml
new file mode 100644
index 0000000000..accc93b872
--- /dev/null
+++ b/releasenotes/notes/move-agent-2d7f3145921fdec2.yaml
@@ -0,0 +1,25 @@
+---
+highlights: >
+    The Agent component enables tool-calling functionality with provider-agnostic chat model support and can be used as a standalone component or within a pipeline.
+
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.components.websearch import SerperDevWebSearch
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.component_tool import ComponentTool
+
+    web_tool = ComponentTool(
+        component=SerperDevWebSearch(),
+    )
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=[web_tool],
+        exit_condition="text",
+    )
+
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+    ```
diff --git a/test/components/agents/test_agent.py b/test/components/agents/test_agent.py
new file mode 100644
index 0000000000..77cdf4312b
--- /dev/null
+++ b/test/components/agents/test_agent.py
@@ -0,0 +1,211 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from datetime import datetime
+from typing import Iterator
+
+from unittest.mock import MagicMock, patch
+import pytest
+
+from openai import Stream
+from openai.types.chat import ChatCompletionChunk, chat_completion_chunk
+
+from haystack.components.agents import Agent
+from haystack.components.builders.prompt_builder import PromptBuilder
+from haystack.components.generators.chat.openai import OpenAIChatGenerator
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.streaming_chunk import StreamingChunk
+from haystack.tools import Tool, ComponentTool
+from haystack.utils import serialize_callable, Secret
+
+import os
+
+
+def streaming_callback_for_serde(chunk: StreamingChunk):
+    pass
+
+
+def weather_function(location):
+    weather_info = {
+        "Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
+        "Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
+        "Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
+    }
+    return weather_info.get(location, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
+
+
+weather_parameters = {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
+
+
+@pytest.fixture
+def weather_tool():
+    return Tool(
+        name="weather_tool",
+        description="Provides weather information for a given location.",
+        parameters=weather_parameters,
+        function=weather_function,
+    )
+
+
+@pytest.fixture
+def component_tool():
+    return ComponentTool(name="parrot", description="This is a parrot.", component=PromptBuilder(template="{{parrot}}"))
+
+
+class OpenAIMockStream(Stream[ChatCompletionChunk]):
+    def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
+        client = client or MagicMock()
+        super().__init__(client=client, *args, **kwargs)
+        self.mock_chunk = mock_chunk
+
+    def __stream__(self) -> Iterator[ChatCompletionChunk]:
+        yield self.mock_chunk
+
+
+@pytest.fixture
+def openai_mock_chat_completion_chunk():
+    """
+    Mock the OpenAI API completion chunk response and reuse it for tests
+    """
+
+    with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
+        completion = ChatCompletionChunk(
+            id="foo",
+            model="gpt-4",
+            object="chat.completion.chunk",
+            choices=[
+                chat_completion_chunk.Choice(
+                    finish_reason="stop",
+                    logprobs=None,
+                    index=0,
+                    delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
+                )
+            ],
+            created=int(datetime.now().timestamp()),
+            usage=None,
+        )
+        mock_chat_completion_create.return_value = OpenAIMockStream(
+            completion, cast_to=None, response=None, client=None
+        )
+        yield mock_chat_completion_create
+
+
+class TestAgent:
+    def test_serde(self, weather_tool, component_tool, monkeypatch):
+        monkeypatch.setenv("FAKE_OPENAI_KEY", "fake-key")
+        generator = OpenAIChatGenerator(api_key=Secret.from_env_var("FAKE_OPENAI_KEY"))
+        agent = Agent(chat_generator=generator, tools=[weather_tool, component_tool])
+
+        serialized_agent = agent.to_dict()
+
+        init_parameters = serialized_agent["init_parameters"]
+
+        assert serialized_agent["type"] == "haystack.components.agents.agent.Agent"
+        assert (
+            init_parameters["chat_generator"]["type"]
+            == "haystack.components.generators.chat.openai.OpenAIChatGenerator"
+        )
+        assert init_parameters["streaming_callback"] == None
+        assert init_parameters["tools"][0]["data"]["function"] == serialize_callable(weather_function)
+        assert (
+            init_parameters["tools"][1]["data"]["component"]["type"]
+            == "haystack.components.builders.prompt_builder.PromptBuilder"
+        )
+
+        deserialized_agent = Agent.from_dict(serialized_agent)
+
+        assert isinstance(deserialized_agent, Agent)
+        assert isinstance(deserialized_agent.chat_generator, OpenAIChatGenerator)
+        assert deserialized_agent.tools[0].function is weather_function
+        assert isinstance(deserialized_agent.tools[1]._component, PromptBuilder)
+
+    def test_serde_with_streaming_callback(self, weather_tool, component_tool, monkeypatch):
+        monkeypatch.setenv("FAKE_OPENAI_KEY", "fake-key")
+        generator = OpenAIChatGenerator(api_key=Secret.from_env_var("FAKE_OPENAI_KEY"))
+        agent = Agent(
+            chat_generator=generator,
+            tools=[weather_tool, component_tool],
+            streaming_callback=streaming_callback_for_serde,
+        )
+
+        serialized_agent = agent.to_dict()
+
+        init_parameters = serialized_agent["init_parameters"]
+        assert init_parameters["streaming_callback"] == "test_agent.streaming_callback_for_serde"
+
+        deserialized_agent = Agent.from_dict(serialized_agent)
+        assert deserialized_agent.streaming_callback is streaming_callback_for_serde
+
+    def test_run_with_params_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
+        chat_generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
+        streaming_callback_called = False
+
+        def streaming_callback(chunk: StreamingChunk) -> None:
+            nonlocal streaming_callback_called
+            streaming_callback_called = True
+
+        agent = Agent(chat_generator=chat_generator, streaming_callback=streaming_callback, tools=[weather_tool])
+        agent.warm_up()
+        response = agent.run([ChatMessage.from_user("Hello")])
+
+        # check we called the streaming callback
+        assert streaming_callback_called is True
+
+        # check that the component still returns the correct response
+        assert isinstance(response, dict)
+        assert "messages" in response
+        assert isinstance(response["messages"], list)
+        assert len(response["messages"]) == 2
+        assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
+        assert "Hello" in response["messages"][1].text  # see openai_mock_chat_completion_chunk
+
+    def test_run_with_run_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
+        chat_generator = OpenAIChatGenerator(api_key=Secret.from_token("test-api-key"))
+
+        streaming_callback_called = False
+
+        def streaming_callback(chunk: StreamingChunk) -> None:
+            nonlocal streaming_callback_called
+            streaming_callback_called = True
+
+        agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
+        agent.warm_up()
+        response = agent.run([ChatMessage.from_user("Hello")], streaming_callback=streaming_callback)
+
+        # check we called the streaming callback
+        assert streaming_callback_called is True
+
+        # check that the component still returns the correct response
+        assert isinstance(response, dict)
+        assert "messages" in response
+        assert isinstance(response["messages"], list)
+        assert len(response["messages"]) == 2
+        assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
+        assert "Hello" in response["messages"][1].text  # see openai_mock_chat_completion_chunk
+
+    def test_keep_generator_streaming(self, openai_mock_chat_completion_chunk, weather_tool):
+        streaming_callback_called = False
+
+        def streaming_callback(chunk: StreamingChunk) -> None:
+            nonlocal streaming_callback_called
+            streaming_callback_called = True
+
+        chat_generator = OpenAIChatGenerator(
+            api_key=Secret.from_token("test-api-key"), streaming_callback=streaming_callback
+        )
+
+        agent = Agent(chat_generator=chat_generator, tools=[weather_tool])
+        agent.warm_up()
+        response = agent.run([ChatMessage.from_user("Hello")])
+
+        # check we called the streaming callback
+        assert streaming_callback_called is True
+
+        # check that the component still returns the correct response
+        assert isinstance(response, dict)
+        assert "messages" in response
+        assert isinstance(response["messages"], list)
+        assert len(response["messages"]) == 2
+        assert [isinstance(reply, ChatMessage) for reply in response["messages"]]
+        assert "Hello" in response["messages"][1].text  # see openai_mock_chat_completion_chunk


Reviews:
user: login: anakin87
body: I left some comments.

- I am OK with adding a check for `tools` parameters in another PR
- I hope that in the near future we can add some tests for this components
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T12:00:02Z
user: login: julian-risch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T12:06:28Z
user: login: julian-risch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T12:10:29Z
user: login: julian-risch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T12:10:35Z
user: login: julian-risch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T12:10:44Z
user: login: julian-risch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T13:08:25Z
user: login: anakin87
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T13:22:59Z

Review comments:
diff_hunk: @@ -0,0 +1,26 @@
+---
+highlights: >
+    The [`Agent`](https://github.com/deepset-ai/haystack-experimental/blob/main/haystack_experimental/components/agents/agent.py) component enables tool-calling functionality with provider-agnostic chat model support and can be used as a standalone component or within a pipeline.
path: releasenotes/notes/move-agent-2d7f3145921fdec2.yaml
user: login: anakin87
body: I'm not sure that markdown links work in release notes.
BTW, the Agent URL points to experimental
created_at: 2025-03-28T11:51:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,211 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from datetime import datetime
+from typing import Iterator
+
+from unittest.mock import MagicMock, patch
+import pytest
+
+from openai import Stream
+from openai.types.chat import ChatCompletionChunk, chat_completion_chunk
+
+from haystack.components.agents import Agent
+from haystack.components.builders.prompt_builder import PromptBuilder
+from haystack.components.generators.chat.openai import OpenAIChatGenerator
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.streaming_chunk import StreamingChunk
+from haystack.tools import Tool, ComponentTool
+from haystack.utils import serialize_callable, Secret
+
+import os
+
+
+def streaming_callback_for_serde(chunk: StreamingChunk):
+    pass
+
+
+def weather_function(location):
+    weather_info = {
+        "Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
+        "Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
+        "Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
+    }
+    return weather_info.get(location, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
+
+
+weather_parameters = {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
+
+
+@pytest.fixture
+def weather_tool():
+    return Tool(
+        name="weather_tool",
+        description="Provides weather information for a given location.",
+        parameters=weather_parameters,
+        function=weather_function,
+    )
+
+
+@pytest.fixture
+def component_tool():
+    return ComponentTool(name="parrot", description="This is a parrot.", component=PromptBuilder(template="{{parrot}}"))
+
+
+class OpenAIMockStream(Stream[ChatCompletionChunk]):
+    def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
+        client = client or MagicMock()
+        super().__init__(client=client, *args, **kwargs)
+        self.mock_chunk = mock_chunk
+
+    def __stream__(self) -> Iterator[ChatCompletionChunk]:
+        yield self.mock_chunk
+
+
+@pytest.fixture
+def openai_mock_chat_completion_chunk():
+    """
+    Mock the OpenAI API completion chunk response and reuse it for tests
+    """
+
+    with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
+        completion = ChatCompletionChunk(
+            id="foo",
+            model="gpt-4",
+            object="chat.completion.chunk",
+            choices=[
+                chat_completion_chunk.Choice(
+                    finish_reason="stop",
+                    logprobs=None,
+                    index=0,
+                    delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
+                )
+            ],
+            created=int(datetime.now().timestamp()),
+            usage=None,
+        )
+        mock_chat_completion_create.return_value = OpenAIMockStream(
+            completion, cast_to=None, response=None, client=None
+        )
+        yield mock_chat_completion_create
+
+
+class TestAgent:
+    def test_serde(self, weather_tool, component_tool):
+        os.environ["FAKE_OPENAI_KEY"] = "fake-key"
path: test/components/agents/test_agent.py
user: login: anakin87
body: we should use `monkeypatch` to avoid setting this env var for all tests
created_at: 2025-03-28T11:54:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
+        :param tools: List of Tool objects available to the agent
+        :param system_prompt: System prompt for the agent.
+        :param exit_condition: Either "text" if the agent should return when it generates a message without tool calls
+            or the name of a tool that will cause the agent to return once the tool was executed
+        :param state_schema: The schema for the runtime state used by the tools.
+        :param max_runs_per_component: Maximum number of runs per component. Agent will raise an exception if a
+            component exceeds the maximum number of runs per component.
+        :param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
+            If set to False, the exception will be turned into a chat message and passed to the LLM.
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        """
+        valid_exits = ["text"] + [tool.name for tool in tools or []]
+        if exit_condition not in valid_exits:
+            raise ValueError(f"Exit condition must be one of {valid_exits}")
+
+        if state_schema is not None:
+            _validate_schema(state_schema)
+        self.state_schema = state_schema or {}
+
+        self.chat_generator = chat_generator
+        self.tools = tools or []
+        self.system_prompt = system_prompt
+        self.exit_condition = exit_condition
+        self.max_runs_per_component = max_runs_per_component
+        self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
+        self.streaming_callback = streaming_callback
+
+        output_types = {"messages": List[ChatMessage]}
+        for param, config in self.state_schema.items():
+            component.set_input_type(self, name=param, type=config["type"], default=None)
+            output_types[param] = config["type"]
+        component.set_output_types(self, **output_types)
+
+        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+
+        self._is_warmed_up = False
+
+    def warm_up(self) -> None:
+        """
+        Warm up the Agent.
+        """
+        if not self._is_warmed_up:
+            if hasattr(self.chat_generator, "warm_up"):
+                self.chat_generator.warm_up()
+            self._is_warmed_up = True
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serialize the component to a dictionary.
+
+        :return: Dictionary with serialized data
+        """
+        if self.streaming_callback is not None:
+            streaming_callback = serialize_callable(self.streaming_callback)
+        else:
+            streaming_callback = None
+
+        return default_to_dict(
+            self,
+            chat_generator=self.chat_generator.to_dict(),
+            tools=[t.to_dict() for t in self.tools],
+            system_prompt=self.system_prompt,
+            exit_condition=self.exit_condition,
+            state_schema=_schema_to_dict(self.state_schema),
+            max_runs_per_component=self.max_runs_per_component,
+            raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
+            streaming_callback=streaming_callback,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Agent":
+        """
+        Deserialize the agent from a dictionary.
+
+        :param data: Dictionary to deserialize from
+        :return: Deserialized agent
+        """
+        init_params = data.get("init_parameters", {})
+
+        chat_generator_class = import_class_by_name(init_params["chat_generator"]["type"])
+        assert hasattr(chat_generator_class, "from_dict")  # we know but mypy doesn't
+        chat_generator_instance = chat_generator_class.from_dict(init_params["chat_generator"])
+        data["init_parameters"]["chat_generator"] = chat_generator_instance
+
+        if "state_schema" in init_params:
+            init_params["state_schema"] = _schema_from_dict(init_params["state_schema"])
+
+        if init_params.get("streaming_callback") is not None:
+            init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
+
+        deserialize_tools_inplace(init_params, key="tools")
+
+        return default_from_dict(cls, data)
+
+    @staticmethod
+    def _load_component(component_data: Dict[str, Any]) -> Component:
path: haystack/components/agents/agent.py
user: login: anakin87
body: this method seems unused, no?
created_at: 2025-03-28T11:55:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
path: haystack/components/agents/agent.py
user: login: anakin87
body: maybe let's mention here that the Chat Generator should support tools
created_at: 2025-03-28T11:55:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
+        :param tools: List of Tool objects available to the agent
+        :param system_prompt: System prompt for the agent.
+        :param exit_condition: Either "text" if the agent should return when it generates a message without tool calls
+            or the name of a tool that will cause the agent to return once the tool was executed
+        :param state_schema: The schema for the runtime state used by the tools.
+        :param max_runs_per_component: Maximum number of runs per component. Agent will raise an exception if a
+            component exceeds the maximum number of runs per component.
+        :param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
+            If set to False, the exception will be turned into a chat message and passed to the LLM.
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        """
+        valid_exits = ["text"] + [tool.name for tool in tools or []]
+        if exit_condition not in valid_exits:
+            raise ValueError(f"Exit condition must be one of {valid_exits}")
+
+        if state_schema is not None:
+            _validate_schema(state_schema)
+        self.state_schema = state_schema or {}
+
+        self.chat_generator = chat_generator
+        self.tools = tools or []
+        self.system_prompt = system_prompt
+        self.exit_condition = exit_condition
+        self.max_runs_per_component = max_runs_per_component
+        self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
+        self.streaming_callback = streaming_callback
+
+        output_types = {"messages": List[ChatMessage]}
+        for param, config in self.state_schema.items():
+            component.set_input_type(self, name=param, type=config["type"], default=None)
+            output_types[param] = config["type"]
+        component.set_output_types(self, **output_types)
+
+        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+
+        self._is_warmed_up = False
+
+    def warm_up(self) -> None:
+        """
+        Warm up the Agent.
+        """
+        if not self._is_warmed_up:
+            if hasattr(self.chat_generator, "warm_up"):
+                self.chat_generator.warm_up()
+            self._is_warmed_up = True
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serialize the component to a dictionary.
+
+        :return: Dictionary with serialized data
+        """
+        if self.streaming_callback is not None:
+            streaming_callback = serialize_callable(self.streaming_callback)
+        else:
+            streaming_callback = None
+
+        return default_to_dict(
+            self,
+            chat_generator=self.chat_generator.to_dict(),
+            tools=[t.to_dict() for t in self.tools],
+            system_prompt=self.system_prompt,
+            exit_condition=self.exit_condition,
+            state_schema=_schema_to_dict(self.state_schema),
+            max_runs_per_component=self.max_runs_per_component,
+            raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
+            streaming_callback=streaming_callback,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Agent":
+        """
+        Deserialize the agent from a dictionary.
+
+        :param data: Dictionary to deserialize from
+        :return: Deserialized agent
+        """
+        init_params = data.get("init_parameters", {})
+
+        chat_generator_class = import_class_by_name(init_params["chat_generator"]["type"])
+        assert hasattr(chat_generator_class, "from_dict")  # we know but mypy doesn't
+        chat_generator_instance = chat_generator_class.from_dict(init_params["chat_generator"])
+        data["init_parameters"]["chat_generator"] = chat_generator_instance
path: haystack/components/agents/agent.py
user: login: anakin87
body: this looks ok.

BTW, I'm introducing a utility function to avoid repeating this code in #9122
created_at: 2025-03-28T11:57:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
+        :param tools: List of Tool objects available to the agent
+        :param system_prompt: System prompt for the agent.
+        :param exit_condition: Either "text" if the agent should return when it generates a message without tool calls
+            or the name of a tool that will cause the agent to return once the tool was executed
+        :param state_schema: The schema for the runtime state used by the tools.
+        :param max_runs_per_component: Maximum number of runs per component. Agent will raise an exception if a
+            component exceeds the maximum number of runs per component.
+        :param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
+            If set to False, the exception will be turned into a chat message and passed to the LLM.
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        """
+        valid_exits = ["text"] + [tool.name for tool in tools or []]
+        if exit_condition not in valid_exits:
+            raise ValueError(f"Exit condition must be one of {valid_exits}")
+
+        if state_schema is not None:
+            _validate_schema(state_schema)
+        self.state_schema = state_schema or {}
+
+        self.chat_generator = chat_generator
+        self.tools = tools or []
+        self.system_prompt = system_prompt
+        self.exit_condition = exit_condition
+        self.max_runs_per_component = max_runs_per_component
+        self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
+        self.streaming_callback = streaming_callback
+
+        output_types = {"messages": List[ChatMessage]}
+        for param, config in self.state_schema.items():
+            component.set_input_type(self, name=param, type=config["type"], default=None)
+            output_types[param] = config["type"]
+        component.set_output_types(self, **output_types)
+
+        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+
+        self._is_warmed_up = False
+
+    def warm_up(self) -> None:
+        """
+        Warm up the Agent.
+        """
+        if not self._is_warmed_up:
+            if hasattr(self.chat_generator, "warm_up"):
+                self.chat_generator.warm_up()
+            self._is_warmed_up = True
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serialize the component to a dictionary.
+
+        :return: Dictionary with serialized data
+        """
+        if self.streaming_callback is not None:
+            streaming_callback = serialize_callable(self.streaming_callback)
+        else:
+            streaming_callback = None
+
+        return default_to_dict(
+            self,
+            chat_generator=self.chat_generator.to_dict(),
+            tools=[t.to_dict() for t in self.tools],
+            system_prompt=self.system_prompt,
+            exit_condition=self.exit_condition,
+            state_schema=_schema_to_dict(self.state_schema),
+            max_runs_per_component=self.max_runs_per_component,
+            raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
+            streaming_callback=streaming_callback,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Agent":
+        """
+        Deserialize the agent from a dictionary.
+
+        :param data: Dictionary to deserialize from
+        :return: Deserialized agent
+        """
+        init_params = data.get("init_parameters", {})
+
+        chat_generator_class = import_class_by_name(init_params["chat_generator"]["type"])
+        assert hasattr(chat_generator_class, "from_dict")  # we know but mypy doesn't
+        chat_generator_instance = chat_generator_class.from_dict(init_params["chat_generator"])
+        data["init_parameters"]["chat_generator"] = chat_generator_instance
+
+        if "state_schema" in init_params:
+            init_params["state_schema"] = _schema_from_dict(init_params["state_schema"])
+
+        if init_params.get("streaming_callback") is not None:
+            init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
+
+        deserialize_tools_inplace(init_params, key="tools")
+
+        return default_from_dict(cls, data)
+
+    @staticmethod
+    def _load_component(component_data: Dict[str, Any]) -> Component:
path: haystack/components/agents/agent.py
user: login: julian-risch
body: I saw it being brought up here: https://github.com/deepset-ai/haystack-experimental/issues/218#issuecomment-2704566371
Will check.
created_at: 2025-03-28T12:06:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,26 @@
+---
+highlights: >
+    The [`Agent`](https://github.com/deepset-ai/haystack-experimental/blob/main/haystack_experimental/components/agents/agent.py) component enables tool-calling functionality with provider-agnostic chat model support and can be used as a standalone component or within a pipeline.
path: releasenotes/notes/move-agent-2d7f3145921fdec2.yaml
user: login: julian-risch
body: I removed the links.
created_at: 2025-03-28T12:10:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,211 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from datetime import datetime
+from typing import Iterator
+
+from unittest.mock import MagicMock, patch
+import pytest
+
+from openai import Stream
+from openai.types.chat import ChatCompletionChunk, chat_completion_chunk
+
+from haystack.components.agents import Agent
+from haystack.components.builders.prompt_builder import PromptBuilder
+from haystack.components.generators.chat.openai import OpenAIChatGenerator
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.streaming_chunk import StreamingChunk
+from haystack.tools import Tool, ComponentTool
+from haystack.utils import serialize_callable, Secret
+
+import os
+
+
+def streaming_callback_for_serde(chunk: StreamingChunk):
+    pass
+
+
+def weather_function(location):
+    weather_info = {
+        "Berlin": {"weather": "mostly sunny", "temperature": 7, "unit": "celsius"},
+        "Paris": {"weather": "mostly cloudy", "temperature": 8, "unit": "celsius"},
+        "Rome": {"weather": "sunny", "temperature": 14, "unit": "celsius"},
+    }
+    return weather_info.get(location, {"weather": "unknown", "temperature": 0, "unit": "celsius"})
+
+
+weather_parameters = {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}
+
+
+@pytest.fixture
+def weather_tool():
+    return Tool(
+        name="weather_tool",
+        description="Provides weather information for a given location.",
+        parameters=weather_parameters,
+        function=weather_function,
+    )
+
+
+@pytest.fixture
+def component_tool():
+    return ComponentTool(name="parrot", description="This is a parrot.", component=PromptBuilder(template="{{parrot}}"))
+
+
+class OpenAIMockStream(Stream[ChatCompletionChunk]):
+    def __init__(self, mock_chunk: ChatCompletionChunk, client=None, *args, **kwargs):
+        client = client or MagicMock()
+        super().__init__(client=client, *args, **kwargs)
+        self.mock_chunk = mock_chunk
+
+    def __stream__(self) -> Iterator[ChatCompletionChunk]:
+        yield self.mock_chunk
+
+
+@pytest.fixture
+def openai_mock_chat_completion_chunk():
+    """
+    Mock the OpenAI API completion chunk response and reuse it for tests
+    """
+
+    with patch("openai.resources.chat.completions.Completions.create") as mock_chat_completion_create:
+        completion = ChatCompletionChunk(
+            id="foo",
+            model="gpt-4",
+            object="chat.completion.chunk",
+            choices=[
+                chat_completion_chunk.Choice(
+                    finish_reason="stop",
+                    logprobs=None,
+                    index=0,
+                    delta=chat_completion_chunk.ChoiceDelta(content="Hello", role="assistant"),
+                )
+            ],
+            created=int(datetime.now().timestamp()),
+            usage=None,
+        )
+        mock_chat_completion_create.return_value = OpenAIMockStream(
+            completion, cast_to=None, response=None, client=None
+        )
+        yield mock_chat_completion_create
+
+
+class TestAgent:
+    def test_serde(self, weather_tool, component_tool):
+        os.environ["FAKE_OPENAI_KEY"] = "fake-key"
path: test/components/agents/test_agent.py
user: login: julian-risch
body: done
created_at: 2025-03-28T12:10:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
path: haystack/components/agents/agent.py
user: login: julian-risch
body: done
created_at: 2025-03-28T12:10:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,247 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any, Dict, List, Optional
+
+from haystack import component, default_from_dict, default_to_dict, logging
+from haystack.components.generators.chat.types import ChatGenerator
+from haystack.components.tools import ToolInvoker
+from haystack.core.component import Component
+from haystack.core.pipeline.base import PipelineError
+from haystack.core.serialization import component_from_dict, import_class_by_name
+from haystack.dataclasses import ChatMessage
+from haystack.dataclasses.state import State, _schema_from_dict, _schema_to_dict, _validate_schema
+from haystack.dataclasses.streaming_chunk import SyncStreamingCallbackT
+from haystack.tools import Tool, deserialize_tools_inplace
+from haystack.utils import type_serialization
+from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
+
+logger = logging.getLogger(__name__)
+
+
+@component
+class Agent:
+    """
+    A Haystack component that implements a tool-using agent with provider-agnostic chat model support.
+
+    The component processes messages and executes tools until a exit_condition condition is met.
+    The exit_condition can be triggered either by a direct text response or by invoking a specific designated tool.
+
+    ### Usage example
+    ```python
+    from haystack.components.agents import Agent
+    from haystack.components.generators.chat import OpenAIChatGenerator
+    from haystack.dataclasses import ChatMessage
+    from haystack.tools.tool import Tool
+
+    tools = [Tool(name="calculator", description="..."), Tool(name="search", description="...")]
+
+    agent = Agent(
+        chat_generator=OpenAIChatGenerator(),
+        tools=tools,
+        exit_condition="search",
+    )
+
+    # Run the agent
+    result = agent.run(
+        messages=[ChatMessage.from_user("Find information about Haystack")]
+    )
+
+    assert "messages" in result  # Contains conversation history
+    ```
+    """
+
+    def __init__(
+        self,
+        *,
+        chat_generator: ChatGenerator,
+        tools: Optional[List[Tool]] = None,
+        system_prompt: Optional[str] = None,
+        exit_condition: str = "text",
+        state_schema: Optional[Dict[str, Any]] = None,
+        max_runs_per_component: int = 100,
+        raise_on_tool_invocation_failure: bool = False,
+        streaming_callback: Optional[SyncStreamingCallbackT] = None,
+    ):
+        """
+        Initialize the agent component.
+
+        :param chat_generator: An instance of the chat generator that your agent should use.
+        :param tools: List of Tool objects available to the agent
+        :param system_prompt: System prompt for the agent.
+        :param exit_condition: Either "text" if the agent should return when it generates a message without tool calls
+            or the name of a tool that will cause the agent to return once the tool was executed
+        :param state_schema: The schema for the runtime state used by the tools.
+        :param max_runs_per_component: Maximum number of runs per component. Agent will raise an exception if a
+            component exceeds the maximum number of runs per component.
+        :param raise_on_tool_invocation_failure: Should the agent raise an exception when a tool invocation fails?
+            If set to False, the exception will be turned into a chat message and passed to the LLM.
+        :param streaming_callback: A callback that will be invoked when a response is streamed from the LLM.
+        """
+        valid_exits = ["text"] + [tool.name for tool in tools or []]
+        if exit_condition not in valid_exits:
+            raise ValueError(f"Exit condition must be one of {valid_exits}")
+
+        if state_schema is not None:
+            _validate_schema(state_schema)
+        self.state_schema = state_schema or {}
+
+        self.chat_generator = chat_generator
+        self.tools = tools or []
+        self.system_prompt = system_prompt
+        self.exit_condition = exit_condition
+        self.max_runs_per_component = max_runs_per_component
+        self.raise_on_tool_invocation_failure = raise_on_tool_invocation_failure
+        self.streaming_callback = streaming_callback
+
+        output_types = {"messages": List[ChatMessage]}
+        for param, config in self.state_schema.items():
+            component.set_input_type(self, name=param, type=config["type"], default=None)
+            output_types[param] = config["type"]
+        component.set_output_types(self, **output_types)
+
+        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+
+        self._is_warmed_up = False
+
+    def warm_up(self) -> None:
+        """
+        Warm up the Agent.
+        """
+        if not self._is_warmed_up:
+            if hasattr(self.chat_generator, "warm_up"):
+                self.chat_generator.warm_up()
+            self._is_warmed_up = True
+
+    def to_dict(self) -> Dict[str, Any]:
+        """
+        Serialize the component to a dictionary.
+
+        :return: Dictionary with serialized data
+        """
+        if self.streaming_callback is not None:
+            streaming_callback = serialize_callable(self.streaming_callback)
+        else:
+            streaming_callback = None
+
+        return default_to_dict(
+            self,
+            chat_generator=self.chat_generator.to_dict(),
+            tools=[t.to_dict() for t in self.tools],
+            system_prompt=self.system_prompt,
+            exit_condition=self.exit_condition,
+            state_schema=_schema_to_dict(self.state_schema),
+            max_runs_per_component=self.max_runs_per_component,
+            raise_on_tool_invocation_failure=self.raise_on_tool_invocation_failure,
+            streaming_callback=streaming_callback,
+        )
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "Agent":
+        """
+        Deserialize the agent from a dictionary.
+
+        :param data: Dictionary to deserialize from
+        :return: Deserialized agent
+        """
+        init_params = data.get("init_parameters", {})
+
+        chat_generator_class = import_class_by_name(init_params["chat_generator"]["type"])
+        assert hasattr(chat_generator_class, "from_dict")  # we know but mypy doesn't
+        chat_generator_instance = chat_generator_class.from_dict(init_params["chat_generator"])
+        data["init_parameters"]["chat_generator"] = chat_generator_instance
+
+        if "state_schema" in init_params:
+            init_params["state_schema"] = _schema_from_dict(init_params["state_schema"])
+
+        if init_params.get("streaming_callback") is not None:
+            init_params["streaming_callback"] = deserialize_callable(init_params["streaming_callback"])
+
+        deserialize_tools_inplace(init_params, key="tools")
+
+        return default_from_dict(cls, data)
+
+    @staticmethod
+    def _load_component(component_data: Dict[str, Any]) -> Component:
path: haystack/components/agents/agent.py
user: login: julian-risch
body: indeed not needed. I removed it and the corresponding imports.
created_at: 2025-03-28T13:08:25Z
author_association: MEMBER

PR comments:
user: login: coveralls
created_at: 2025-03-28T10:44:27Z
body: ## Pull Request Test Coverage Report for [Build 14129639952](https://coveralls.io/builds/72972707)

### Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

- For more information on this, see <a target="_blank" href="https://docs.coveralls.io/build-types#tracking-coverage-changes-with-pull_request-builds">Tracking coverage changes with pull request builds</a>.
- To avoid this issue with future PRs, see these <a target="_blank" href="https://docs.coveralls.io/build-types#recommended-ci-configurations">Recommended CI Configurations</a>.
- For a quick fix, <a target="_blank" href="https://github.blog/changelog/2022-02-03-more-ways-to-keep-your-pull-request-branch-up-to-date/#update-your-pull-request-branch-by-rebasing">rebase this PR at GitHub</a>. Your next report should be accurate.

### Details

* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.01%**) to **90.161%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/72972707/badge)](https://coveralls.io/builds/72972707) |
| :-- | --: |
| Change from base [Build 14126286424](https://coveralls.io/builds/72969716): |  -0.01% |
| Covered Lines: | 10218 |
| Relevant Lines: | 11333 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: COLLABORATOR
user: login: julian-risch
created_at: 2025-03-28T11:41:07Z
body: @anakin87 if you have time, it would be great if you could have a quick look at this PR too. In particular at the changes made in:
https://github.com/deepset-ai/haystack/pull/9112/commits/0de26eb1e40898eb4fd198c139938bf781b6a989
and at the update of `from_dict` similar to how changes were done by you for LLMMetadataExtractor.
author_association: MEMBER