Files
hermes-agent/agent/interrupt_scope.py
teknium1 9d0ab880bc fix: attribute gateway lifecycle stops to their system issuer, not the user
The parent commit only stamped a tool_reason on the watchdog producers, so a
reason-less hard_cancel still minted "explicit stop requested" (a USER reason)
for gateway stop/restart, session eviction while a turn runs, abandoned SSE
runs and TUI/desk shutdown - the exact confusion class of #112647, failing open
toward "user". Pass a system tool_reason at those producers (run_shutdown,
run_agent_cache via the eviction caller only - /stop and /new stay user-owned -
and api_server) so only genuine surface stops book interrupted_by_user.

InterruptScope.cancel now takes tool_reason so the Hermes Console user-cancel
branch is attributed to the user; the console timeout keeps the host slug.

Also repairs the red CI: the cross-process lease tests stub agent.interrupt
with (message, hard_cancel) only, so the new tool_reason kwarg raised TypeError
inside the lease's fallback and the stub never fired. Widen the doubles, and
update the two shutdown mocks that asserted the exact interrupt() call.
2026-09-16 17:48:17 -07:00

74 lines
2.7 KiB
Python

"""Host-owned cancellation for agents created deep inside synchronous work.
A host that runs a blocking command on a worker thread (Hermes Console) never sees
the ``AIAgent`` a CLI subcommand forks inside it, so it cannot call ``interrupt()``
when the user cancels. The host binds an :class:`InterruptScope` around the work;
every ``run_conversation()`` under that scope registers its agent, and
``scope.cancel()`` hard-interrupts them from any thread. Agents registering after
the cancel are interrupted immediately, so a cancel never loses the race with a
turn that has not started yet (#106179).
"""
from __future__ import annotations
import threading
from contextlib import contextmanager, nullcontext
from contextvars import ContextVar
from typing import Any, Iterator, Optional
from agent.interrupt_compat import request_hard_interrupt
_ACTIVE_SCOPE: ContextVar[Optional["InterruptScope"]] = ContextVar("hermes_interrupt_scope", default=None)
_TOOL_REASON_HOST_CANCELLED = "host cancelled the command"
class InterruptScope:
def __init__(self) -> None:
self._lock = threading.Lock()
self._agents: list[Any] = []
self.reason: Optional[str] = None
self._tool_reason: Optional[str] = _TOOL_REASON_HOST_CANCELLED
def cancel(self, reason: str, *, tool_reason: Optional[str] = _TOOL_REASON_HOST_CANCELLED) -> None:
"""Latch ``reason`` and hard-interrupt every agent running under this scope.
``tool_reason`` names the system issuer; pass ``None`` for a human stop so the turn is
attributed to the user rather than to the host (#112647)."""
with self._lock:
self.reason = reason
self._tool_reason = tool_reason
agents = list(self._agents)
for agent in agents:
request_hard_interrupt(agent, reason, tool_reason=tool_reason)
@contextmanager
def track(self, agent: Any) -> Iterator[None]:
with self._lock:
self._agents.append(agent)
reason, tool_reason = self.reason, self._tool_reason
if reason is not None:
request_hard_interrupt(agent, reason, tool_reason=tool_reason)
try:
yield
finally:
with self._lock:
self._agents.remove(agent)
@contextmanager
def bind_interrupt_scope(scope: Optional[InterruptScope]) -> Iterator[None]:
"""Make ``scope`` the owner of every agent turn started in this context."""
token = _ACTIVE_SCOPE.set(scope)
try:
yield
finally:
_ACTIVE_SCOPE.reset(token)
def track_in_interrupt_scope(agent: Any):
"""Register ``agent`` with the bound scope for the duration of its turn (no-op without one)."""
scope = _ACTIVE_SCOPE.get()
return nullcontext() if scope is None else scope.track(agent)