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}.
150 lines
7.7 KiB
Python
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=("_",))
|