Skip to content

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

Open
adityasingh2400 wants to merge 4 commits into
mainfrom
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 4 commits into
mainfrom
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 at runner and agent scopes: on_turn_start and on_turn_end run concurrently and can return "continue" or "stop" to control turn flow; runner package now exposes these base hook types.
  • Behavior

    • on_turn_end is skipped for interrupted attempts and fired exactly once after resume; returning "stop" halts a turn before the LLM is invoked.
  • Tests

    • New tests cover single/multi-turn, streamed runs, interrupt/resume, hook ordering, agent vs runner invocation, and stop/continue 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

Adds a TurnControl type and on_turn_start/on_turn_end async hooks to RunHooksBase and AgentHooksBase; runner and agent turn hooks are invoked concurrently each agent turn, and a hook-returned "stop" halts the run before that turn's LLM call.

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; enabled postponed type evaluation and adjusted typing imports.
Hook Invocation in Runner
src/agents/run.py
Concurrently invoke runner- and agent-level on_turn_start / on_turn_end via asyncio.gather(...) with _coro.noop_coroutine() fallback; treat "stop" return by halting the run (raise MaxTurnsExceeded). Imported _coro.
Run Loop Adjustments
src/agents/run_internal/run_loop.py
Main loop now checks concurrent on_turn_start results and stops the loop if either returns "stop"; on_turn_end is invoked only for completed (non-interrupted) turns and resumed turns fire on_turn_end once after successful resume.
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 tests covering runner- and agent-level on_turn_start/on_turn_end: ordering vs LLM start/end, turn numbering, streamed vs non-streamed runs, interruption/resume behavior, and stop-control semantics.

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
    rect rgba(221,160,221,0.5)
    participant LLM/Tools
    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: "stop"/None
        Runner->>Runner: mark max-turns, emit QueueCompleteSentinel, raise MaxTurnsExceeded
        Note right of Runner: halted — no LLM/Tools call for this turn
    else all continue
        Hooks-->>Runner: None/"continue"
        Agent-->>Runner: None/"continue"
        Runner->>LLM/Tools: execute turn (LLM/tool calls)
        LLM/Tools-->>Runner: turn result
        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

🎯 4 (Complex) | ⏱️ ~45 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 pause mid-climb.
A rabbit cheers each turn, one at a time.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately reflects the main change: adding on_turn_start and on_turn_end hooks to RunHooksBase, which is the primary objective of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 Outdated
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 Outdated
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 thread src/agents/run_internal/run_loop.py Outdated
CodeRabbit flagged that on_turn_end was being emitted prematurely whenever a
turn ended in NextStepInterruption, and was never emitted at all when the
resumed path completed the previously interrupted turn.

This change:

  * Wraps the on_turn_end gather in run.py and run_internal/run_loop.py with
    a not-isinstance(..., NextStepInterruption) guard so an interrupted turn
    no longer fires on_turn_end before the run returns.
  * Adds an on_turn_end gather on the resume path (both sync and streaming)
    after the NextStepInterruption check, so a successfully resumed turn
    fires on_turn_end exactly once before handling RunAgain / FinalOutput /
    Handoff.
  * Updates the on_turn_start docstring to drop the StopAgentRun reference
    (only the return-value mechanism is supported by the runners) and adds a
    note to on_turn_end clarifying that interrupted turns defer the call
    until they resume to a non-interrupted outcome.
  * Adds two regression tests (sync + streaming) covering interrupt then
    approve then resume, asserting on_turn_end fires exactly once per
    completed turn and never on the interrupted attempt.
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: 2

🧹 Nitpick comments (1)
tests/test_turn_lifecycle_hooks.py (1)

255-340: Add one streamed stop-control regression.

The new "stop" behavior is only pinned on Runner.run(...) here, but Runner.run_streamed(...) goes through its own branch in src/agents/run_internal/run_loop.py. Mirroring one of these stop tests through the streamed runner would protect the parity claim the same way the interrupt/resume tests already do.

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

In `@tests/test_turn_lifecycle_hooks.py` around lines 255 - 340, Add a mirrored
streamed test that verifies returning "stop" from RunHooks.on_turn_start halts
the streamed loop: create a test like test_run_hook_stop_halts_loop but call
Runner.run_streamed(...) (or the streamed API used in tests) with
StopAfterTurnRunHooks(stop_after=1), a FakeModel with the same two-turn outputs,
and assert that the streamed call raises agents.exceptions.MaxTurnsExceeded with
"halted by on_turn_start hook"; also assert hooks.turn_starts == [1,2] and
hooks.turn_ends == [1] to match the non-streamed behavior. Ensure the new test
uses the same helper symbols (StopAfterTurnRunHooks, FakeModel,
get_function_tool, get_function_tool_call, get_text_message) and mirrors the
assertions from test_run_hook_stop_halts_loop.
🤖 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 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.

In `@src/agents/run.py`:
- Around line 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.

---

Nitpick comments:
In `@tests/test_turn_lifecycle_hooks.py`:
- Around line 255-340: Add a mirrored streamed test that verifies returning
"stop" from RunHooks.on_turn_start halts the streamed loop: create a test like
test_run_hook_stop_halts_loop but call Runner.run_streamed(...) (or the streamed
API used in tests) with StopAfterTurnRunHooks(stop_after=1), a FakeModel with
the same two-turn outputs, and assert that the streamed call raises
agents.exceptions.MaxTurnsExceeded with "halted by on_turn_start hook"; also
assert hooks.turn_starts == [1,2] and hooks.turn_ends == [1] to match the
non-streamed behavior. Ensure the new test uses the same helper symbols
(StopAfterTurnRunHooks, FakeModel, get_function_tool, get_function_tool_call,
get_text_message) and mirrors the assertions from test_run_hook_stop_halts_loop.
🪄 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: ceee7f44-1ed8-4f72-9b47-71669a935467

📥 Commits

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

📒 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 (1)
  • src/agents/run_internal/run_loop.py

Comment thread src/agents/lifecycle.py
Comment on lines +17 to +18
* ``"continue"`` (default / ``None``) – proceed with the turn as normal.
* ``"stop"`` – abort the run gracefully after this hook returns, exactly as if
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.

Comment thread src/agents/run.py
Comment on lines +996 to +1003
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}"
)
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.

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