Skip to content

Commit a6fa8c8

Browse files
authored
feat: Add exit_reason to Agent (#12074)
1 parent a5514e7 commit a6fa8c8

5 files changed

Lines changed: 228 additions & 47 deletions

File tree

docs-website/docs/pipeline-components/agents-1/agent.mdx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ The `Agent` returns a dictionary containing:
4141
- `step_count`: the number of steps the agent ran,
4242
- `token_usage`: aggregated token usage summed across every LLM call in the run,
4343
- `tool_call_counts`: how many times each tool was invoked, keyed by tool name,
44+
- `exit_reason`: why the agent stopped, useful for routing its output downstream,
4445
- Additional dynamic keys based on `state_schema`.
4546

4647
### Run Metadata
4748

48-
The `step_count`, `token_usage`, and `tool_call_counts` outputs are populated automatically during a run. They are added to the agent's `state_schema` behind the scenes, so tools registered with `inputs_from_state` can read them mid-run. They are outputs only — they cannot be passed as inputs to `run()` or `run_async()`, and using them as keys in your own `state_schema` raises a `ValueError`. See [State](./state.mdx#schema-definition) for details.
49+
The `step_count`, `token_usage`, `tool_call_counts`, and `exit_reason` outputs are populated automatically during a run. They are added to the agent's `state_schema` behind the scenes, so tools registered with `inputs_from_state` and [hooks](./hooks.mdx) can read them from the live `State`. They are outputs only — they cannot be passed as inputs to `run()` or `run_async()`, and using them as keys in your own `state_schema` raises a `ValueError`. See [State](./state.mdx#schema-definition) for details.
4950

5051
```python
5152
response = agent.run(messages=[ChatMessage.from_user("What is 7 * (4 + 2)?")])
@@ -55,6 +56,32 @@ print(
5556
response["token_usage"],
5657
) # {"prompt_tokens": 512, "completion_tokens": 86, ...}
5758
print(response["tool_call_counts"]) # {"calculator": 1}
59+
print(response["exit_reason"]) # "text"
60+
```
61+
62+
### Exit reason
63+
64+
The `exit_reason` output tells you why the agent stopped, which makes it easy to route the agent's output downstream — for example, with a [`ConditionalRouter`](../routers/conditionalrouter.mdx). It is one of:
65+
66+
- `"text"`: the model returned a reply with no tool calls.
67+
- the name of the tool that satisfied a tool exit condition. In this case `last_message` is that tool's result — a tool-result `ChatMessage` whose `text` is empty — so `exit_reason` tells you how to consume it.
68+
- `"max_agent_steps"`: the agent reached `max_agent_steps` before meeting an exit condition.
69+
70+
Because `exit_reason` is available on the live `State`, an `after_run` [hook](./hooks.mdx) can read it to react to how the run ended — for example, appending a fallback answer when the step budget is exhausted before the agent finished:
71+
72+
```python
73+
from haystack.components.agents.state import State
74+
from haystack.dataclasses import ChatMessage
75+
from haystack.hooks import hook
76+
77+
78+
@hook
79+
def fallback_on_max_steps(state: State) -> None:
80+
if state.get("exit_reason") == "max_agent_steps":
81+
state.set(
82+
"messages",
83+
[ChatMessage.from_assistant("Sorry, I ran out of steps before finishing.")],
84+
)
5885
```
5986

6087
## Parameters

docs-website/docs/pipeline-components/agents-1/state.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ If you don't specify a handler, State automatically assigns a default based on t
7070
:::info Reserved keys
7171
The `Agent` manages some state keys itself and rejects them in a user-provided `state_schema` with a `ValueError`:
7272

73-
- The run-metadata keys `step_count`, `token_usage`, and `tool_call_counts`, which the Agent populates automatically during a run: tools can read them mid-run via `inputs_from_state`, and they are returned in the result dictionary.
73+
- The run-metadata keys `step_count`, `token_usage`, `tool_call_counts`, and `exit_reason`, which the Agent populates automatically during a run: tools and hooks can read them from the live `State`, and they are returned in the result dictionary. `exit_reason` reports why the Agent stopped (`"text"`, the name of the tool that triggered a tool exit condition, or `"max_agent_steps"`).
7474
- The hook-facing keys `continue_run` (set by an `on_exit` hook to keep the Agent running), `tools` (the tools available in the current step, for hooks to inspect), and `hook_context` (the request-scoped resources passed to `Agent.run(hook_context={...})`).
7575

7676
If one of your state keys clashes, rename it (for example, `my_token_usage`).

haystack/components/agents/agent.py

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,18 @@
6363
# Regex to extract the role from a Jinja2 message block, e.g. {% message role="user" %}
6464
_JINJA2_MESSAGE_ROLE_RE = re.compile(r'\{%\s*message\s+role\s*=\s*["\'](\w+)["\']')
6565

66+
# `exit_reason` values the Agent sets when it stops without a tool exit condition: a tool-call-free reply, or the
67+
# `max_agent_steps` budget running out. A tool exit condition instead reports the tool's name.
68+
_EXIT_REASON_TEXT = "text"
69+
_EXIT_REASON_MAX_STEPS = "max_agent_steps"
70+
6671
# Run-metadata state keys the Agent populates automatically during a run. Users may not define them in their own
6772
# `state_schema`, and they are exposed as Agent outputs only (not inputs).
6873
_RUN_METADATA_STATE_KEYS: dict[str, dict[str, Any]] = {
6974
"step_count": {"type": int, "handler": replace_values},
7075
"token_usage": {"type": dict[str, Any], "handler": replace_values},
7176
"tool_call_counts": {"type": dict[str, int], "handler": replace_values},
77+
"exit_reason": {"type": str, "handler": replace_values},
7278
}
7379

7480
# Internal state keys the Agent manages for run control and hooks. Like run-metadata keys they are reserved and cannot
@@ -933,6 +939,7 @@ def _initialize_fresh_execution(
933939
state.set("step_count", 0)
934940
state.set("token_usage", {})
935941
state.set("tool_call_counts", {tool.name: 0 for tool in flat_tools})
942+
state.set("exit_reason", None)
936943
state.set("continue_run", False)
937944
state.set("hook_context", hook_context or {})
938945

@@ -1031,6 +1038,10 @@ def run(
10311038
- "token_usage": Aggregated token usage from every LLM call in the run, summed from each LLM message's
10321039
`meta["usage"]`.
10331040
- "tool_call_counts": Mapping of tool name to the number of times that tool was invoked.
1041+
- "exit_reason": Why the Agent stopped, useful for routing the output downstream (e.g. with a
1042+
`ConditionalRouter`). One of: `"text"` (the model returned a reply with no tool calls), the name of
1043+
the tool that satisfied a tool exit condition (in which case `last_message` is that tool's result),
1044+
or `"max_agent_steps"` (the Agent hit `max_agent_steps` before meeting an exit condition).
10341045
- Any additional keys defined in the `state_schema`.
10351046
"""
10361047
agent_inputs = {"messages": messages, "streaming_callback": streaming_callback, **kwargs}
@@ -1052,11 +1063,14 @@ def run(
10521063
while exe_context.counter < self.max_agent_steps:
10531064
if not self._run_step(exe_context, span):
10541065
break
1055-
if exe_context.counter >= self.max_agent_steps:
1066+
else:
1067+
# Reached only when the loop ends without a `break`. A `break` means a step already set its own
1068+
# `exit_reason`, so this branch runs only when `max_agent_steps` is why the Agent stopped.
10561069
logger.warning(
10571070
"Agent reached maximum agent steps of {max_agent_steps}, stopping.",
10581071
max_agent_steps=self.max_agent_steps,
10591072
)
1073+
exe_context.state.set("exit_reason", _EXIT_REASON_MAX_STEPS)
10601074
_run_hooks(self.hooks, AFTER_RUN, exe_context.state)
10611075
result = _public_outputs(exe_context.state)
10621076
if msgs := result.get("messages"):
@@ -1105,6 +1119,10 @@ async def run_async(
11051119
- "token_usage": Aggregated token usage from every LLM call in the run, summed from each LLM message's
11061120
`meta["usage"]`.
11071121
- "tool_call_counts": Mapping of tool name to the number of times that tool was invoked.
1122+
- "exit_reason": Why the Agent stopped, useful for routing the output downstream (e.g. with a
1123+
`ConditionalRouter`). One of: `"text"` (the model returned a reply with no tool calls), the name of
1124+
the tool that satisfied a tool exit condition (in which case `last_message` is that tool's result),
1125+
or `"max_agent_steps"` (the Agent hit `max_agent_steps` before meeting an exit condition).
11081126
- Any additional keys defined in the `state_schema`.
11091127
"""
11101128
agent_inputs = {"messages": messages, "streaming_callback": streaming_callback, **kwargs}
@@ -1126,11 +1144,14 @@ async def run_async(
11261144
while exe_context.counter < self.max_agent_steps:
11271145
if not await self._run_step_async(exe_context, span):
11281146
break
1129-
if exe_context.counter >= self.max_agent_steps:
1147+
else:
1148+
# Reached only when the loop ends without a `break`. A `break` means a step already set its own
1149+
# `exit_reason`, so this branch runs only when `max_agent_steps` is why the Agent stopped.
11301150
logger.warning(
11311151
"Agent reached maximum agent steps of {max_agent_steps}, stopping.",
11321152
max_agent_steps=self.max_agent_steps,
11331153
)
1154+
exe_context.state.set("exit_reason", _EXIT_REASON_MAX_STEPS)
11341155
await _run_hooks_async(self.hooks, AFTER_RUN, exe_context.state)
11351156
result = _public_outputs(exe_context.state)
11361157
if msgs := result.get("messages"):
@@ -1171,6 +1192,7 @@ def _run_step(self, exe_context: _ExecutionContext, agent_span: tracing.Span) ->
11711192
if not current_tools or _is_text_exit(llm_messages):
11721193
exe_context.counter += 1
11731194
exe_context.state.set("step_count", exe_context.counter)
1195+
exe_context.state.set("exit_reason", _EXIT_REASON_TEXT)
11741196
return self._continue_after_exit_hooks(exe_context)
11751197

11761198
_run_hooks(self.hooks, BEFORE_TOOL, exe_context.state)
@@ -1191,10 +1213,13 @@ def _run_step(self, exe_context: _ExecutionContext, agent_span: tracing.Span) ->
11911213

11921214
exe_context.counter += 1
11931215
exe_context.state.set("step_count", exe_context.counter)
1194-
exit_triggered = self.exit_conditions != ["text"] and self._check_exit_conditions(
1195-
llm_messages=pending_tool_call_messages, tool_messages=tool_messages
1216+
exit_condition_tool = (
1217+
None
1218+
if self.exit_conditions == ["text"]
1219+
else self._check_exit_conditions(llm_messages=pending_tool_call_messages, tool_messages=tool_messages)
11961220
)
1197-
if exit_triggered:
1221+
if exit_condition_tool is not None:
1222+
exe_context.state.set("exit_reason", exit_condition_tool)
11981223
return self._continue_after_exit_hooks(exe_context)
11991224
return True
12001225

@@ -1231,6 +1256,7 @@ async def _run_step_async(self, exe_context: _ExecutionContext, agent_span: trac
12311256
if not current_tools or _is_text_exit(llm_messages):
12321257
exe_context.counter += 1
12331258
exe_context.state.set("step_count", exe_context.counter)
1259+
exe_context.state.set("exit_reason", _EXIT_REASON_TEXT)
12341260
return await self._continue_after_exit_hooks_async(exe_context)
12351261

12361262
await _run_hooks_async(self.hooks, BEFORE_TOOL, exe_context.state)
@@ -1251,50 +1277,45 @@ async def _run_step_async(self, exe_context: _ExecutionContext, agent_span: trac
12511277

12521278
exe_context.counter += 1
12531279
exe_context.state.set("step_count", exe_context.counter)
1254-
exit_triggered = self.exit_conditions != ["text"] and self._check_exit_conditions(
1255-
llm_messages=pending_tool_call_messages, tool_messages=tool_messages
1280+
exit_condition_tool = (
1281+
None
1282+
if self.exit_conditions == ["text"]
1283+
else self._check_exit_conditions(llm_messages=pending_tool_call_messages, tool_messages=tool_messages)
12561284
)
1257-
if exit_triggered:
1285+
if exit_condition_tool is not None:
1286+
exe_context.state.set("exit_reason", exit_condition_tool)
12581287
return await self._continue_after_exit_hooks_async(exe_context)
12591288
return True
12601289

1261-
def _check_exit_conditions(self, llm_messages: list[ChatMessage], tool_messages: list[ChatMessage]) -> bool:
1290+
def _check_exit_conditions(self, llm_messages: list[ChatMessage], tool_messages: list[ChatMessage]) -> str | None:
12621291
"""
1263-
Decide whether the agent should stop looping.
1292+
Decide whether the agent should stop looping and, if so, on which tool.
12641293
1265-
Returns True when the model called at least one tool listed in `exit_conditions` and
1266-
that tool did not error. Every tool call in the message is checked, so the order of
1267-
parallel tool calls does not matter.
1294+
Returns the name of the tool that triggered an exit condition: the model called at least one tool listed in
1295+
`exit_conditions` and that tool did not error. Every tool call in the message is checked, so the order of
1296+
parallel tool calls does not matter. When several exit-condition tools are called in the same step, the first
1297+
one encountered is returned.
12681298
12691299
:param llm_messages: List of messages from the LLM
12701300
:param tool_messages: List of messages from tool execution.
1271-
:return: True if an exit condition is met and there are no errors, False otherwise
1301+
:return: The name of the tool that satisfied an exit condition, or None if none did (or one errored).
12721302
"""
1273-
matched_exit_conditions: set[str] = set()
1274-
has_errors = False
1303+
errored_tools = {
1304+
tool_msg.tool_call_result.origin.tool_name
1305+
for tool_msg in tool_messages
1306+
if tool_msg.tool_call_result is not None and tool_msg.tool_call_result.error
1307+
}
12751308

1309+
first_match: str | None = None
12761310
for msg in llm_messages:
12771311
for tool_call in msg.tool_calls:
12781312
if tool_call.tool_name not in self.exit_conditions:
12791313
continue
1280-
matched_exit_conditions.add(tool_call.tool_name)
1281-
1282-
# Check if any error is specifically from the tool matching the exit condition
1283-
tool_errors = [
1284-
tool_msg.tool_call_result.error
1285-
for tool_msg in tool_messages
1286-
if tool_msg.tool_call_result is not None
1287-
and tool_msg.tool_call_result.origin.tool_name == tool_call.tool_name
1288-
]
1289-
if any(tool_errors):
1290-
has_errors = True
1291-
# No need to check further if we found an error
1292-
break
1293-
if has_errors:
1294-
break
1295-
1296-
# Only return True if at least one exit condition was matched AND none had errors
1297-
return bool(matched_exit_conditions) and not has_errors
1314+
# An errored exit-condition tool cancels the exit, even if another exit-condition tool succeeded.
1315+
if tool_call.tool_name in errored_tools:
1316+
return None
1317+
first_match = first_match or tool_call.tool_name
1318+
return first_match
12981319

12991320
def _continue_after_exit_hooks(self, exe_context: _ExecutionContext) -> bool:
13001321
"""
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
upgrade:
3+
- |
4+
``exit_reason`` is now a reserved state key on ``Agent``. If you defined a custom ``state_schema`` key named
5+
``exit_reason``, rename it: the Agent now raises a ``ValueError`` at initialization when a reserved key is
6+
redefined.
7+
features:
8+
- |
9+
``Agent`` now returns an ``exit_reason`` output reporting why the run stopped, making it easier to route the
10+
Agent's output downstream (for example with a ``ConditionalRouter``). It is one of: ``"text"`` (the model
11+
returned a reply with no tool calls), the name of the tool that satisfied a tool exit condition (in which case
12+
``last_message`` is that tool's result), or ``"max_agent_steps"`` (the Agent hit ``max_agent_steps`` before
13+
meeting an exit condition). The reason is also available to hooks via ``state.get("exit_reason")``, so an
14+
``after_run`` hook can, for instance, append a fallback answer when the step budget is exhausted.

0 commit comments

Comments
 (0)