豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

feat(lifecycle): add on_turn_start and on_turn_end hooks to RunHooksBase (#2671)#1

Open
adityasingh2400 wants to merge 3 commits intomainfrom
feat/turn-lifecycle-hooks-2671-v2
Open

feat(lifecycle): add on_turn_start and on_turn_end hooks to RunHooksBase (#2671)#1
adityasingh2400 wants to merge 3 commits intomainfrom
feat/turn-lifecycle-hooks-2671-v2

Conversation

@adityasingh2400
Copy link
Copy Markdown
Owner

@adityasingh2400 adityasingh2400 commented Apr 14, 2026

Summary

Right now there's no way to react to the start or end of an individual agent turn — you can hook into LLM calls and tool calls, but not the turn boundary itself. This matters for things like compacting context between turns, injecting messages, logging per-turn metrics, or cancelling after a specific turn.

This PR adds on_turn_start and on_turn_end to both RunHooksBase and AgentHooksBase.

Closes openai#2671

What changed

  • Added on_turn_start(context, agent, turn_number) and on_turn_end(context, agent, turn_number) to RunHooksBase and AgentHooksBase in lifecycle.py
  • Both hooks are called in the sync (run.py) and streaming (run_internal/run_loop.py) code paths
  • Turn numbers are 1-indexed and increment each time through the agent loop

Usage example

from agents import Agent, Runner
from agents.lifecycle import RunHooks

class TurnLogger(RunHooks):
    async def on_turn_start(self, context, agent, turn_number):
        print(f"Turn {turn_number} starting")

    async def on_turn_end(self, context, agent, turn_number):
        print(f"Turn {turn_number} complete")

agent = Agent(name="assistant", model="gpt-4o")
await Runner.run(agent, "hello", hooks=TurnLogger())

Tests

Added tests/test_turn_lifecycle_hooks.py covering:

  • Single-turn runs fire exactly once each
  • Multi-turn runs get correct incrementing turn numbers
  • on_turn_start fires before the LLM, on_turn_end fires after tool calls
  • Agent-level hooks (agent.hooks) also fire
  • Streaming path works the same way

Summary by CodeRabbit

  • New Features

    • Added turn-level lifecycle hooks (on_turn_start, on_turn_end) with 1-indexed turn numbers at both runner and agent scopes; hooks run concurrently and can return "continue" or "stop" to control turn flow.
  • Tests

    • Added tests for single/multi-turn and streamed runs, hook ordering relative to LLM callbacks, agent vs runner invocation, and stop/continue control behavior.

…ase (openai#2671)

Both RunHooksBase and AgentHooksBase get two new hook methods:

- on_turn_start(context, agent, turn_number): fires before each LLM call
- on_turn_end(context, agent, turn_number): fires after all tool calls for
  the turn complete (i.e. just before the next-step decision)

Turn numbers are 1-indexed and increment each time through the agent
loop, regardless of handoffs.  The hooks are called in both the sync
and streaming code paths.  Agent-level hooks on agent.hooks are also
called, matching the existing on_tool_start/on_tool_end pattern.

Closes openai#2671
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

📝 Walkthrough

Walkthrough

Added TurnControl type and two async lifecycle hooks—on_turn_start and on_turn_end—to RunHooksBase and AgentHooksBase; the run loop now concurrently invokes runner- and agent-level turn hooks at each agent turn and respects a hook-requested "stop" by halting the run.

Changes

Cohort / File(s) Summary
Lifecycle Hook Declarations
src/agents/lifecycle.py
Added TurnControl = Literal["continue","stop"]; added async on_turn_start(context, agent, turn_number) -> Union[TurnControl, None] and async on_turn_end(context, agent, turn_number) -> None to RunHooksBase and AgentHooksBase. Also added from __future__ import annotations and typing imports.
Hook Invocation in Runner
src/agents/run.py
Invoke runner- and agent-level on_turn_start concurrently via await asyncio.gather(...) (uses _coro.noop_coroutine() fallback); stop-request handling added to raise MaxTurnsExceeded when a hook returns "stop"; added concurrent on_turn_end invocation; imported _coro.
Run Loop Changes
src/agents/run_internal/run_loop.py
Concurrent on_turn_start/on_turn_end calls added; when on_turn_start returns "stop", mark max-turns handled, set streamed result state, emit QueueCompleteSentinel(), and break the turn loop before executing the halted turn.
Package Exports
src/agents/__init__.py
Re-exported RunHooksBase, AgentHooksBase, and TurnControl by adding them to imports and __all__.
Turn Lifecycle Hook Tests
tests/test_turn_lifecycle_hooks.py
New comprehensive tests for on_turn_start/on_turn_end: turn numbering, ordering relative to LLM start/end, runner- vs agent-level hooks, streamed vs non-streamed runs, and stop-control behavior (returning "stop" halts before LLM and prevents on_turn_end for halted turn).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(135,206,235,0.5)
    participant Runner
    end
    rect rgba(144,238,144,0.5)
    participant Agent
    end
    rect rgba(255,228,181,0.5)
    participant Hooks
    end

    Runner->>Hooks: on_turn_start(context, agent, turn_number)
    Runner->>Agent: agent.hooks.on_turn_start(context, agent, turn_number)
    Note right of Runner: await asyncio.gather(...)
    alt any hook returns "stop"
        Hooks-->>Runner: "stop"
        Agent-->>Runner: None/"stop"
        Runner->>Runner: raise MaxTurnsExceeded / emit QueueCompleteSentinel
        Runner-->>Agent: (halt — no LLM call for this turn)
    else all continue
        Hooks-->>Runner: None/"continue"
        Agent-->>Runner: None/"continue"
        Runner->>Agent: proceed with turn (LLM/tool calls)
        Runner->>Hooks: on_turn_end(context, agent, turn_number)
        Runner->>Agent: agent.hooks.on_turn_end(context, agent, turn_number)
        Note right of Runner: await asyncio.gather(...)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I counted hops at turn one’s start,
I twitched my whiskers, played my part.
Start together, end in line,
If a hook says "stop", I stay by the vine.
A happy rabbit marks each turn with heart.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: adding on_turn_start and on_turn_end hooks to RunHooksBase, which is the primary focus of this changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/turn-lifecycle-hooks-2671-v2

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_turn_lifecycle_hooks.py`:
- Around line 115-119: The tests define async hook methods on_llm_start and
on_llm_end that append literal strings using unnecessary f-strings
(call_order.append(f"llm_start") and call_order.append(f"llm_end")), which
triggers Ruff F541; remove the f-string prefixes and use plain string literals
(call_order.append("llm_start") and call_order.append("llm_end")) in the
on_llm_start and on_llm_end implementations to satisfy the linter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdc7731b-45ac-4b18-afdd-c275d8cb7287

📥 Commits

Reviewing files that changed from the base of the PR and between 5c9fb2c and 27d5dbe.

📒 Files selected for processing (4)
  • src/agents/lifecycle.py
  • src/agents/run.py
  • src/agents/run_internal/run_loop.py
  • tests/test_turn_lifecycle_hooks.py

Comment thread tests/test_turn_lifecycle_hooks.py Outdated
- Remove unnecessary f-string prefixes from on_llm_start/on_llm_end (Ruff F541)
- Add missing docstrings to class methods to improve docstring coverage
- Add docstring to OrderTrackingHooks inner class
@adityasingh2400 adityasingh2400 force-pushed the feat/turn-lifecycle-hooks-2671-v2 branch from ff99d90 to 05a4f15 Compare April 16, 2026 16:08
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/agents/run.py`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e8532f9-f6ab-4e6f-a350-24009b7e485a

📥 Commits

Reviewing files that changed from the base of the PR and between 27d5dbe and 05a4f15.

📒 Files selected for processing (4)
  • src/agents/lifecycle.py
  • src/agents/run.py
  • src/agents/run_internal/run_loop.py
  • tests/test_turn_lifecycle_hooks.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/agents/run_internal/run_loop.py
  • src/agents/lifecycle.py

Comment thread src/agents/run.py
Comment on lines +1107 to +1116
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.

Address seratch's review feedback on openai#2911: hooks that only observe
cannot affect agent loop orchestration. This commit adds a TurnControl
return type ('continue' | 'stop') so on_turn_start can now halt the
run before the LLM is called for that turn.

Changes:
- lifecycle.py: on_turn_start now returns Union[TurnControl, None]
  (None and 'continue' are equivalent; 'stop' halts the loop)
- run.py (non-streaming path): checks return value; raises
  MaxTurnsExceeded with descriptive message on 'stop'
- run_internal/run_loop.py (streaming path): checks return value;
  signals QueueCompleteSentinel on 'stop'
- __init__.py: exports TurnControl, RunHooksBase, AgentHooksBase
- tests: 4 new test cases covering stop-on-turn-N, stop-on-turn-1,
  explicit 'continue', and agent-level stop

The MaxTurnsExceeded raise on 'stop' keeps behaviour consistent with
the existing max_turns limit: callers can catch and inspect
.run_data if needed.
@adityasingh2400 adityasingh2400 force-pushed the feat/turn-lifecycle-hooks-2671-v2 branch from 05a4f15 to f1cafcd Compare April 17, 2026 03:11
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/agents/lifecycle.py`:
- Around line 115-118: The docstring for on_turn_start incorrectly claims
raising StopAgentRun will gracefully halt a run, but runners only handle the
hook's return value; remove the exception-based stopping text and only document
returning "stop" (or None/"continue") as the supported mechanism in the
on_turn_start documentation, and ensure the symbol names mentioned are
on_turn_start and StopAgentRun so future implementers know the discrepancy if
runners are later updated.

In `@src/agents/run_internal/run_loop.py`:
- Around line 935-944: The resumed-interrupted-turn path (where
resolve_interrupted_turn(...) completes a previously interrupted turn) never
emits the on_turn_end hooks, so add the same hook invocation used at the bottom
of the loop to the resume path: after resolve_interrupted_turn(...) finishes,
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())) so resumed
runs fire both the global hooks.on_turn_end and the agent-specific
current_agent.hooks.on_turn_end; locate the resume branch around
resolve_interrupted_turn and mirror the existing invocation around on_turn_end
in run_loop.py.
- Around line 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).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 68cf7fac-dc17-4621-858a-5666b3fcf559

📥 Commits

Reviewing files that changed from the base of the PR and between 05a4f15 and f1cafcd.

📒 Files selected for processing (5)
  • src/agents/__init__.py
  • src/agents/lifecycle.py
  • src/agents/run.py
  • src/agents/run_internal/run_loop.py
  • tests/test_turn_lifecycle_hooks.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_turn_lifecycle_hooks.py
  • src/agents/run.py

Comment thread src/agents/lifecycle.py
Comment on lines +115 to +118
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.
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

Don’t document exception-based stopping unless the runners support it.

These lines say on_turn_start can raise StopAgentRun to halt gracefully, but the reviewed runner changes only check the hook’s return value for "stop". A hook exception here will currently propagate as an error instead of taking the graceful stop path.

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

In `@src/agents/lifecycle.py` around lines 115 - 118, The docstring for
on_turn_start incorrectly claims raising StopAgentRun will gracefully halt a
run, but runners only handle the hook's return value; remove the exception-based
stopping text and only document returning "stop" (or None/"continue") as the
supported mechanism in the on_turn_start documentation, and ensure the symbol
names mentioned are on_turn_start and StopAgentRun so future implementers know
the discrepancy if runners are later updated.

Comment on lines +823 to +844
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
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).

Comment on lines +935 to +944
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

Fire on_turn_end when a resumed interrupted turn finishes too.

This is the only place that emits turn-end hooks, so the resumed path around src/agents/run_internal/run_loop.py:608-705 never calls on_turn_end after resolve_interrupted_turn(...) completes a previously interrupted turn. Resumed runs will therefore miss the hook for exactly the turn whose tool calls just finished.

🤖 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 935 - 944, The
resumed-interrupted-turn path (where resolve_interrupted_turn(...) completes a
previously interrupted turn) never emits the on_turn_end hooks, so add the same
hook invocation used at the bottom of the loop to the resume path: after
resolve_interrupted_turn(...) finishes, 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())) so resumed runs fire both
the global hooks.on_turn_end and the agent-specific
current_agent.hooks.on_turn_end; locate the resume branch around
resolve_interrupted_turn and mirror the existing invocation around on_turn_end
in run_loop.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: better support for agent state changes between turns

1 participant