Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
102 changes: 101 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,57 @@ 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"`` (or raising :class:`StopAgentRun`) will halt 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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.

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 +211,43 @@ 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.

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
32 changes: 31 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 @@ -968,6 +968,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 +1112,17 @@ def _with_reasoning_item_id_policy(result: RunResult) -> RunResult:
last_saved_input_snapshot_for_rewind = None
should_run_agent_start_hooks = False

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()
),
)
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

Don't emit on_turn_end before an interrupted turn actually finishes.

When run_single_turn(...) yields NextStepInterruption, Line 1107 still fires on_turn_end(...) even though the turn is only paused for approval/resume. The resumed path above (Lines 648-760) then completes that same turn without re-emitting the hook, so interrupted runs observe on_turn_end too early and never at the real completion point.

Please skip this callback for NextStepInterruption and emit it only once the resumed turn reaches a non-interrupted step. The same fix should be mirrored in src/agents/run_internal/run_loop.py:923-931 to keep sync and streaming behavior aligned. A regression test for the interrupt/resume flow would also help here.

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

In `@src/agents/run.py` around lines 1107 - 1116, The on_turn_end hook is being
emitted prematurely when run_single_turn yields NextStepInterruption; update the
logic around the await asyncio.gather call that invokes hooks.on_turn_end and
current_agent.hooks.on_turn_end so it skips calling those callbacks if the turn
was interrupted (i.e., detected NextStepInterruption returned from
run_single_turn) and only invokes on_turn_end when the resumed path completes a
non-interrupted step; apply the same conditional guard/change to the analogous
block in run_internal.run_loop (the section around run_loop lines handling
on_turn_end) so sync and streaming behavior match, and add a regression test
exercising interrupt -> resume -> complete to assert on_turn_end is emitted
exactly once at final completion.


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
34 changes: 34 additions & 0 deletions src/agents/run_internal/run_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,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 +932,17 @@ async def _save_stream_items_without_count(
tool_use_tracker
)

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()
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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