Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
ToolCallOutputItem,
TResponseInputItem,
)
from .lifecycle import AgentHooks, RunHooks
from .lifecycle import AgentHooks, AgentHooksBase, RunHooks, RunHooksBase, TurnControl
from .memory import (
OpenAIConversationsSession,
OpenAIResponsesCompactionArgs,
Expand Down Expand Up @@ -361,7 +361,10 @@ def enable_verbose_stdout_logging():
"ReasoningItem",
"ItemHelpers",
"RunHooks",
"RunHooksBase",
"AgentHooks",
"AgentHooksBase",
"TurnControl",
"Session",
"SessionABC",
"SessionSettings",
Expand Down
109 changes: 108 additions & 1 deletion src/agents/lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from typing import Any, Generic, Optional
from __future__ import annotations

from typing import Any, Generic, Literal, Optional, Union

from typing_extensions import TypeVar

Expand All @@ -9,10 +11,26 @@

TAgent = TypeVar("TAgent", bound=AgentBase, default=AgentBase)

TurnControl = Literal["continue", "stop"]
"""Return value for :meth:`RunHooksBase.on_turn_start` / :meth:`AgentHooksBase.on_turn_start`.

* ``"continue"`` (default / ``None``) – proceed with the turn as normal.
* ``"stop"`` – abort the run gracefully after this hook returns, exactly as if
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace the en dashes in the TurnControl docstring.

Ruff flags the characters on Lines 17-18 as ambiguous punctuation (RUF001), so this will fail in strict linting. Use plain - here instead.

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 17-17: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)


[warning] 18-18: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agents/lifecycle.py` around lines 17 - 18, The TurnControl docstring
contains en dashes (–) that trigger RUF001; open the TurnControl docstring and
replace each en dash with a plain hyphen '-' (e.g., in the `"continue" (default
/ None) – proceed...` and `"stop" – abort...` phrases), saving the updated
string in the TurnControl definition so linting passes.

``max_turns`` had been reached. The model is **not** called for this turn and
:meth:`on_turn_end` is **not** fired.
"""


class RunHooksBase(Generic[TContext, TAgent]):
"""A class that receives callbacks on various lifecycle events in an agent run. Subclass and
override the methods you need.

Turn-lifecycle hooks
--------------------
:meth:`on_turn_start` and :meth:`on_turn_end` fire once per iteration of the
agent loop. :meth:`on_turn_start` may return ``"stop"`` to halt the run
gracefully before the LLM is called for that turn (useful for implementing
custom turn-budget logic, external kill-switches, etc.).
"""

async def on_llm_start(
Expand Down Expand Up @@ -86,12 +104,60 @@ async def on_tool_end(
"""Called immediately after a local tool is invoked."""
pass

async def on_turn_start(
self,
context: RunContextWrapper[TContext],
agent: TAgent,
turn_number: int,
) -> Union[TurnControl, None]:
"""Called at the start of each agent turn, before the LLM is invoked.

Returning ``"stop"`` halts the run gracefully: the model is **not** called
for this turn and :meth:`on_turn_end` is **not** fired. Returning ``None``
or ``"continue"`` proceeds normally.

Args:
context: The run context wrapper.
agent: The current agent.
turn_number: The 1-indexed turn number (increments each time through the
agent loop).

Returns:
``None`` / ``"continue"`` to proceed, or ``"stop"`` to halt the run.
"""
return None

async def on_turn_end(
self,
context: RunContextWrapper[TContext],
agent: TAgent,
turn_number: int,
) -> None:
"""Called at the end of each agent turn, after all tool calls for that turn complete.

When a turn is interrupted (for example, by a tool approval requirement),
:meth:`on_turn_end` is **not** fired for that turn. It will fire once the
turn is resumed and reaches a non-interrupted outcome.

Args:
context: The run context wrapper.
agent: The current agent.
turn_number: The 1-indexed turn number.
"""
pass


class AgentHooksBase(Generic[TContext, TAgent]):
"""A class that receives callbacks on various lifecycle events for a specific agent. You can
set this on `agent.hooks` to receive events for that specific agent.

Subclass and override the methods you need.

Turn-lifecycle hooks
--------------------
:meth:`on_turn_start` and :meth:`on_turn_end` fire once per iteration of the
agent loop. :meth:`on_turn_start` may return ``"stop"`` to halt the run
gracefully before the LLM is called for that turn.
"""

async def on_start(self, context: AgentHookContext[TContext], agent: TAgent) -> None:
Expand Down Expand Up @@ -148,6 +214,47 @@ async def on_tool_end(
"""Called immediately after a local tool is invoked."""
pass

async def on_turn_start(
self,
context: RunContextWrapper[TContext],
agent: TAgent,
turn_number: int,
) -> Union[TurnControl, None]:
"""Called at the start of each agent turn, before the LLM is invoked.

Returning ``"stop"`` halts the run gracefully before the model is called.
Returning ``None`` or ``"continue"`` proceeds normally.

Args:
context: The run context wrapper.
agent: The current agent.
turn_number: The 1-indexed turn number (increments each time through the
agent loop).

Returns:
``None`` / ``"continue"`` to proceed, or ``"stop"`` to halt the run.
"""
return None

async def on_turn_end(
self,
context: RunContextWrapper[TContext],
agent: TAgent,
turn_number: int,
) -> None:
"""Called at the end of each agent turn, after all tool calls for that turn complete.

When a turn is interrupted (for example, by a tool approval requirement),
:meth:`on_turn_end` is **not** fired for that turn. It will fire once the
turn is resumed and reaches a non-interrupted outcome.

Args:
context: The run context wrapper.
agent: The current agent.
turn_number: The 1-indexed turn number.
"""
pass

async def on_llm_start(
self,
context: RunContextWrapper[TContext],
Expand Down
48 changes: 47 additions & 1 deletion src/agents/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
from .tracing import Span, SpanError, agent_span, get_current_trace
from .tracing.context import TraceCtxManager, create_trace_for_run
from .tracing.span_data import AgentSpanData
from .util import _error_tracing
from .util import _coro, _error_tracing

DEFAULT_AGENT_RUNNER: AgentRunner = None # type: ignore
# the value is set at the end of the module
Expand Down Expand Up @@ -756,6 +756,21 @@ def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:
run_state=run_state,
)

# The resumed turn reached a non-interrupted outcome, so fire
# on_turn_end exactly once for it before handling the next step.
await asyncio.gather(
hooks.on_turn_end(
context_wrapper, current_agent, current_turn
),
(
current_agent.hooks.on_turn_end(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)

if isinstance(turn_result.next_step, NextStepRunAgain):
continue

Expand Down Expand Up @@ -968,6 +983,25 @@ def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:

logger.debug("Running agent %s (turn %s)", current_agent.name, current_turn)

run_hook_control, agent_hook_control = await asyncio.gather(
hooks.on_turn_start(context_wrapper, current_agent, current_turn),
(
current_agent.hooks.on_turn_start(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)
if run_hook_control == "stop" or agent_hook_control == "stop":
logger.debug(
"Turn %s: on_turn_start hook requested stop; halting run.",
current_turn,
)
raise MaxTurnsExceeded(
f"Run halted by on_turn_start hook at turn {current_turn}"
)
Comment on lines +996 to +1003

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route hook-triggered stops through the existing max-turns handler.

Line 1001 raises MaxTurnsExceeded directly, so a stop returned from on_turn_start skips the resolve_run_error_handler_result(...) flow used by the real max-turns branch below. That breaks the new “same as max_turns” contract for callers that have a max-turns handler configured, because they’ll get an exception here instead of the handled final output path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agents/run.py` around lines 996 - 1003, Stop requests from on_turn_start
should be handled via the existing max-turns handler flow instead of raising
MaxTurnsExceeded directly; modify the branch that checks
run_hook_control/agent_hook_control so it calls resolve_run_error_handler_result
(passing the same MaxTurnsExceeded instance or an indicator that this is a
max-turns stop) and returns/uses its result, rather than raising the exception,
so callers with a configured max-turns handler receive the handled final output
path; reference the run_hook_control/agent_hook_control check, logger.debug
call, on_turn_start hook, current_turn, MaxTurnsExceeded, and
resolve_run_error_handler_result when making the change.


if session_persistence_enabled:
try:
last_saved_input_snapshot_for_rewind = (
Expand Down Expand Up @@ -1093,6 +1127,18 @@ def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:
last_saved_input_snapshot_for_rewind = None
should_run_agent_start_hooks = False

if not isinstance(turn_result.next_step, NextStepInterruption):
await asyncio.gather(
hooks.on_turn_end(context_wrapper, current_agent, current_turn),
(
current_agent.hooks.on_turn_end(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)

model_responses.append(turn_result.model_response)
original_input = turn_result.original_input
# For model input, use new_step_items (filtered on handoffs).
Expand Down
48 changes: 48 additions & 0 deletions src/agents/run_internal/run_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,19 @@ async def _save_stream_items_without_count(
)
break

# The resumed streaming turn reached a non-interrupted outcome,
# so fire on_turn_end exactly once for it before handling next step.
await asyncio.gather(
hooks.on_turn_end(context_wrapper, current_agent, current_turn),
(
current_agent.hooks.on_turn_end(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)

if isinstance(turn_result.next_step, NextStepHandoff):
current_agent = turn_result.next_step.new_agent
if run_state is not None:
Expand Down Expand Up @@ -820,6 +833,29 @@ async def _save_stream_items_without_count(
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
break

run_hook_control, agent_hook_control = await asyncio.gather(
hooks.on_turn_start(context_wrapper, current_agent, current_turn),
(
current_agent.hooks.on_turn_start(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)
if run_hook_control == "stop" or agent_hook_control == "stop":
logger.debug(
"Turn %s: on_turn_start hook requested stop; halting run.",
current_turn,
)
streamed_result._max_turns_handled = True
streamed_result.current_turn = current_turn
if run_state is not None:
run_state._current_turn = current_turn
run_state._current_step = None
streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
break
Comment on lines +836 to +857

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route hook-requested stops through the existing max-turns path.

Lines 833-844 terminate the stream directly, but they skip the max_turns handler flow already implemented at Lines 746-821. That means a configured max_turns error handler cannot translate on_turn_start(...)->"stop" into a final output, and it also diverges from the sync path in src/agents/run.py:971-988, which raises MaxTurnsExceeded for the same condition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agents/run_internal/run_loop.py` around lines 823 - 844, The
on_turn_start stop branch currently terminates the stream directly; instead,
mirror the existing max-turns flow by setting streamed_result._max_turns_handled
= True, updating streamed_result.current_turn and
run_state._current_turn/_current_step as you already do, then raise the same
MaxTurnsExceeded exception (the one used by the max-turns handler) so the outer
max-turns handling path processes the stop and produces the unified final output
(preserve the logger.debug and QueueCompleteSentinel enqueueing behavior before
raising so behavior matches the sync path).


if current_turn == 1:
all_input_guardrails = starting_agent.input_guardrails + (
run_config.input_guardrails or []
Expand Down Expand Up @@ -909,6 +945,18 @@ async def _save_stream_items_without_count(
tool_use_tracker
)

if not isinstance(turn_result.next_step, NextStepInterruption):
await asyncio.gather(
hooks.on_turn_end(context_wrapper, current_agent, current_turn),
(
current_agent.hooks.on_turn_end(
context_wrapper, current_agent, current_turn
)
if current_agent.hooks
else _coro.noop_coroutine()
),
)

streamed_result.raw_responses = streamed_result.raw_responses + [
turn_result.model_response
]
Expand Down
Loading