Skip to content

Commit c9a22a9

Browse files
authored
fix: Fix "text" Agent exit condition (#11665)
1 parent 3fa627a commit c9a22a9

3 files changed

Lines changed: 64 additions & 4 deletions

File tree

haystack/components/agents/agent.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -857,8 +857,17 @@ def run( # noqa: PLR0915
857857
llm_messages = result["replies"]
858858
exe_context.state.set("messages", llm_messages)
859859

860-
# Check if any of the LLM responses contain a tool call or if the LLM is not using tools
861-
if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
860+
# Exit for `exit_conditions=["text"]` behavior: the agent stops when there is no tool invoker, or when
861+
# the model returns a plain text response (no tool calls). We require the last message to be a non-empty
862+
# assistant text message so that an invalid response (e.g. a message with no tool calls or text) won't
863+
# trigger an exit.
864+
last_message = llm_messages[-1] if llm_messages else None
865+
if self._tool_invoker is None or (
866+
last_message is not None
867+
and not any(msg.tool_call for msg in llm_messages)
868+
and last_message.is_from(ChatRole.ASSISTANT)
869+
and last_message.text
870+
):
862871
exe_context.counter += 1
863872
break
864873

@@ -1092,8 +1101,17 @@ async def run_async( # noqa: PLR0915
10921101
llm_messages = result["replies"]
10931102
exe_context.state.set("messages", llm_messages)
10941103

1095-
# Check if any of the LLM responses contain a tool call or if the LLM is not using tools
1096-
if not any(msg.tool_call for msg in llm_messages) or self._tool_invoker is None:
1104+
# Exit for `exit_conditions=["text"]` behavior: the agent stops when there is no tool invoker, or when
1105+
# the model returns a plain text response (no tool calls). We require the last message to be a non-empty
1106+
# assistant text message so that an invalid response (e.g. a message with no tool calls or text) won't
1107+
# trigger an exit.
1108+
last_message = llm_messages[-1] if llm_messages else None
1109+
if self._tool_invoker is None or (
1110+
last_message is not None
1111+
and not any(msg.tool_call for msg in llm_messages)
1112+
and last_message.is_from(ChatRole.ASSISTANT)
1113+
and last_message.text
1114+
):
10971115
exe_context.counter += 1
10981116
break
10991117

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed the ``Agent`` exiting prematurely under the default ``exit_conditions=["text"]``.
5+
The agent now only stops when the last message is an assistant message with non-empty text
6+
(or when no tool invoker is configured). Previously, if the LLM produced an invalid tool call
7+
that was discarded, the resulting assistant message with empty text and no tool calls would
8+
trigger an exit, preventing the agent from recovering. The agent now continues looping so the
9+
model can recover on the next iteration.

test/components/agents/test_agent.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,22 @@ def test_exit_condition_exits(self, monkeypatch, weather_tool):
792792
assert isinstance(result["last_message"], ChatMessage)
793793
assert result["messages"][-1] == result["last_message"]
794794

795+
def test_does_not_exit_on_empty_assistant_message(self, monkeypatch, weather_tool):
796+
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
797+
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["text"])
798+
799+
# The first reply simulates the LLM producing an invalid tool call that our code discards,leaving an assistant
800+
# message with empty text and no tool calls. This must not be treated as a "text" exit condition, so the agent
801+
# keeps looping and recovers on the second reply.
802+
empty_reply = {"replies": [ChatMessage.from_assistant(text="")]}
803+
recovered_reply = {"replies": [ChatMessage.from_assistant(text="The weather is sunny.")]}
804+
agent.chat_generator.run = MagicMock(side_effect=[empty_reply, recovered_reply])
805+
806+
result = agent.run([ChatMessage.from_user("What's the weather?")])
807+
808+
assert agent.chat_generator.run.call_count == 2
809+
assert result["last_message"].text == "The weather is sunny."
810+
795811
def test_check_exit_conditions_parallel_tool_calls(self, monkeypatch, weather_tool):
796812
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
797813
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["weather_tool"])
@@ -1077,6 +1093,23 @@ def streaming_callback(chunk: StreamingChunk) -> None:
10771093
assert result["last_message"] is not None
10781094
assert streaming_callback_called
10791095

1096+
@pytest.mark.asyncio
1097+
async def test_does_not_exit_on_empty_assistant_message_async(self, monkeypatch, weather_tool):
1098+
monkeypatch.setenv("OPENAI_API_KEY", "fake-key")
1099+
agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[weather_tool], exit_conditions=["text"])
1100+
1101+
# The first reply simulates the LLM producing an invalid tool call that our code discards,leaving an assistant
1102+
# message with empty text and no tool calls. This must not be treated as a "text" exit condition, so the agent
1103+
# keeps looping and recovers on the second reply.
1104+
empty_reply = {"replies": [ChatMessage.from_assistant(text="")]}
1105+
recovered_reply = {"replies": [ChatMessage.from_assistant(text="The weather is sunny.")]}
1106+
agent.chat_generator.run_async = AsyncMock(side_effect=[empty_reply, recovered_reply])
1107+
1108+
result = await agent.run_async([ChatMessage.from_user("What's the weather?")])
1109+
1110+
assert agent.chat_generator.run_async.call_count == 2
1111+
assert result["last_message"].text == "The weather is sunny."
1112+
10801113
@pytest.mark.asyncio
10811114
async def test_run_async_with_async_streaming_callback(self, weather_tool):
10821115
chat_generator = MockChatGenerator()

0 commit comments

Comments
 (0)