Files
Teknium ce18aafe01 fix(mcp): spend totals survive session rotation; each cron run has its own; review fixes
Independent review of #142470:
- Compression (CLI, TUI/Desktop) and /branch continue a chat under a new session id, which reset its
  spend total: a chat at its limit got a fresh budget. The continuation hook every rotation site already
  calls (transfer_session_yolo) now moves the total too.
- Every cron job in a process shared one tally (their approval session key is ""), so one job's spend
  refused the next and stayed spent until restart. The cron run scope binds its own run id.
- tool_costs keys the server does not offer (a typo, or Hermes' mcp_<server>_ prefix) are named in a
  warning at registration instead of silently carrying no budget.
- Untrusted over-limit prompts lead with the limit notice (the gateway text fallback keeps ~200 chars);
  "1 credit", not "1 credits"; the multiplex rekey moves the prompt slots with the totals.
- Tests: rotation + per-run cron tally + unknown-key warning (red on the previous head), and parallel
  over-limit prompts asked one at a time (red with the prompt slot made a no-op).
- Docs: where a total survives and resets, the counted-on-start wording, the same-name override note.
2026-10-21 04:12:11 -07:00

112 lines
5.2 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; sessions the gateway owns for CLI/TUI/Desktop clients (``config.set key=yolo``) and their managed
workers use that same routing entry. 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 apply_launch_yolo(session_key: str) -> None:
"""Seed a gateway-owned session's launch ``--yolo`` policy once per session boundary.
Called every turn of such a route; only the first call (or the first after ``clear_session``) arms it,
so a later ``/yolo`` off sticks instead of lasting one turn. Skipped under the frozen process ``--yolo``.
"""
if not session_key or approval._YOLO_MODE_FROZEN:
return
with approval._lock:
if session_key in approval._launch_yolo_applied:
return
approval._launch_yolo_applied.add(session_key)
approval.enable_session_yolo(session_key)
def restore_gateway_yolo(session_key: str, persisted: Optional[bool], *, launch: bool = False) -> None:
"""Restore the authoritative routing value, including OFF, before frozen launch seeding.
``persisted`` None means nobody toggled this route: the ``--yolo`` launch policy (*launch*) is seeded
once per boundary instead, so an untouched entry never reads as a revocation.
"""
if not session_key:
return
if persisted is None:
if launch:
apply_launch_yolo(session_key)
return
with approval._lock:
if session_key in approval._launch_yolo_applied:
return # after restoration, the live toggle remains authoritative in this process
approval._launch_yolo_applied.add(session_key)
(approval._session_yolo.add if persisted else approval._session_yolo.discard)(session_key)
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. Every
continuation site calls this, so it also moves the chat's MCP spend total: a rotation must not hand a
chat that spent its limit a fresh budget."""
from tools.mcp_tool_costs import transfer_session
transfer_session(old_key, new_key)
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}