refactor(tools): compact kanban/delegation/interrupt/desktop tool modules; drop dead helpers

This commit is contained in:
Teknium
2026-09-02 22:06:11 -07:00
parent 4ab6bdb94b
commit 26186cb0b8
8 changed files with 245 additions and 469 deletions
+24 -49
View File
@@ -1,15 +1,9 @@
"""Per-thread interrupt signaling for all tools.
Thread-scoped interrupt tracking so that interrupting one agent session does
not kill tools running in other sessions — critical in the gateway where
multiple agents run concurrently in one process. The agent stores its
execution thread ID at the start of run_conversation() and passes it to
Thread-scoped so interrupting one agent session does not kill tools running in
other sessions (the gateway runs many agents in one process). The agent stores
its execution thread id at the start of run_conversation() and passes it to
set_interrupt(); tools call is_interrupted(), which checks the CURRENT thread.
Usage in tools:
from tools.interrupt import is_interrupted
if is_interrupted():
return {"output": "[interrupted]", "returncode": 130}
"""
import logging
@@ -19,13 +13,12 @@ import threading
logger = logging.getLogger(__name__)
# Opt-in debug tracing — pairs with HERMES_DEBUG_INTERRUPT in
# tools/environments/base.py. Logs caller thread, target thread, and current
# state per set/check for "interrupt signaled but tool never saw it" reports.
# tools/environments/base.py; logs caller/target thread and state per set/check.
_DEBUG_INTERRUPT = bool(os.getenv("HERMES_DEBUG_INTERRUPT"))
if _DEBUG_INTERRUPT:
# AIAgent's quiet_mode path forces the `tools` logger to ERROR on CLI
# startup; force ours back to INFO so the trace is visible in agent.log.
# 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, plus an optional user-safe cause per signal. The
@@ -35,15 +28,9 @@ _interrupt_reasons: dict[int, str] = {}
_lock = threading.Lock()
def set_interrupt(
active: bool,
thread_id: int | None = None,
*,
reason: str | None = None,
) -> None:
"""Set (``active=True``) or clear the interrupt for *thread_id*, defaulting
to the current thread (backward compat for CLI/tests). ``reason`` is an
optional user-safe cause."""
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, for
CLI/tests). ``reason`` is an optional user-safe cause."""
tid = thread_id if thread_id is not None else threading.current_thread().ident
with _lock:
if active:
@@ -60,8 +47,7 @@ def set_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,
)
active, tid, threading.current_thread().ident, _snapshot)
def is_interrupted() -> bool:
@@ -70,12 +56,9 @@ def is_interrupted() -> bool:
def is_thread_interrupted(thread_id: int | None) -> bool:
"""Check whether *thread_id* has an interrupt bit set (``None`` never is).
Used when a wait is moved onto a deadline worker (``run_bounded_sync``)
so ``/stop`` targeting the original tool-worker tid still kills the
subprocess.
"""
"""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."""
if thread_id is None:
return False
with _lock:
@@ -83,35 +66,27 @@ def is_thread_interrupted(thread_id: int | None) -> bool:
def get_interrupt_reason() -> str | None:
"""Return the user-safe interrupt cause for the current thread, if known."""
tid = threading.current_thread().ident
"""User-safe interrupt cause for the current thread, if known."""
with _lock:
return _interrupt_reasons.get(tid)
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 interrupt slate immediately before
it spawns its child process, so a stale bit that landed on this thread
during the blocking approval-wait cannot SIGINT the just-approved run
(exit 130 + "[Command interrupted]"). Single-thread ordering on this tid
keeps the DO-NOT-BREAK invariant intact: a *genuine* interrupt arriving
after this call re-sets the bit on the same thread and is still observed by
the executor's poll loop. Call this directly, never via the
_interrupt_event proxy (its .clear() binds to whatever thread runs it).
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. Single-thread ordering keeps the invariant: 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) # thread_id=None -> current thread
set_interrupt(False)
# ---------------------------------------------------------------------------
# Backward-compatible _interrupt_event proxy: legacy call sites
# (code_execution_tool, process_registry, tests) import it and call
# .is_set() / .set() / .clear(); the shim maps those to the per-thread API.
# ---------------------------------------------------------------------------
class _ThreadAwareEventProxy:
"""Drop-in proxy that maps threading.Event methods to per-thread state."""
"""Backward-compatible ``_interrupt_event``: legacy call sites call
.is_set()/.set()/.clear(); the shim maps those to the per-thread API."""
def is_set(self) -> bool:
return is_interrupted()