Files
hermes-agent/gateway/run_turn_runner_approval_settle.py
teknium1 23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00

82 lines
3.8 KiB
Python

"""Tell the chat when an exec-approval prompt times out (messaging platforms).
``tools.approval_gateway_wait._await_gateway_decision`` calls ``entry.settle(reason)`` once the
wait ends. Only the TUI registered such a hook, so on Telegram / Slack / WhatsApp a card whose
timer ran out kept live buttons and the user never learned the command did NOT run. The turn
runner registers the hook here right after the prompt was delivered.
Best-effort by design: a failed notice is logged at debug — the approval already resolved as
"no", and nothing here may block the agent thread.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from gateway.platforms.base_exec_approval import approval_timeout_seconds, format_approval_timed_out_notice
logger = logging.getLogger(__name__)
def register_timeout_notice(
runner, approval_data: dict, *, command: str, card_message_id: Optional[str]) -> None:
"""Arm a settle hook that posts the timed-out notice for ``approval_data['request_id']``.
``runner`` is the ``TurnRunner`` (for ``_ctx`` and ``_schedule``); ``card_message_id`` is the
delivered BUTTON card's id when the adapter returned one, so the card itself is edited in place
(which also drops its buttons). The plain-text prompt passes ``None``: it has no buttons to
drop and rewriting it would erase the record of what was asked. ``command`` is the
already-redacted command shown to the user. The notice is skipped when the run is no longer
current (``ctx._run_still_current``).
"""
from tools.approval import register_gateway_settle
request_id = approval_data.get("request_id")
session_key = runner._ctx.session_key or ""
if not request_id or not session_key:
return
timeout_s = approval_timeout_seconds()
def settle(reason: str) -> None:
if reason != "timeout":
return # answered / interrupted / notify_failed already produced their own feedback
# Same guard as every other late notice in TurnRunner: after /stop, /new or a restart the
# turn is over and this chat belongs to a newer run — do not edit or post into it.
still_current = getattr(runner._ctx, "_run_still_current", None)
if callable(still_current) and not still_current():
return
runner._schedule(
_post_timeout_notice(runner._ctx, command, card_message_id, timeout_s),
"Approval timeout notice scheduling error")
register_gateway_settle(session_key, request_id, settle)
async def _post_timeout_notice(ctx, command: str, card_message_id: Optional[str], timeout_s: int) -> None:
from gateway.run import _interim_metadata
adapter = ctx._status_adapter
notice = format_approval_timed_out_notice(timeout_s)
metadata = _interim_metadata(ctx._status_thread_metadata)
try:
# Plain markdown, not the card's platform markup: ``edit_message`` re-formats it itself.
if card_message_id and await _edit_card(adapter, ctx._status_chat_id, card_message_id, f"{notice}\n```\n{command}\n```"):
return
await adapter.send(ctx._status_chat_id, notice, metadata=metadata)
except Exception:
logger.debug("Approval timeout notice failed", exc_info=True)
async def _edit_card(adapter, chat_id: str, message_id: str, content: str) -> bool:
"""Edit the card in place (drops the buttons on platforms whose edit replaces the markup)."""
edit: Optional[Callable[..., Any]] = getattr(adapter, "edit_message", None)
if edit is None:
return False
try:
result = await edit(chat_id, message_id, content)
except Exception:
logger.debug("Approval card edit failed; sending the notice as a new message", exc_info=True)
return False
return bool(getattr(result, "success", False))