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)
|