Files
hermes-agent/tools/interrupt.py
Teknium e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00

110 lines
4.1 KiB
Python

"""Per-thread interrupt signaling for all tools: thread-scoped so interrupting one
agent session does not kill tools in other sessions (the gateway runs many agents in one
process). The agent passes its execution thread id to set_interrupt(); tools call
is_interrupted(), which checks the CURRENT thread."""
import logging
import os
import threading
from collections.abc import Callable
logger = logging.getLogger(__name__)
# Opt-in debug tracing — pairs with HERMES_DEBUG_INTERRUPT in tools/environments/base.py.
_DEBUG_INTERRUPT = bool(os.getenv("HERMES_DEBUG_INTERRUPT"))
if _DEBUG_INTERRUPT:
# AIAgent's quiet_mode forces the `tools` logger to ERROR on CLI startup;
# force ours back to INFO so the trace is visible in agent.log.
logger.setLevel(logging.INFO)
# Interrupted thread idents + optional user-safe cause (never the user's message text).
_interrupted_threads: set[int] = set()
_interrupt_reasons: dict[int, str] = {}
_lock = threading.Lock()
def set_interrupt(active: bool, thread_id: int | None = None, *, reason: str | None = None) -> None:
"""Set or clear the interrupt for *thread_id* (default: current thread); ``reason`` is
an optional user-safe cause."""
tid = thread_id if thread_id is not None else threading.current_thread().ident
with _lock:
(_interrupted_threads.add if active else _interrupted_threads.discard)(tid)
if active and reason:
_interrupt_reasons[tid] = reason
else:
_interrupt_reasons.pop(tid, None)
_snapshot = set(_interrupted_threads) if _DEBUG_INTERRUPT else None
if _DEBUG_INTERRUPT:
logger.info(
"[interrupt-debug] set_interrupt(active=%s, target_tid=%s) "
"called_from_tid=%s current_set=%s",
active, tid, threading.current_thread().ident, _snapshot)
def is_interrupted() -> bool:
return is_thread_interrupted(threading.current_thread().ident)
def is_thread_interrupted(thread_id: int | None) -> bool:
"""Whether *thread_id* has an interrupt bit set (``None`` never is). Used when
a wait moves onto a deadline worker (``run_bounded_sync``) so ``/stop``
targeting the original tool-worker tid still kills the subprocess.
See #94285.
"""
if thread_id is None:
return False
with _lock:
return thread_id in _interrupted_threads
def run_if_not_interrupted(callback: Callable[[], None]) -> bool:
"""Run a state transition atomically with current-thread interruption.
Returns ``False`` without calling ``callback`` when the current thread is
already interrupted. The callback runs under the interrupt lock and must
not block or re-enter any interrupt API.
"""
tid = threading.current_thread().ident
with _lock:
if tid in _interrupted_threads:
return False
callback()
return True
def get_interrupt_reason() -> str | None:
"""User-safe interrupt cause for the current thread, if known."""
with _lock:
return _interrupt_reasons.get(threading.current_thread().ident)
def clear_current_thread_interrupt() -> None:
"""Clear any interrupt bit on the CURRENT thread: gives a user-approved command a clean
slate right before it spawns its child, so a stale bit that landed during the blocking
approval-wait cannot SIGINT the just-approved run. A *genuine* interrupt arriving after
this call re-sets the bit and is still observed by the executor's poll loop. Call
directly, never via the _interrupt_event proxy (its .clear() binds to whatever thread
runs it)."""
set_interrupt(False)
class _ThreadAwareEventProxy:
"""Backward-compatible ``_interrupt_event``: legacy .is_set()/.set()/.clear()/.wait()
call sites mapped onto the per-thread API (``wait`` returns the current state at once)."""
def is_set(self) -> bool:
return is_interrupted()
def set(self) -> None: # noqa: A003
set_interrupt(True)
def clear(self) -> None:
set_interrupt(False)
def wait(self, timeout: float | None = None) -> bool:
return self.is_set()
_interrupt_event = _ThreadAwareEventProxy()