Files
hermes-agent/tui_gateway/methods_session_interrupt.py
alt-glitch 995bc1c0e7 feat(free-tier): offer sign-in after finished tasks, backing off
A free-tier user is offered sign-in 3 minutes after their first finished
task, whether or not they send another prompt. Later offers each need
another finished task after the previous one, come 3 minutes after that
task, and never sooner than 1h, 4h, then 24h after the previous offer, so
a user who keeps saying "Not now" hears it less often.

- hermes_cli/free_tier_offer.py: the policy. One install-wide record
  (<default root>/free_tier/sign_in_offer.json, file lock + atomic write);
  claim_offer() is compare-and-set so two windows never both show it.
  Nothing is due off the free tier.
- tui_gateway/free_tier_task_done.py: a "finished task" is a completed
  turn with a reply, on the free-tier agent, not a crash resume, and with
  no steer/redirect/interrupt/busy-typing while it ran. Called from
  prompt_turn after a complete turn, before message.complete goes out, so
  a status read prompted by that event already sees the record. Intervention is reported by the agent
  (result["user_intervened"], reset per turn) and marked on the session by
  the steer/redirect/interrupt RPCs and busy submits.
- Wire: free_tier.status gains nudge_due_in (seconds, 0 = now, absent when
  none is pending); new free_tier.claim_nudge -> {claimed}.
2026-10-07 12:26:42 +05:30

150 lines
7.7 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 ─────────────────────────────────────
def _note_user_input(session: dict) -> None:
"""Mark the running turn as touched by the user (also covers compute-host turns, whose agent
flag never reaches this process); popped by the turn's own finally."""
with session["history_lock"]:
if session.get("running"):
session["_turn_user_input"] = True
@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 "")
_note_user_input(session)
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
_note_user_input(session)
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=("_",))