Files
hermes-agent/tui_gateway/methods_session_interrupt.py
Austin Pickett 4c3f816011 refactor(cli,tui): move interrupt + session-tool handlers to topical modules
Pure code moves, no behaviour change. The session-interrupt gateway
handlers (methods_session_interrupt.py) and the CLI session-tool
handlers (cli_commands_session_tools.py) leave the two ratchet-offset
monoliths they lived in, so the module-size ratchet offsets can be
reclaimed. cli_commands_mixin re-imports the moved names so existing
'from cli_commands_mixin import _t' consumers keep working.
2026-10-06 15:15:06 -04:00

140 lines
7.3 KiB
Python

"""Interrupt / steer / redirect session handlers (``methods_session`` split).
Moved verbatim from ``tui_gateway/methods_session.py`` (file-line ratchet #68779): the
bodies close over server.py globals through ``method_ctx.bind_module`` exactly as before —
``@method`` registration, ``_session_arg`` wrapping and module publication all still run
from the parent's ``register()`` via ``HandlerRegistry``.
"""
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
def _resume_wake_after_interrupt() -> None:
"""Re-arm a wake lease held by the interrupt caller or a voice capture.
``_wake_resume_if_owner`` no-ops unless that object holds the lease, so an
in-progress capture owned by someone else is not stolen. Interrupt already
silenced TTS before it can return an error; this matches that cut. A
``not_interrupted`` hosted-task mismatch must not call it.
"""
with _voice_sid_lock:
voice_owner = _voice_wake_owner
seen = []
for owner in (_caller_transport(), voice_owner):
if owner is None or any(owner is item for item in seen):
continue
seen.append(owner)
_wake_resume_if_owner(owner)
# ── interrupt / steer / redirect ─────────────────────────────────────
@method("session.interrupt")
def _(rid, params: dict) -> dict:
_tts_stream_stop() # keypress barge-in also silences streaming TTS (voice is process-global)
resume_wake = True
try:
session, err = _sess_nowait(params, rid)
if err:
return err
if expected := _str_param(params, "expected_hosted_task_id"):
with session["history_lock"]:
task = session.get("_hosted_room_task")
if not (session.get("running") and isinstance(task, dict) and task.get("task_id") == expected):
resume_wake = False
return _ok(rid, {"status": "not_interrupted", "interrupted": False})
sid = str(params.get("session_id") or "")
if _session_uses_compute_host(session):
try:
_interrupt_session_turn(sid, session, request_id=f"interrupt-{rid}")
except Exception as exc: # health: allow BLE001 -- the bridge raises transport-specific errors unknown here; surfaced verbatim to the client, never swallowed
return _err(rid, 5019, f"compute-host interrupt failed: {exc}")
return _ok(rid, {"status": "interrupted", "turn_isolation": True})
session, err = _sess(params, rid)
if err:
return err
_interrupt_session_turn(sid, session)
# Retire the crash-recovery marker NOW: until the run thread's finally, a backend exit looks like a crash
# and session.resume auto-continues the turn the user just stopped (the extra key covers compression
# rotating session_key mid-turn).
with session["history_lock"]:
active_marker_key = str(session.pop("_active_turn_marker_key", "") or "")
_retire_turn_marker(session, active_marker_key)
return _ok(rid, {"status": "interrupted"})
finally:
if resume_wake:
try:
_resume_wake_after_interrupt()
except Exception:
logger.debug("session.interrupt wake resume failed", exc_info=True)
def _apply_correction(rid, session: dict, verb: str, text: str, accepted_status: str) -> dict:
"""``agent.<verb>(text)``; on acceptance record it on the live turn (mid-turn resume rebuilds the bubble)
and purge queued self-copies so post-turn drain cannot re-fire the old prompt."""
try:
accepted = getattr(session["agent"], verb)(text)
except Exception as exc:
return _err(rid, 5000, f"{verb} failed: {exc}")
if accepted:
with session["history_lock"]:
_record_inflight_correction(session, text)
# #84417: steer does not cancel the live original, but a server queue self-copy of that original
# must still not re-fire after settle (same class as redirect).
# #84417: purge server-queue self-duplicates of the live original so post-turn drain cannot
# restart the pre-correction prompt.
_drop_queued_duplicates_of_inflight_user(session)
session["last_active"] = time.time()
return _ok(rid, {"status": accepted_status if accepted else "rejected", "text": text})
def _correction_method(name: str, verb: str, accepted_status: str, supported, unsupported: str):
"""steer/redirect RPC: ``params.text`` (4002, checked before the session) into a live session;
``supported(agent)`` gates 4010."""
@method(name)
def _(rid, params: dict) -> dict:
if not (text := (params.get("text") or "").strip()):
return _err(rid, 4002, "text is required")
session, err = _sess_nowait(params, rid)
if err:
return err
agent = session.get("agent")
# Redirect during the turn-build window (running=True, agent None): queue for the next turn instead of
# a misleading 4010 the client swallows into a lost follow-up.
if verb == "redirect" and agent is None and session.get("running"):
_enqueue_prompt(session, text, current_transport() or _stdio_transport)
session["last_active"] = time.time()
return _ok(rid, {"status": "queued", "text": text})
# Compression in flight: queue instead of steering/redirecting. A correction that
# reaches the provider mid-compression aborts the compression (explicit_interrupt)
# — the follow-up kills the turn that would answer it (#61042). Queued here, it
# drains when compression finishes (the Discord-gateway contract; mirrors the
# interrupt→queue demotion in gateway/run_busy.py for the channel busy path).
if _session_compression_in_flight(session):
_enqueue_prompt(session, text, current_transport() or _stdio_transport)
session["last_active"] = time.time()
return _ok(rid, {"status": "queued", "text": text})
if not supported(agent):
return _err(rid, 4010, unsupported)
# An idle agent accepts steer() but only the next turn drains it, spliced after an old tool
# row (#64578). 'rejected' makes the client queue it as a normal next prompt.
if verb == "steer" and not session.get("running"):
return _ok(rid, {"status": "rejected", "text": text})
return _apply_correction(rid, session, verb, text, accepted_status)
# Inject text into the next tool result without interrupting (AIAgent.steer(): no new user turn, no role
# alternation violation).
_correction_method("session.steer", "steer", "queued", lambda agent: hasattr(agent, "steer"),
"agent does not support steer")
# Redirect the active model turn while preserving valid work/context.
_correction_method("session.redirect", "redirect", "redirected",
lambda agent: getattr(agent, "_supports_active_turn_redirect", False) is True
and hasattr(agent, "redirect"), "agent does not support active-turn redirect")
def register(server) -> None:
"""Publish this module's helpers onto ``server`` (rebound to its globals) and install handlers."""
bind_module(globals(), server, skip=("_",))