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.
74 lines
2.7 KiB
Python
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)
|