Files
hermes-agent/tools/approval_yolo.py
Teknium b1a39a1b1b refactor(approval): one session /yolo contract for CLI, TUI/Desktop and the messaging gateway
The persist / restore / carry logic for session YOLO had grown three copies
(the CLI mixin, tui_gateway, the gateway runner), each with its own key choice,
ordering and error handling. tools/approval_yolo.py now owns it:

- toggle_session_yolo: flip or set, persist BEFORE the live flip (a racing
  restore never revives an OFF), stored-copy-aware so a toggle after a restart
  turns it OFF; a failed write logs a warning and never blocks the flip.
- restore_session_yolo: re-arm a persisted flag in a fresh process; skipped
  under the frozen process --yolo.
- transfer_session_yolo: move the live flag to a continuation id.
- with_session_yolo: a row being created records a live flag (lazy first row,
  compression child, /branch).

Each surface keeps only its storage: the session row for CLI/TUI/Desktop, the
routing entry for the gateway. Removed: HermesCLI._transfer_session_yolo /
_persist_session_yolo, GatewayRunner._restore_session_yolo, the TUI-only
restore helper, and the TUI's post-publish set_session_yolo carry (the
compression publish now writes the child row with the flag).

Closes a gap the unification exposed: a CLI manual /compress that rotated the
session dropped YOLO (the next dangerous command prompted, and a --resume of
the continuation came back with it off).
2026-10-08 00:49:32 -07:00

73 lines
3.3 KiB
Python

"""Session ``/yolo``: the one toggle / restore / carry contract every surface calls.
The bypass itself is ``tools.approval``'s in-memory per-key set, so every process starts with it empty.
Each surface persists it where its session identity lives: CLI, TUI and Desktop on the session row
(``model_config.yolo_mode``), the messaging gateway on the routing entry, whose key outlives compression
rotations. The surface hands its writer in as ``persist(enabled)``; everything else is decided here, so
the CLI ``/yolo``, the TUI Shift+Tab, the Desktop zap and a Telegram ``/yolo`` behave the same.
"""
import logging
from typing import Any, Callable, Optional
from tools import approval
logger = logging.getLogger(__name__)
Persist = Optional[Callable[[bool], Any]]
def _persist(persist: Persist, enabled: bool, session_key: str) -> None:
# Best-effort: the live flag is authoritative for this process; a failed write only costs the restore.
if persist is None:
return
try:
persist(enabled)
except Exception:
logger.warning("failed to persist session yolo=%s for %s", enabled, session_key, exc_info=True)
def toggle_session_yolo(session_key: str, enabled: Optional[bool] = None, *, persisted: bool = False,
persist: Persist = None) -> bool:
"""Flip (``enabled=None``) or set the bypass for *session_key*; returns the new state.
*persisted* is the stored flag: after a restart only it is set and the user still sees ON, so a toggle
turns it OFF. The write runs BEFORE the live flip, so a turn restoring in between never revives a
bypass that is being switched off.
"""
if enabled is None:
enabled = not (persisted or approval.is_session_yolo_enabled(session_key))
_persist(persist, enabled, session_key)
(approval.enable_session_yolo if enabled else approval.disable_session_yolo)(session_key)
return enabled
def restore_session_yolo(session_key: str, persisted: bool) -> bool:
"""Re-arm a persisted bypass in a fresh process; True when this call turned it on.
Key it on the id approvals are checked under (the stored session id / routing key), never a
transport-local id. Skipped under the frozen process ``--yolo``, which already bypasses everything.
"""
if (not session_key or not persisted or approval._YOLO_MODE_FROZEN
or approval.is_session_yolo_enabled(session_key)):
return False
approval.enable_session_yolo(session_key)
return True
def transfer_session_yolo(old_key: str, new_key: str) -> None:
"""Move the live bypass when the conversation continues under a new id (compression rotation, /branch).
The new row records it at creation (``with_session_yolo``), so nothing is written here."""
if not old_key or not new_key or old_key == new_key or not approval.is_session_yolo_enabled(old_key):
return
approval.enable_session_yolo(new_key)
approval.disable_session_yolo(old_key)
def with_session_yolo(model_config: Optional[dict], session_key: str) -> Optional[dict]:
"""``model_config`` for a session row being created, carrying a live bypass. Rows are created lazily on
the first turn, so this is where a toggle made before the row existed gets recorded."""
if not approval.is_session_yolo_enabled(session_key):
return model_config
return {**(model_config or {}), "yolo_mode": True}