Repository: deepset-ai/haystack
Pull request: fix: Allow Agent to run with no tools
### Related Issues

- fixes https://github.com/deepset-ai/haystack/issues/9220

### Proposed Changes:

 <!--- In case of a bug: Describe what caused the issue and how you solved it -->
 <!--- In case of a feature: Describe what did you add and how it works -->
Updates the init and run logic of Agent to truly allow Tools to be optional. This means an Agent can be run with no Tools now which makes it act like a normal ChatGenerator. 

### How did you test it?

<!-- unit tests, integration tests, manual verification, instructions for manual tests -->
Added a test

### Notes for the reviewer

<!-- E.g. point out section where the reviewer  -->

### 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


Problem statement:
Agent component has an Optional 'tools' init param, but fails if no tool is provided
**Describe the bug**
Hey there! Not sure it's a bug, but I need some clarifications. 
If I simply do:

```python
import os
os.environ["AZURE_OPENAI_API_KEY"] = "***"
os.environ["AZURE_OPENAI_ENDPOINT"] = "***"

from haystack.components.generators.chat import AzureOpenAIChatGenerator
from haystack.components.agents import Agent

agent = Agent(chat_generator=AzureOpenAIChatGenerator())
```

I obtain:

```python
File "***/main.py", line 9, in <module>
    agent = Agent(chat_generator=AzureOpenAIChatGenerator())
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "***/.venv/lib/python3.12/site-packages/haystack/core/component/component.py", line 268, in __call__
    instance = super().__call__(*args, **kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "***/.venv/lib/python3.12/site-packages/haystack/components/agents/agent.py", line 131, in __init__
    self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "***/.venv/lib/python3.12/site-packages/haystack/core/component/component.py", line 268, in __call__
    instance = super().__call__(*args, **kwargs)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "***/.venv/lib/python3.12/site-packages/haystack/components/tools/tool_invoker.py", line 132, in __init__
    raise ValueError("ToolInvoker requires at least one tool.")
ValueError: ToolInvoker requires at least one tool.
````

**Expected behavior**
If tools are `Optional`, I should be able to create an Agent without tools.


**System:**
 - Haystack version (commit or version number): 2.12.1

Hints from issue discussion:
Hey @danielescaramuzzi thanks for catching this! You're right this is a bug, just trying to think if it makes sense to allow Tools to be optional or not. My impression is that if you are running an agent with no Tools then probably it's best to use a ChatGenerator directly at that point. 

But it could easily be set up so that Agent acts like a ChatGenerator if no tools are supplied as well. 
> My impression is that if you are running an agent with no Tools then probably it's best to use a ChatGenerator directly at that point.

Want to drop my two cents here from my previous experience. In some cases an agent can actually have no tool call capabilities while still benefit from a state/loop mechanism. For example, you can set up an agent where it reads a webpage chunk by chunk, while maintaining a "reading note" in the loop. Depending on the setup, we can make the agent call an early stop if it feels like these is nothing else to learn, and of course the natural exit is when the agent has gone through all the content.
Sounds good! We opted to allow the behavior of no tools. You can check it out in the PR https://github.com/deepset-ai/haystack/pull/9230
@sjrl Thanks for the ping. I left a comment there as well.

Commit message: Update docstring and warning message

Patch to review:
diff --git a/haystack/components/agents/agent.py b/haystack/components/agents/agent.py
index dce17d6a8f..64afae46f5 100644
--- a/haystack/components/agents/agent.py
+++ b/haystack/components/agents/agent.py
@@ -30,6 +30,9 @@ class Agent:
     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.
 
+    When you call an Agent without any tools it acts like a ChatGenerator. It will exit after generating a text
+    response.
+
     ### Usage example
     ```python
     from haystack.components.agents import Agent
@@ -129,7 +132,14 @@ def __init__(
             component.set_input_type(self, name=param, type=config["type"], default=None)
         component.set_output_types(self, **output_types)
 
-        self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+        if self.tools:
+            self._tool_invoker = ToolInvoker(tools=self.tools, raise_on_failure=self.raise_on_tool_invocation_failure)
+        else:
+            logger.warning(
+                "No tools provided to the Agent. The Agent will behave like a ChatGenerator and only return text "
+                "responses. To enable tool usage, pass tools directly to the Agent, not to the chat_generator."
+            )
+            self._tool_invoker = None
         self._is_warmed_up = False
 
     def warm_up(self) -> None:
@@ -224,8 +234,8 @@ def run(
             llm_messages = self.chat_generator.run(messages=messages, **generator_inputs)["replies"]
             state.set("messages", llm_messages)
 
-            # 2. Check if any of the LLM responses contain a tool call
-            if not any(msg.tool_call for msg in llm_messages):
+            # 2. Check if any of the LLM responses contain a tool call or if the LLM is not using tools
+            if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
                 return {**state.data}
 
             # 3. Call the ToolInvoker
@@ -300,8 +310,8 @@ async def run_async(
 
             state.set("messages", llm_messages)
 
-            # 2. Check if any of the LLM responses contain a tool call
-            if not any(msg.tool_call for msg in llm_messages):
+            # 2. Check if any of the LLM responses contain a tool call or if the LLM is not using tools
+            if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
                 return {**state.data}
 
             # 3. Call the ToolInvoker
diff --git a/releasenotes/notes/fix-agent-with-no-tools-aadc4274e2f72033.yaml b/releasenotes/notes/fix-agent-with-no-tools-aadc4274e2f72033.yaml
new file mode 100644
index 0000000000..c344a03764
--- /dev/null
+++ b/releasenotes/notes/fix-agent-with-no-tools-aadc4274e2f72033.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+  - |
+    Now when you call an Agent with no tools it acts like a ChatGenerator which means it returns a ChatMessage based on a user input.
diff --git a/test/components/agents/test_agent.py b/test/components/agents/test_agent.py
index bc1e00a990..c21222add7 100644
--- a/test/components/agents/test_agent.py
+++ b/test/components/agents/test_agent.py
@@ -491,6 +491,31 @@ def test_exit_conditions_checked_across_all_llm_messages(self, monkeypatch, weat
             == "{'weather': 'mostly sunny', 'temperature': 7, 'unit': 'celsius'}"
         )
 
+    def test_agent_with_no_tools(self, monkeypatch, caplog):
+        monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
+        generator = OpenAIChatGenerator()
+
+        # Mock messages where the exit condition appears in the second message
+        mock_messages = [ChatMessage.from_assistant("Berlin")]
+
+        with caplog.at_level("WARNING"):
+            agent = Agent(chat_generator=generator, tools=[], max_agent_steps=3)
+            agent.warm_up()
+            assert "No tools provided to the Agent." in caplog.text
+
+        # Patch agent.chat_generator.run to return mock_messages
+        agent.chat_generator.run = MagicMock(return_value={"replies": mock_messages})
+
+        response = agent.run([ChatMessage.from_user("What is the capital of Germany?")])
+
+        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 response["messages"][0].text == "What is the capital of Germany?"
+        assert response["messages"][1].text == "Berlin"
+
     @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
     @pytest.mark.integration
     def test_run(self, weather_tool):


Code review comments:
diff_hunk: @@ -30,6 +30,9 @@ class Agent:
     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.
 
+    When you call an Agent without any tools it acts like a ChatGenerator. It will exit after generating a text
+    response.
path: haystack/components/agents/agent.py
text: @author:
@user1 Could you give this docstring a review?

@user1:
I propose to order it a bit: "When you call an Agent without tools, it acts as a ChatGenerator, produces one response, then exits."