Describe the bug
Under the default exit_conditions=["text"], a reply that is empty because the model spent its whole max_output_tokens budget on reasoning does not exit the loop. The Agent keeps calling the LLM until max_agent_steps, and when the budget is systematically too small for the task every retry truncates identically, so all of those calls are wasted.
Details
An assistant reply can be empty for two different reasons, and the "text" exit currently treats them the same:
| Reply is empty because |
2.29.0 |
After #11665 |
Desired |
| a malformed tool call was discarded |
exits |
retries |
retries |
generation hit max_output_tokens |
exits |
retries until max_agent_steps |
exits |
#11665 fixed the first row, which was its intent. But the condition it introduced is bool(last.text), so it changed the second row too.
Expected behavior
The "Desired" column above: exit when the reply is empty because it was truncated, and keep #11665's recovery loop for every other empty reply.
Reproduce
What we observed
Measured against the real OpenAI API, gpt-5.6-terra, OpenAIResponsesChatGenerator (streaming), haystack 2.31.0.
With a reasoning budget well below what the task's reasoning pass needs (max_output_tokens=256, reasoning={"effort": "high"}, on a hard combinatorics prompt), the truncation is deterministic rather than a coin flip:
trial 1: text=EMPTY output_tokens=256 reasoning_tokens=256 status='incomplete' reason='max_output_tokens'
trial 2: text=EMPTY output_tokens=256 reasoning_tokens=256 status='incomplete' reason='max_output_tokens'
trial 3: text=EMPTY output_tokens=256 reasoning_tokens=256 status='incomplete' reason='max_output_tokens'
trial 4: text=EMPTY output_tokens=256 reasoning_tokens=256 status='incomplete' reason='max_output_tokens'
=> zero-text on 4/4 attempts
Wrapping the same generator in an Agent with max_agent_steps=5, one tool, and the default exit_conditions=["text"]:
assistant replies (= real API calls): 5
[0] user 'Let S be the set of all 12-digit positive integers whos...'
[1] assistant EMPTY
[2] assistant EMPTY
[3] assistant EMPTY
[4] assistant EMPTY
[5] assistant EMPTY
reasoning tokens burned across the run: 1280
Agent reached maximum agent steps of 5, stopping.
This becomes worse when max_agent_steps is higher. For example, max_agent_steps is 100 by default.
Reduced reproduction, no API key
from typing import Any
from haystack import component
from haystack.components.agents import Agent
from haystack.dataclasses import ChatMessage
from haystack.tools import Tool
llm_calls = 0
@component
class TruncatedGenerator:
"""Mimics a reply that hit `max_output_tokens` during reasoning: no text, no tool calls."""
@component.output_types(replies=list[ChatMessage])
def run(self, messages: list[ChatMessage], tools: Any = None, **kwargs: Any) -> dict:
global llm_calls
llm_calls += 1
return {
"replies": [
ChatMessage.from_assistant(
"",
meta={"status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}},
)
]
}
def noop(x: str) -> str:
"""A tool, so that a ToolInvoker is configured."""
return x
tool = Tool(
name="noop",
description="noop",
function=noop,
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
)
agent = Agent(
chat_generator=TruncatedGenerator(),
tools=[tool],
exit_conditions=["text"], # the default
max_agent_steps=15,
)
agent.warm_up()
agent.run(messages=[ChatMessage.from_user("Write a very long essay.")])
print(f"LLM calls: {llm_calls} (expected 1, got {llm_calls})")
On 2.31.0 this prints LLM calls: 15. On 2.29.0 and earlier it prints LLM calls: 1 (verified by running the same script against 2.29.0).
Possible fix
1. Retry on the discarded tool call itself, not on empty text.
The condition #11665 wanted to retry on is "haystack discarded a malformed tool call". Every generator already detects exactly that and logs a warning, then drops the fact:
| Generator |
Discard site (2.31.0) |
OpenAIChatGenerator |
chat/openai.py:634 |
OpenAIResponsesChatGenerator |
chat/openai_responses.py:611, :825 |
| shared streaming path |
generators/utils.py:134 |
HuggingFaceAPIChatGenerator |
chat/hugging_face_api.py:77 |
HuggingFaceLocalChatGenerator |
chat/hugging_face_local.py:85 |
If that were recorded on the message instead of only logged, the Agent could retry on it directly rather than inferring it from bool(last.text). That is more precise, and it keeps provider knowledge out of agent.py: each generator sets the flag at its own discard site, and the Agent reads one haystack-owned field.
It also means the Agent never needs to know about truncation. A truncated reply simply would not set the flag, so it exits by default. The same goes for other empty-text replies that are currently retried by mistake, such as a reasoning-only reply or a content-filter stop.
2. If keeping the empty-text condition, exclude truncation explicitly.
On OpenAI, the only provider we tested, the signal is:
| OpenAI API |
Generator |
Truncation signal on the message |
| Responses |
OpenAIResponsesChatGenerator |
meta["incomplete_details"]["reason"] == "max_output_tokens" |
| Chat Completions |
OpenAIChatGenerator |
meta["finish_reason"] == "length" |
This is localized to agent.py but needs provider-specific knowledge there, or a normalized finish_reason == "length" that generators populate consistently. Other providers report truncation differently.
The check is inline in run (haystack/components/agents/agent.py:869) and run_async (:1113) on 2.31.0, and extracted into _is_text_exit (:201-211) on 3.0.0.
FAQ Check
System:
- OS: Linux
- Haystack version: 2.31.0
Describe the bug
Under the default
exit_conditions=["text"], a reply that is empty because the model spent its wholemax_output_tokensbudget on reasoning does not exit the loop. TheAgentkeeps calling the LLM untilmax_agent_steps, and when the budget is systematically too small for the task every retry truncates identically, so all of those calls are wasted.Details
An assistant reply can be empty for two different reasons, and the
"text"exit currently treats them the same:max_output_tokensmax_agent_steps#11665 fixed the first row, which was its intent. But the condition it introduced is
bool(last.text), so it changed the second row too.Expected behavior
The "Desired" column above: exit when the reply is empty because it was truncated, and keep #11665's recovery loop for every other empty reply.
Reproduce
What we observed
Measured against the real OpenAI API,
gpt-5.6-terra,OpenAIResponsesChatGenerator(streaming), haystack 2.31.0.With a reasoning budget well below what the task's reasoning pass needs (
max_output_tokens=256,reasoning={"effort": "high"}, on a hard combinatorics prompt), the truncation is deterministic rather than a coin flip:Wrapping the same generator in an
Agentwithmax_agent_steps=5, one tool, and the defaultexit_conditions=["text"]:This becomes worse when
max_agent_stepsis higher. For example,max_agent_stepsis 100 by default.Reduced reproduction, no API key
On 2.31.0 this prints
LLM calls: 15. On 2.29.0 and earlier it printsLLM calls: 1(verified by running the same script against 2.29.0).Possible fix
1. Retry on the discarded tool call itself, not on empty text.
The condition #11665 wanted to retry on is "haystack discarded a malformed tool call". Every generator already detects exactly that and logs a warning, then drops the fact:
OpenAIChatGeneratorchat/openai.py:634OpenAIResponsesChatGeneratorchat/openai_responses.py:611,:825generators/utils.py:134HuggingFaceAPIChatGeneratorchat/hugging_face_api.py:77HuggingFaceLocalChatGeneratorchat/hugging_face_local.py:85If that were recorded on the message instead of only logged, the Agent could retry on it directly rather than inferring it from
bool(last.text). That is more precise, and it keeps provider knowledge out ofagent.py: each generator sets the flag at its own discard site, and the Agent reads one haystack-owned field.It also means the Agent never needs to know about truncation. A truncated reply simply would not set the flag, so it exits by default. The same goes for other empty-text replies that are currently retried by mistake, such as a reasoning-only reply or a content-filter stop.
2. If keeping the empty-text condition, exclude truncation explicitly.
On OpenAI, the only provider we tested, the signal is:
OpenAIResponsesChatGeneratormeta["incomplete_details"]["reason"] == "max_output_tokens"OpenAIChatGeneratormeta["finish_reason"] == "length"This is localized to
agent.pybut needs provider-specific knowledge there, or a normalizedfinish_reason == "length"that generators populate consistently. Other providers report truncation differently.The check is inline in
run(haystack/components/agents/agent.py:869) andrun_async(:1113) on 2.31.0, and extracted into_is_text_exit(:201-211) on 3.0.0.FAQ Check
System: