Skip to content

Commit 4caad28

Browse files
authored
feat(gateway): auto-delete slash-command system notices after TTL (NousResearch#18266)
Adds opt-in auto-deletion for slash-command reply messages like "New session started!", "Restarting gateway…", "Stopped.", and YOLO toggles. After the TTL elapses the gateway calls the adapter's delete_message; on platforms without a delete API (everything except Telegram today) the TTL is silently ignored and the message stays. Requested on Twitter by @CharlesMcDowell — tool-call bubbles are useful real-time, but system notices clutter the thread once the agent finishes. Implementation: - EphemeralReply(str) sentinel in gateway/platforms/base.py. Subclasses str so existing 'X' in response / response.startswith(...) checks in tests and call sites keep working unchanged; isinstance() still distinguishes it for the send path. - _process_message_background and both busy-session bypass paths (in base.py) call _unwrap_ephemeral() on the handler return, send the unwrapped text, and schedule a detached delete task when the TTL > 0 AND the adapter class overrides delete_message. - display.ephemeral_system_ttl (default 0 = disabled) in DEFAULT_CONFIG. Handler can pass ttl_seconds explicitly to override. - Wrapped the highest-noise return sites: /new, /reset, /stop, /yolo on/off, /restart success + "already in progress". Draining notices and /help output left as plain strings — those are informational and users want to read them. Backward-compat: default TTL 0 → no scheduling, no behavior change for existing users. Platforms without delete_message silently no-op.
1 parent e2eb561 commit 4caad28

4 files changed

Lines changed: 530 additions & 26 deletions

File tree

gateway/platforms/base.py

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
416416
from dataclasses import dataclass, field
417417
from datetime import datetime
418418
from pathlib import Path
419-
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple
419+
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union
420420
from enum import Enum
421421

422422
from pathlib import Path as _Path
@@ -981,7 +981,7 @@ def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
981981
return
982982

983983

984-
@dataclass
984+
@dataclass
985985
class SendResult:
986986
"""Result of sending a message."""
987987
success: bool
@@ -991,6 +991,45 @@ class SendResult:
991991
retryable: bool = False # True for transient connection errors — base will retry automatically
992992

993993

994+
class EphemeralReply(str):
995+
"""System-notice reply that auto-deletes after a TTL.
996+
997+
Slash-command handlers in ``gateway/run.py`` can return this wrapper
998+
instead of a plain string to request that the reply message be deleted
999+
after ``ttl_seconds`` on platforms that support ``delete_message``.
1000+
1001+
Subclassing ``str`` keeps the wrapper transparent to anything that
1002+
treats handler return values as text (existing tests use ``in`` /
1003+
``startswith`` / equality; the ``_process_message_background`` pipeline
1004+
extracts attachments from the string content). ``isinstance(r,
1005+
EphemeralReply)`` still distinguishes ephemeral replies from plain
1006+
strings so the send path can schedule deletion.
1007+
1008+
Platforms that don't override :meth:`BasePlatformAdapter.delete_message`
1009+
silently ignore the TTL — the message is sent normally and left in
1010+
place. When ``ttl_seconds`` is ``None``, the pipeline uses the
1011+
configured ``display.ephemeral_system_ttl`` default. A default of ``0``
1012+
disables auto-deletion globally, preserving prior behavior.
1013+
"""
1014+
1015+
ttl_seconds: Optional[int]
1016+
1017+
def __new__(cls, text: str, ttl_seconds: Optional[int] = None):
1018+
instance = super().__new__(cls, text)
1019+
instance.ttl_seconds = ttl_seconds
1020+
return instance
1021+
1022+
@property
1023+
def text(self) -> str:
1024+
"""Return the underlying text.
1025+
1026+
Provided for call sites that want an explicit string conversion,
1027+
though ``str(reply)`` and using ``reply`` directly where a string
1028+
is expected both work identically.
1029+
"""
1030+
return str.__str__(self)
1031+
1032+
9941033
def merge_pending_message_event(
9951034
pending_messages: Dict[str, MessageEvent],
9961035
session_key: str,
@@ -1073,8 +1112,10 @@ def merge_pending_message_event(
10731112
)
10741113

10751114

1076-
# Type for message handlers
1077-
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[str]]]
1115+
# Type for message handlers. Handlers may return a plain string (normal
1116+
# reply), an ``EphemeralReply`` to opt the reply into auto-deletion, or
1117+
# ``None`` when the response was already delivered (e.g. via streaming).
1118+
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]
10781119

10791120

10801121
def resolve_channel_prompt(
@@ -1459,6 +1500,64 @@ async def delete_message(
14591500
"""
14601501
return False
14611502

1503+
def _get_ephemeral_system_ttl_default(self) -> int:
1504+
"""Read ``display.ephemeral_system_ttl`` from config.
1505+
1506+
Returns the TTL in seconds to use when an :class:`EphemeralReply`
1507+
does not specify one explicitly. ``0`` (the default) disables
1508+
auto-deletion. Non-fatal if config is unreadable.
1509+
"""
1510+
try:
1511+
from hermes_cli.config import load_config as _load_config
1512+
except Exception:
1513+
return 0
1514+
try:
1515+
cfg = _load_config()
1516+
except Exception:
1517+
return 0
1518+
display = cfg.get("display", {}) if isinstance(cfg, dict) else {}
1519+
if not isinstance(display, dict):
1520+
return 0
1521+
raw = display.get("ephemeral_system_ttl", 0)
1522+
try:
1523+
return int(raw)
1524+
except (TypeError, ValueError):
1525+
return 0
1526+
1527+
def _schedule_ephemeral_delete(
1528+
self,
1529+
chat_id: str,
1530+
message_id: str,
1531+
ttl_seconds: int,
1532+
) -> None:
1533+
"""Spawn a detached task that deletes ``message_id`` after ``ttl_seconds``.
1534+
1535+
Best-effort — failures (gateway restart, permission denied, message
1536+
too old for Telegram's 48h window) are swallowed at debug level.
1537+
Does not block the caller.
1538+
"""
1539+
1540+
async def _run_delete() -> None:
1541+
try:
1542+
await asyncio.sleep(max(1, int(ttl_seconds)))
1543+
await self.delete_message(chat_id=chat_id, message_id=message_id)
1544+
except asyncio.CancelledError:
1545+
raise
1546+
except Exception as e:
1547+
logger.debug(
1548+
"[%s] Ephemeral delete failed for %s/%s: %s",
1549+
self.name, chat_id, message_id, e,
1550+
)
1551+
1552+
coro = _run_delete()
1553+
try:
1554+
asyncio.create_task(coro)
1555+
except RuntimeError:
1556+
# No running loop (e.g. unit tests that never reach the async
1557+
# path). Close the coroutine cleanly so Python doesn't warn
1558+
# about it never being awaited, then drop silently.
1559+
coro.close()
1560+
14621561
async def send_slash_confirm(
14631562
self,
14641563
chat_id: str,
@@ -2048,6 +2147,28 @@ def _is_timeout_error(error: Optional[str]) -> bool:
20482147
lowered = error.lower()
20492148
return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered
20502149

2150+
def _unwrap_ephemeral(self, response: Any) -> Tuple[Optional[str], int]:
2151+
"""Unwrap a handler response into (text, ttl_seconds).
2152+
2153+
Accepts a plain string, ``None``, or an :class:`EphemeralReply`.
2154+
Returns ``(text, ttl)`` where ``ttl > 0`` means the caller should
2155+
schedule a deletion via :meth:`_schedule_ephemeral_delete` after
2156+
the send succeeds. ``ttl`` is forced to 0 when the adapter
2157+
doesn't override :meth:`delete_message` so non-supporting
2158+
platforms silently degrade to normal sends.
2159+
"""
2160+
if isinstance(response, EphemeralReply):
2161+
ttl = response.ttl_seconds
2162+
if ttl is None:
2163+
try:
2164+
ttl = int(self._get_ephemeral_system_ttl_default())
2165+
except Exception:
2166+
ttl = 0
2167+
if ttl and ttl > 0 and type(self).delete_message is BasePlatformAdapter.delete_message:
2168+
ttl = 0
2169+
return response.text, int(ttl or 0)
2170+
return response, 0
2171+
20512172
async def _send_with_retry(
20522173
self,
20532174
chat_id: str,
@@ -2355,13 +2476,20 @@ async def _dispatch_active_session_command(
23552476
release_guard=False,
23562477
discard_pending=False,
23572478
)
2358-
if response:
2359-
await self._send_with_retry(
2479+
_text, _eph_ttl = self._unwrap_ephemeral(response)
2480+
if _text:
2481+
_r = await self._send_with_retry(
23602482
chat_id=event.source.chat_id,
2361-
content=response,
2483+
content=_text,
23622484
reply_to=event.message_id,
23632485
metadata=thread_meta,
23642486
)
2487+
if _eph_ttl > 0 and _r.success and _r.message_id:
2488+
self._schedule_ephemeral_delete(
2489+
chat_id=event.source.chat_id,
2490+
message_id=_r.message_id,
2491+
ttl_seconds=_eph_ttl,
2492+
)
23652493
except Exception:
23662494
# On failure, restore the original guard if one still exists so
23672495
# we don't leave the session in a half-reset state.
@@ -2441,13 +2569,20 @@ async def handle_message(self, event: MessageEvent) -> None:
24412569
try:
24422570
_thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
24432571
response = await self._message_handler(event)
2444-
if response:
2445-
await self._send_with_retry(
2572+
_text, _eph_ttl = self._unwrap_ephemeral(response)
2573+
if _text:
2574+
_r = await self._send_with_retry(
24462575
chat_id=event.source.chat_id,
2447-
content=response,
2576+
content=_text,
24482577
reply_to=event.message_id,
24492578
metadata=_thread_meta,
24502579
)
2580+
if _eph_ttl > 0 and _r.success and _r.message_id:
2581+
self._schedule_ephemeral_delete(
2582+
chat_id=event.source.chat_id,
2583+
message_id=_r.message_id,
2584+
ttl_seconds=_eph_ttl,
2585+
)
24512586
except Exception as e:
24522587
logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
24532588
return
@@ -2553,7 +2688,16 @@ async def _stop_typing_task() -> None:
25532688

25542689
# Call the handler (this can take a while with tool calls)
25552690
response = await self._message_handler(event)
2556-
2691+
2692+
# Slash-command handlers may return an EphemeralReply sentinel to
2693+
# request that their reply message auto-delete after a TTL (used
2694+
# for system notices like "✨ New session started!" that the user
2695+
# doesn't need to keep in the thread). Unwrap here so all the
2696+
# downstream extract_media / text-processing logic sees a plain
2697+
# string, and remember the TTL + platform capability so the
2698+
# post-send block can schedule the deletion.
2699+
response, _ephemeral_ttl = self._unwrap_ephemeral(response)
2700+
25572701
# Send response if any. A None/empty response is normal when
25582702
# streaming already delivered the text (already_sent=True) or
25592703
# when the message was queued behind an active agent. Log at
@@ -2642,6 +2786,21 @@ async def _stop_typing_task() -> None:
26422786
)
26432787
_record_delivery(result)
26442788

2789+
# Schedule auto-deletion of system-notice replies.
2790+
# Detached so the handler returns immediately; errors
2791+
# (permission denied, message too old) are swallowed.
2792+
if (
2793+
_ephemeral_ttl
2794+
and _ephemeral_ttl > 0
2795+
and result.success
2796+
and result.message_id
2797+
):
2798+
self._schedule_ephemeral_delete(
2799+
chat_id=event.source.chat_id,
2800+
message_id=result.message_id,
2801+
ttl_seconds=_ephemeral_ttl,
2802+
)
2803+
26452804
# Human-like pacing delay between text and media
26462805
human_delay = self._get_human_delay()
26472806

gateway/run.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from contextvars import copy_context
3030
from pathlib import Path
3131
from datetime import datetime
32-
from typing import Dict, Optional, Any, List
32+
from typing import Dict, Optional, Any, List, Union
3333

3434
# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by
3535
# /usage; we still import it at module top in the gateway because test
@@ -454,6 +454,7 @@ def _ensure_ssl_certs() -> None:
454454
from gateway.delivery import DeliveryRouter
455455
from gateway.platforms.base import (
456456
BasePlatformAdapter,
457+
EphemeralReply,
457458
MessageEvent,
458459
MessageType,
459460
merge_pending_message_event,
@@ -4472,7 +4473,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
44724473
invalidation_reason="stop_command",
44734474
)
44744475
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key)
4475-
return "⚡ Stopped. You can continue this session."
4476+
return EphemeralReply("⚡ Stopped. You can continue this session.")
44764477

44774478
# /reset and /new must bypass the running-agent guard so they
44784479
# actually dispatch as commands instead of being queued as user
@@ -4677,7 +4678,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
46774678
# Force-clean the sentinel so the session is unlocked.
46784679
self._release_running_agent_state(_quick_key)
46794680
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key)
4680-
return "⚡ Force-stopped. The agent was still starting — session unlocked."
4681+
return EphemeralReply("⚡ Force-stopped. The agent was still starting — session unlocked.")
46814682
# Queue the message so it will be picked up after the
46824683
# agent starts.
46834684
adapter = self.adapters.get(source.platform)
@@ -6353,7 +6354,7 @@ def _format_session_info(self) -> str:
63536354

63546355
return "\n".join(lines)
63556356

6356-
async def _handle_reset_command(self, event: MessageEvent) -> str:
6357+
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
63576358
"""Handle /new or /reset command."""
63586359
source = event.source
63596360

@@ -6464,8 +6465,8 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
64646465
_tip_line = ""
64656466

64666467
if session_info:
6467-
return f"{header}\n\n{session_info}{_tip_line}"
6468-
return f"{header}{_tip_line}"
6468+
return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}")
6469+
return EphemeralReply(f"{header}{_tip_line}")
64696470

64706471
async def _handle_profile_command(self, event: MessageEvent) -> str:
64716472
"""Handle /profile — show active profile name and home directory."""
@@ -6713,7 +6714,7 @@ async def _handle_agents_command(self, event: MessageEvent) -> str:
67136714

67146715
return "\n".join(lines)
67156716

6716-
async def _handle_stop_command(self, event: MessageEvent) -> str:
6717+
async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
67176718
"""Handle /stop command - interrupt a running agent.
67186719

67196720
When an agent is truly hung (blocked thread that never checks
@@ -6738,7 +6739,7 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:
67386739
invalidation_reason="stop_command_pending",
67396740
)
67406741
logger.info("STOP (pending) for session %s — sentinel cleared", session_key)
6741-
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
6742+
return EphemeralReply("⚡ Stopped. The agent hadn't started yet — you can continue this session.")
67426743
if agent:
67436744
# Force-clean the session lock so a truly hung agent doesn't
67446745
# keep it locked forever.
@@ -6748,11 +6749,11 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:
67486749
interrupt_reason=_INTERRUPT_REASON_STOP,
67496750
invalidation_reason="stop_command_handler",
67506751
)
6751-
return "⚡ Stopped. You can continue this session."
6752+
return EphemeralReply("⚡ Stopped. You can continue this session.")
67526753
else:
67536754
return "No active task to stop."
67546755

6755-
async def _handle_restart_command(self, event: MessageEvent) -> str:
6756+
async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
67566757
"""Handle /restart command - drain active work, then restart the gateway."""
67576758
# Defensive idempotency check: if the previous gateway process
67586759
# recorded this same /restart (same platform + update_id) and the new
@@ -6778,7 +6779,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> str:
67786779
count = self._running_agent_count()
67796780
if count:
67806781
return f"⏳ Draining {count} active agent(s) before restart..."
6781-
return "⏳ Gateway restart already in progress..."
6782+
return EphemeralReply("⏳ Gateway restart already in progress...")
67826783

67836784
# Save the requester's routing info so the new gateway process can
67846785
# notify them once it comes back online.
@@ -6830,7 +6831,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> str:
68306831
self.request_restart(detached=True, via_service=False)
68316832
if active_agents:
68326833
return f"⏳ Draining {active_agents} active agent(s) before restart..."
6833-
return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`."
6834+
return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.")
68346835

68356836
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
68366837
"""Return True if this /restart is a Telegram re-delivery we already handled.
@@ -8321,7 +8322,7 @@ def _save_config_key(key_path: str, value):
83218322
return f"⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_"
83228323
return f"⚡ ✓ Priority Processing: **{label}** (this session only)"
83238324

8324-
async def _handle_yolo_command(self, event: MessageEvent) -> str:
8325+
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
83258326
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
83268327
from tools.approval import (
83278328
disable_session_yolo,
@@ -8333,10 +8334,10 @@ async def _handle_yolo_command(self, event: MessageEvent) -> str:
83338334
current = is_session_yolo_enabled(session_key)
83348335
if current:
83358336
disable_session_yolo(session_key)
8336-
return "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
8337+
return EphemeralReply("⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval.")
83378338
else:
83388339
enable_session_yolo(session_key)
8339-
return "⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution."
8340+
return EphemeralReply("⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution.")
83408341

83418342
async def _handle_verbose_command(self, event: MessageEvent) -> str:
83428343
"""Handle /verbose command — cycle tool progress display mode.

0 commit comments

Comments
 (0)