Problem
Plugins that hold per-turn external resources have no way to know when the user interrupts the run with /stop. The existing on_session_finalize hook covers /new (slow path) but is not fired by:
/stop — see gateway/run.py::_interrupt_and_clear_session (called from _handle_stop_command)
- The
/new fast-path inside _dispatch_message (running-agent guard at gateway/run.py:6122) — interrupts the agent before _handle_reset_command runs
For these paths the plugin's outbound work just sits there until it times out, while the agent has already moved on.
Concrete use case
hermes-livekit v0.3.0 lets clients connected over a LiveKit data channel register tools that the agent can invoke (notifications, browser actions, robotic-arm controls, etc.). When the LLM picks one of those tools, the plugin's proxy coroutine awaits a result future with a 30s timeout:
return await asyncio.wait_for(future, timeout=TOOL_CALL_TIMEOUT)
If the user runs /stop mid-call, the agent loop's interrupt flag flips but the proxy keeps waiting — the client stays in "we owe a result" mode for the full timeout window, and the agent:tool-call-cancelled notification we'd want to publish to the client never goes out.
The same shape applies to any plugin that proxies a tool call to an external service (MCP-style outbound RPC, remote-fn-call gateways, etc.).
Proposed solution
Add an agent_loop_stopped plugin hook fired from _interrupt_and_clear_session right after running_agent.interrupt(). Kwargs:
session_key (str) — which session was interrupted
platform (str) — source.platform.value
reason (str) — interrupt_reason (e.g. \"user_stop\", \"reset\")
invalidation_reason (str) — finer-grained tag (\"stop_command\", \"new_command\", \"stop_command_pending\", \"stop_command_handler\")
Dispatch wrapped in try/except so a misbehaving plugin can't block the interrupt. Symmetric with how on_session_finalize is fired today.
Why a new hook, not a reused one
on_session_finalize is per-session; fires only when the session is finalized. /stop doesn't finalize the session, and the /new fast-path interrupts before finalize runs.
subagent_stop fires per-delegation, not per-turn.
- The async gateway hooks (
session:end, session:reset) cover /new only, and are file-based hooks, not plugin-registered ones.
No existing hook gives plugins a "the current turn just got abandoned, drop your per-turn external work" signal.
PR
PR: #27208 PR adds the dispatch, registers agent_loop_stopped in VALID_HOOKS with docstring comments, and includes a 3-case pytest covering hook presence, dispatch-on-interrupt, and fault tolerance when a handler raises.
Tested on
- macOS 25.4.1 (Darwin)
- Python 3.11
- Hermes v0.14.0 / commit 3b39096 (current main as of 2026-05-16)
Problem
Plugins that hold per-turn external resources have no way to know when the user interrupts the run with
/stop. The existingon_session_finalizehook covers/new(slow path) but is not fired by:/stop— seegateway/run.py::_interrupt_and_clear_session(called from_handle_stop_command)/newfast-path inside_dispatch_message(running-agent guard atgateway/run.py:6122) — interrupts the agent before_handle_reset_commandrunsFor these paths the plugin's outbound work just sits there until it times out, while the agent has already moved on.
Concrete use case
hermes-livekitv0.3.0 lets clients connected over a LiveKit data channel register tools that the agent can invoke (notifications, browser actions, robotic-arm controls, etc.). When the LLM picks one of those tools, the plugin's proxy coroutine awaits a result future with a 30s timeout:If the user runs
/stopmid-call, the agent loop's interrupt flag flips but the proxy keeps waiting — the client stays in "we owe a result" mode for the full timeout window, and theagent:tool-call-cancellednotification we'd want to publish to the client never goes out.The same shape applies to any plugin that proxies a tool call to an external service (MCP-style outbound RPC, remote-fn-call gateways, etc.).
Proposed solution
Add an
agent_loop_stoppedplugin hook fired from_interrupt_and_clear_sessionright afterrunning_agent.interrupt(). Kwargs:session_key(str) — which session was interruptedplatform(str) —source.platform.valuereason(str) —interrupt_reason(e.g.\"user_stop\",\"reset\")invalidation_reason(str) — finer-grained tag (\"stop_command\",\"new_command\",\"stop_command_pending\",\"stop_command_handler\")Dispatch wrapped in
try/exceptso a misbehaving plugin can't block the interrupt. Symmetric with howon_session_finalizeis fired today.Why a new hook, not a reused one
on_session_finalizeis per-session; fires only when the session is finalized./stopdoesn't finalize the session, and the/newfast-path interrupts before finalize runs.subagent_stopfires per-delegation, not per-turn.session:end,session:reset) cover/newonly, and are file-based hooks, not plugin-registered ones.No existing hook gives plugins a "the current turn just got abandoned, drop your per-turn external work" signal.
PR
PR: #27208 PR adds the dispatch, registers
agent_loop_stoppedinVALID_HOOKSwith docstring comments, and includes a 3-case pytest covering hook presence, dispatch-on-interrupt, and fault tolerance when a handler raises.Tested on