Files
hermes-agent/gateway/platforms/base.py
kshitijk4poor f39f76508e fix(gateway): tighten busy re-queue back-off identity check and test matrix
Review cleanups on the drain back-off:
- `_drain_after` now requires `guard`: a None default silently meant "release
  whatever guard is current", which is the guard-swap bug the parameter fixes.
- The identity rule is stated once (docstring, wrapped), and the redundant
  `pending_event is dispatched_event` disjunct is dropped: the same object
  always has an equal message_id, and when that id is empty its timestamp
  equals itself, so the remaining comparison already covers it.
- Tests drop the `_Adapter` alias and cut the hot-loop matrix from 6 to 4
  explicit cases (plain, rewrite with id, rewrite without id, steer). The
  demotion route does not interact with the identity axis, and each case
  spends a fixed 1s measuring window.
2026-09-27 20:44:38 +05:30

4836 lines
267 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Base platform adapter interface; every platform adapter inherits from BasePlatformAdapter."""
import asyncio
import contextlib
import inspect
import ipaddress
import logging
import math
import os
import random
import re
import socket as _socket
import subprocess
import sys
import tempfile
import threading
import time
import uuid
import weakref
from abc import ABC, abstractmethod
from urllib.parse import urlsplit
from utils import normalize_proxy_url
from agent.retry_utils import jittered_backoff
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy as _should_bypass_proxy
logger = logging.getLogger(__name__)
def _consume_detached_handler_exception(task: "asyncio.Task") -> None:
"""Done-callback for a detached fatal-error handler task (carrier cancelled in
``_notify_fatal_error``): retrieve its exception so asyncio never logs "never retrieved"."""
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error("Detached fatal-error handler task failed: %s", exc, exc_info=exc)
# Audio exts for native audio delivery; Telegram's narrower sets stay separate (.m2a is audio to
# Hermes but not to sendAudio).
_AUDIO_MIME_TYPES = {
".ogg": "audio/ogg", ".opus": "audio/opus", ".mp3": "audio/mpeg", ".m2a": "audio/mpeg",
".wav": "audio/wav", ".m4a": "audio/m4a", ".flac": "audio/flac"}
_AUDIO_EXTS = frozenset(_AUDIO_MIME_TYPES)
# Outbound dispatch partition for MEDIA/local files (image batch vs send_video).
_VIDEO_EXTS = frozenset({".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"})
_IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif"})
# Telegram sendAudio accepts only MP3 / M4A; others go via sendVoice (Opus/OGG) or as a document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
def transcode_to_ogg_opus(path: str, *, bitrate: str = "32k", timeout: int = 60,
output_path: "str | None" = None) -> "str | None":
"""Best-effort ffmpeg transcode to Ogg/Opus (voip-tuned) for native voice bubbles: the written
``.ogg`` path (a NEW temp file unless ``output_path`` is given; caller cleans up), or None when
ffmpeg is missing/fails. ``output_path`` may equal ``path`` (in-place container repair) — the
encode goes through a sidecar so a failed run never truncates the source. Blocking (to_thread)."""
import shutil as _shutil
ffmpeg = _shutil.which("ffmpeg")
if not ffmpeg:
return None
if output_path is None:
fd, ogg_path = tempfile.mkstemp(prefix="voice_transcode_", suffix=".ogg")
os.close(fd)
else:
ogg_path = output_path
in_place = os.path.abspath(str(path)) == os.path.abspath(ogg_path)
work_path = ogg_path + ".tmp.ogg" if in_place else ogg_path
try:
result = subprocess.run(
[ffmpeg, "-v", "error", "-y", "-i", str(path),
"-acodec", "libopus", "-ac", "1", "-b:a", bitrate, "-vbr", "on",
"-application", "voip", "-compression_level", "10", "-f", "ogg", work_path],
capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL)
if result.returncode == 0 and os.path.getsize(work_path) > 0:
if in_place:
os.replace(work_path, ogg_path)
return ogg_path
logger.warning("ffmpeg Ogg/Opus transcode of %s failed (returncode=%s): %s", path, result.returncode,
(result.stderr or b"").decode("utf-8", errors="replace")[:500])
except Exception:
logger.warning("voice transcode to Ogg/Opus failed for %s", path, exc_info=True)
with contextlib.suppress(OSError):
os.unlink(work_path)
return None
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
# History dedup is best-effort: stay well below the Discord heartbeat watchdog and fail open.
_HISTORY_MEDIA_LOOKUP_TIMEOUT_SECONDS = 5.0
# Timed-out reads can't be cancelled mid-SQLite: cap the isolated threads so wedged lookups
# can't exhaust the shared executor.
_HISTORY_MEDIA_LOOKUP_MAX_WORKERS = 2
_HISTORY_MEDIA_LOOKUP_ADMISSION = threading.BoundedSemaphore(_HISTORY_MEDIA_LOOKUP_MAX_WORKERS)
def _platform_name(platform) -> str:
"""Normalize a Platform enum / raw string into a lowercase name."""
value = getattr(platform, "value", platform)
return str(value or "").lower()
def _or_default(thunk, default, exc=(TypeError, ValueError)):
"""``thunk()``, or ``default`` when it raises one of ``exc`` (numeric config/env coercion)."""
try:
return thunk()
except exc:
return default
DEFAULT_BUSY_TEXT_DEBOUNCE_SECONDS = 0.35
DEFAULT_BUSY_TEXT_HARD_CAP_SECONDS = 1.0
def _thread_metadata_for_source(source, reply_to_message_id: str | None = None) -> dict | None:
"""Platform-aware thread metadata for adapter sends. Telegram DM topics route with
``message_thread_id`` + a reply anchor; anchorless synthetic/resumed sends fall back to
``direct_messages_topic_id`` when supported."""
thread_id = getattr(source, "thread_id", None)
platform = _platform_name(getattr(source, "platform", None))
metadata = {"thread_id": thread_id} if thread_id is not None else {}
# Slack workspace identity is routing state: carry it so a multi-workspace Socket Mode
# gateway never falls back to its primary WebClient.
scope_id = getattr(source, "scope_id", None) if platform == "slack" else None
if scope_id:
metadata["slack_team_id"] = str(scope_id)
if not metadata:
return None
if platform == "telegram" and getattr(source, "chat_type", None) == "dm":
metadata["telegram_dm_topic_reply_fallback"] = True
if str(thread_id) not in {"", "1"}:
metadata["direct_messages_topic_id"] = str(thread_id)
anchor = reply_to_message_id or getattr(source, "message_id", None)
if anchor is not None:
metadata["telegram_reply_to_message_id"] = str(anchor)
# Routed profile (multiplex / profile_routes): outbound prune paths must not assume the
# adapter's static profile stamp.
profile = str(getattr(source, "profile", None) or "").strip()
if profile:
metadata["hermes_profile"] = profile
return metadata
def _thread_metadata_for_event(event) -> dict | None:
"""``_thread_metadata_for_source`` for an event, anchored on its reply id."""
return _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
def _mark_notify_metadata(metadata: dict | None) -> dict:
"""Clone metadata and mark a user-visible reply as notify-worthy."""
notify_metadata = dict(metadata) if metadata else {}
notify_metadata["notify"] = True
return notify_metadata
def _reply_anchor_for_event(event) -> str | None:
"""Return reply_to id for platforms that need reply semantics."""
override = getattr(event, "reply_anchor_override", None)
if override is not None:
return override # the turn was redirected onto another message (#115001)
source = getattr(event, "source", None)
platform = _platform_name(getattr(source, "platform", None))
thread_id = getattr(source, "thread_id", None)
raw_message = getattr(event, "raw_message", None)
if (platform == "slack" and isinstance(raw_message, dict)
and raw_message.get("_hermes_no_thread_response")):
# Slack reaction handoff = new top-level message; a message_id anchor would make
# _resolve_thread_ts() reply in a nonexistent thread.
return None
if platform == "telegram" and thread_id:
# Forum topics route by topic metadata (no reply); DM-topic lanes reply to the triggering
# message — replying to the topic seed/anchor can render outside the active lane.
if getattr(source, "chat_type", None) != "dm":
return None
return getattr(event, "message_id", None) or getattr(event, "reply_to_message_id", None)
if platform == "feishu" and thread_id and getattr(event, "reply_to_message_id", None):
return getattr(event, "reply_to_message_id", None)
return getattr(event, "message_id", None)
def _media_failure_text(kind: str, file_name: "str | None" = None) -> str:
"""User-facing "couldn't deliver" notice; ``file_name`` is the only name ever shown."""
suffix = f" ({file_name})" if file_name else ""
return f"⚠️ Couldn't deliver the {kind} attachment{suffix}."
def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
"""True when a media file should use the platform's audio sender. Telegram: explicit
``is_voice`` ([[audio_as_voice]]) routes ANY format to the voice sender (adapter transcodes
non-Opus); otherwise only sendAudio's MP3/M4A qualify — a plain Opus/OGG attachment is never
turned into a voice bubble, everything else → document. Other platforms: any audio ext."""
normalized_ext = (ext or "").lower()
if normalized_ext not in _AUDIO_EXTS:
return False
if _platform_name(platform) != "telegram":
return True
return is_voice or normalized_ext in _TELEGRAM_AUDIO_ATTACHMENT_EXTS
def build_auto_tts_output_path(platform) -> str:
"""Unique temp output path for gateway auto-TTS: ``.ogg`` for ``OPUS_VOICE_PLATFORMS``
(the tool's ``_repair_ogg_container`` then guarantees real Opus bytes), else ``.mp3``.
Platform-awareness lives HERE because ``_clear_session_env`` wipes the TTS tool's
``HERMES_SESSION_PLATFORM`` contextvar before the post-handler auto-TTS block runs.
Platforms whose native voice bubbles require Ogg/Opus (``tools.tts_tool.OPUS_VOICE_PLATFORMS`` — the
single source of truth) get an explicit ``.ogg`` path; the tool's central container repair
(``_repair_ogg_container``) then guarantees real Ogg/Opus bytes for every provider, including MP3-only
backends like Edge TTS. Everything else keeps the MP3 default. See #36685, #57049.
"""
from tools.tts_tool import OPUS_VOICE_PLATFORMS
ext = "ogg" if _platform_name(platform) in OPUS_VOICE_PLATFORMS else "mp3"
audio_path = os.path.join(
tempfile.gettempdir(), "hermes_voice", f"tts_reply_{uuid.uuid4().hex[:12]}.{ext}")
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
return audio_path
def utf16_len(s: str) -> int:
"""UTF-16 code units in *s* — Telegram's 4 096 limit counts those, so astral chars
(emoji, CJK Ext B) cost **two** units although Python's ``len()`` counts one.
Ported from nearai/ironclaw#2304 which discovered the same discrepancy in Rust's ``chars().count()``.
"""
return len(s.encode("utf-16-le")) // 2
def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int:
"""Largest codepoint offset *n* with ``len_fn(s[:n]) <= budget`` (binary search)."""
if len_fn(s) <= budget:
return len(s)
lo, hi = 0, len(s)
while lo < hi:
mid = (lo + hi + 1) // 2
if len_fn(s[:mid]) <= budget:
lo = mid
else:
hi = mid - 1
return lo
def _prefix_within_utf16_limit(s: str, limit: int) -> str:
"""Longest prefix of *s* with UTF-16 length ≤ *limit*; never splits a surrogate pair."""
return s[:_custom_unit_to_cp(s, limit, utf16_len)]
def is_network_accessible(host: str) -> bool:
"""True if *host* would expose the server beyond loopback (incl. IPv4-mapped
::ffff:127.0.0.1); hostnames are resolved and DNS failure fails closed (True)."""
with contextlib.suppress(ValueError): # ValueError: hostname — resolve below
addr = ipaddress.ip_address(host)
# ::ffff:127.0.0.1 reports is_loopback=False; check the mapped IPv4 explicitly.
mapped = getattr(addr, "ipv4_mapped", None)
return not (addr.is_loopback or (mapped and mapped.is_loopback))
try:
resolved = _socket.getaddrinfo(host, None, _socket.AF_UNSPEC, _socket.SOCK_STREAM)
# Network-accessible if any resolved address is non-loopback.
return any(not ipaddress.ip_address(sockaddr[0]).is_loopback for *_, sockaddr in resolved)
except (_socket.gaierror, OSError):
return True
# ``scutil --proxy`` is a fork+exec (~11 ms measured) and resolve_proxy_url runs it on the SEND path —
# per chunk of an outbound message and per media attachment, not once per adapter. The answer is an
# OS-level network setting that changes when someone edits Network Settings or joins a VPN, so it is
# cached briefly rather than per call. The TTL is the staleness a proxy change can suffer; a send that
# goes out on a stale answer fails and is retried, which is the same outcome as any transient proxy error.
# No lock: a race costs one extra fork and both answers are equally current.
_MACOS_PROXY_TTL_SECONDS = 60.0
_macos_proxy_cache: "tuple[float, str | None] | None" = None
def _detect_macos_system_proxy() -> str | None:
"""Read the macOS system HTTP(S) proxy via ``scutil --proxy``: ``http://host:port``
when an HTTP(S) proxy is enabled, else None (non-macOS or any subprocess error).
Memoised for ``_MACOS_PROXY_TTL_SECONDS``; call :func:`reset_macos_proxy_cache` to force a re-read.
"""
global _macos_proxy_cache
if sys.platform != "darwin":
return None
cached = _macos_proxy_cache
now = time.monotonic()
if cached is not None and (now - cached[0]) < _MACOS_PROXY_TTL_SECONDS:
return cached[1]
try:
out = subprocess.check_output(["scutil", "--proxy"], timeout=3, text=True, encoding='utf-8',
errors='replace', stderr=subprocess.DEVNULL)
except Exception:
# Cache the failure too: a broken/slow scutil must not re-fork on every chunk.
_macos_proxy_cache = (now, None)
return None
props = {
key.strip(): val.strip()
for key, sep, val in (line.strip().partition(" : ") for line in out.splitlines()) if sep}
# Prefer HTTPS, fall back to HTTP
resolved = None
for enable_key, host_key, port_key in (
("HTTPSEnable", "HTTPSProxy", "HTTPSPort"), ("HTTPEnable", "HTTPProxy", "HTTPPort")):
if props.get(enable_key) == "1" and props.get(host_key) and props.get(port_key):
resolved = f"http://{props[host_key]}:{props[port_key]}"
break
_macos_proxy_cache = (now, resolved)
return resolved
def reset_macos_proxy_cache() -> None:
"""Drop the memoised ``scutil --proxy`` answer so the next call re-reads it."""
global _macos_proxy_cache
_macos_proxy_cache = None
def should_bypass_proxy(target_hosts: str | list[str] | tuple[str, ...] | set[str] | None) -> bool:
"""True when NO_PROXY/no_proxy matches at least one target host (exact hosts, domain /
wildcard suffixes, IP literals, CIDR ranges, optional host:port entries, ``*``)."""
return _should_bypass_proxy(target_hosts)
def resolve_proxy_url(
platform_env_var: str | None = None, *,
target_hosts: str | list[str] | tuple[str, ...] | set[str] | None = None,
configured: str | None = None) -> str | None:
"""Proxy URL: *platform_env_var* (e.g. ``DISCORD_PROXY``) first, then the adapter's own YAML
value *configured* (``telegram.proxy_url``), then HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any
case), then the macOS system proxy — the latter two only when ``gateway.trust_env`` is true.
None when nothing is found or NO_PROXY matches a target.
*platform_env_var* is a per-adapter, per-profile-configurable setting (each proxy URL can
embed credentials, e.g. ``http://user:pass@host``) so it is read scope-aware: under a
secondary multiplex profile it comes from that profile's own ``.env``, not the shared
process env another profile's ``TELEGRAM_PROXY``/``DISCORD_PROXY``/etc. may hold; the YAML
value is the same profile's, so a secondary keeps its configured route without any env
bridge (#108440). The generic ``HTTPS_PROXY``/``HTTP_PROXY``/``ALL_PROXY`` fallback stays a raw
process-env read — those are OS/system-level network settings, not a per-profile Hermes concept."""
from gateway.platforms._shared import get_scoped_secret as _get_scoped_proxy_var
value = (_get_scoped_proxy_var(platform_env_var, "") or "").strip() if platform_env_var else ""
if not value:
value = str(configured or "").strip()
if not value:
if not gateway_trust_env(): # only the explicit per-platform var is honored
return None
value = first_proxy_env_value()
proxy = normalize_proxy_url(value or _detect_macos_system_proxy())
return None if proxy and should_bypass_proxy(target_hosts) else proxy
def _aiohttp_socks_connector(proxy_url: str):
"""``aiohttp_socks.ProxyConnector`` for ``proxy_url``, or None when aiohttp_socks is missing
(SOCKS logs a warning; HTTP callers fall back to ``proxy=``). ``rdns=True`` forces remote DNS
through the proxy — required by Shadowrocket/Clash-style SOCKS and against GFW DNS pollution."""
try:
from aiohttp_socks import ProxyConnector
return ProxyConnector.from_url(proxy_url, rdns=True)
except ImportError:
if proxy_url.lower().startswith("socks"):
logger.warning("aiohttp_socks not installed — SOCKS proxy %s ignored. "
"Use an HTTP proxy instead.", proxy_url)
return None
def proxy_kwargs_for_bot(proxy_url: str | None) -> dict:
"""Kwargs for ``commands.Bot()`` / ``discord.Client()``: SOCKS → ``{"connector"}``,
HTTP → ``{"proxy": url}``, None → ``{}``."""
if not proxy_url:
return {}
if proxy_url.lower().startswith("socks"):
connector = _aiohttp_socks_connector(proxy_url)
return {"connector": connector} if connector is not None else {}
return {"proxy": proxy_url}
def _config_section(name: str) -> dict:
"""Read-only ``config.yaml`` section ``name``; ``{}`` when unreadable/missing/not a dict."""
try:
from hermes_cli.config import load_config_readonly as _load_config
cfg = _load_config() # read-only: .get() only, never mutated
except Exception:
return {}
section = cfg.get(name) if isinstance(cfg, dict) else None
return section if isinstance(section, dict) else {}
def gateway_trust_env() -> bool:
"""``gateway.trust_env`` from config.yaml (default True): whether gateway
``aiohttp.ClientSession``s honor HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE. Set false
when the gateway inherits a proxy env it must not use. Fail-open to default."""
value = _config_section("gateway").get("trust_env", True)
if isinstance(value, str):
return value.strip().lower() not in {"0", "false", "no", "off"}
return bool(value) if value is not None else True
def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
"""``(session_kwargs, request_kwargs)`` for a standalone ``aiohttp.ClientSession``. With
aiohttp-socks every scheme uses a connector (mautrix-style libs never forward per-request
``proxy=``); without it HTTP falls back to ``({}, {"proxy": url})`` and SOCKS is ignored."""
if not proxy_url:
return {}, {}
connector = _aiohttp_socks_connector(proxy_url)
if connector is not None:
return {"connector": connector}, {}
return ({}, {}) if proxy_url.lower().startswith("socks") else ({}, {"proxy": proxy_url})
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
"""Return True when ``hostname`` matches a ``NO_PROXY`` entry (``no_proxy_value`` overrides the
environment); same matcher as :func:`should_bypass_proxy`."""
return _should_bypass_proxy(hostname, no_proxy_value=no_proxy_value)
import dataclasses
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import fence_state_after
from gateway.platforms.base_exec_approval import (
EA_HEADER_TEXT, EA_REASON_LABEL_TEXT, approval_timeout_seconds, format_approval_deadline_line)
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
from gateway.warning_notifications import diagnostic_wake_muted
from gateway.session import SessionSource, build_session_key
from gateway.session_transcript import TranscriptReadError
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
if TYPE_CHECKING:
from agent.display import ToolPreview
@dataclass
# --------------------------------------------------------------------------- Streaming TTS format
# descriptor and handle (#60671) ---------------------------------------------------------------------------
class AudioFormat:
"""Declared PCM format for a streaming-TTS session: every ``write_streaming_tts``
chunk must be raw little-endian PCM at this rate / channels / sample width."""
sample_rate: int = 24000
channels: int = 1
sample_width: int = 2 # bytes per sample (int16 = 2)
@dataclass
class StreamingTTSHandle:
"""Opaque handle returned by ``begin_streaming_tts``; adapters may extend it with
platform state. The base fields are consumer bookkeeping / cancellation."""
chat_id: str = ""
audio_format: AudioFormat = field(default_factory=AudioFormat)
# True once the first PCM chunk is written: a later failure then ends cleanly instead of
# falling back to whole-file TTS (don't replay already-audible output).
audible: bool = False
aborted: bool = False # set by abort_streaming_tts; late chunks are dropped
def streaming_tts_turn_key(session_key: str | None, turn_marker: Any = None, *, event: Any = None) -> str | None:
"""Per-turn streaming-TTS suppression key — turn-scoped (not chat-scoped) so
overlapping turns in one chat can't suppress each other's fallback paths.
``turn_marker`` is normally the run generation, else the event's message/update id."""
if not session_key:
return None
if turn_marker is None and event is not None:
turn_marker = getattr(event, "message_id", None) or getattr(event, "platform_update_id", None)
return None if turn_marker is None else f"{session_key}:{turn_marker}"
def streaming_tts_should_skip_whole_file(completed_turns: set[str], session_key: str | None,
turn_marker: Any = None, *, event: Any = None) -> bool:
"""Pure, turn-scoped auto-TTS suppression decision (testable without the adapter stack)."""
turn_key = streaming_tts_turn_key(session_key, turn_marker, event=event)
return bool(turn_key and turn_key in completed_turns)
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
"Secure secret entry is not supported over messaging. "
"Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually.")
# One sentence for every "you may not press/run this" refusal on every platform (slash commands,
# approval buttons, pickers, prompts). ``{platform}`` is the ``Platform.value`` for the
# ``hermes pairing approve`` command (hermes_cli/subcommands/pairing.py) that lets the owner fix it.
# Kept under 200 chars: Telegram's answerCallbackQuery truncates longer text.
UNAUTHORIZED_ACTION_NOTICE = (
"This bot is private and you're not on its allowed list. If you own it, run "
"`hermes pairing approve {platform} <request-id>` on the host (`hermes pairing list` shows the id).")
def unauthorized_action_notice(platform: Any) -> str:
"""``UNAUTHORIZED_ACTION_NOTICE`` for a ``Platform`` member or its string name."""
name = getattr(platform, "value", platform)
return UNAUTHORIZED_ACTION_NOTICE.format(platform=str(name or "<platform>"))
def safe_url_for_log(url: str, max_len: int = 80) -> str:
"""Return a URL string safe for logs (no query/fragment/userinfo)."""
raw = str(url) if max_len > 0 and url is not None else ""
if not raw:
return ""
try:
parsed = urlsplit(raw)
except Exception:
return raw[:max_len]
safe = raw
if parsed.scheme and parsed.netloc:
# Strip potential embedded credentials (user:pass@host).
path = parsed.path or ""
basename = path.rsplit("/", 1)[-1]
tail = "" if path in ("", "/") else f"/.../{basename}" if basename else "/..."
safe = f"{parsed.scheme}://{parsed.netloc.rsplit('@', 1)[-1]}{tail}"
if len(safe) <= max_len:
return safe
return "." * max_len if max_len <= 3 else f"{safe[:max_len - 3]}..."
async def _ssrf_redirect_guard(response):
"""Re-validate each redirect target (a public URL 302-ing to http://169.254.169.254/ would
bypass the pre-flight is_safe_url()). Async because httpx awaits response event hooks."""
from tools.url_safety import is_safe_url, redirect_target_from_response
redirect_url = redirect_target_from_response(response)
if redirect_url and not is_safe_url(redirect_url):
raise ValueError(f"Blocked redirect to private/internal address: {safe_url_for_log(redirect_url)}")
# Inbound images are cached locally for the vision tool (platform URLs are ephemeral).
# Import-time default; tests monkeypatch it, getters re-resolve per call.
IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache")
# Inbound media cap (``gateway.max_inbound_media_bytes``): payloads are buffered fully in memory,
# so an uncapped upload (Discord Nitro: 500 MB) could OOM-kill the gateway.
# Inbound image / audio / video payloads are buffered fully into process memory before being written to the
# cache directory. With no cap, a single large upload (Discord Nitro allows 500 MB) — or a remote URL in an
# inbound message payload pointing at an arbitrarily large file — can spike RAM and OOM-kill the gateway.
# The ``cache_*_from_bytes`` helpers (the shared funnel every platform reaches eventually) and the
# ``cache_*_from_url`` downloaders enforce this cap, so the protection holds regardless of which platform
# adapter or code path produced the bytes. Configurable via ``gateway.max_inbound_media_bytes`` in
# config.yaml. ``0`` disables the cap. Default 128 MiB — generous enough for ordinary photos/voice
# notes/short clips while still bounding a hostile upload.
# --------------------------------------------------------------------------- See #13145.
DEFAULT_INBOUND_MEDIA_MAX_BYTES = 128 * 1024 * 1024
def get_inbound_media_max_bytes() -> int:
"""Max inbound media bytes held in memory (``gateway.max_inbound_media_bytes``);
``0`` / negative / unparseable disables the cap; unreadable config → default."""
return _or_default(lambda: int(_config_section("gateway")["max_inbound_media_bytes"]),
DEFAULT_INBOUND_MEDIA_MAX_BYTES, (KeyError, TypeError, ValueError))
def validate_inbound_media_size(
size: int, *, media_type: str = "media", max_bytes: Optional[int] = None) -> None:
"""Raise ``ValueError`` if an inbound payload exceeds the cap (``max_bytes`` of ``0``
disables it; pass it explicitly to resolve the limit once across an incremental read)."""
limit = get_inbound_media_max_bytes() if max_bytes is None else max_bytes
if limit and size > limit:
raise ValueError(f"Inbound {media_type} payload is too large ({size} bytes > {limit} bytes)")
async def _read_httpx_body_with_limit(response, *, media_type: str) -> bytes:
"""Read an httpx streaming body under the media cap: reject an oversized ``Content-Length``
early, then re-check the running total per chunk (a lying/absent header can't smuggle more)."""
max_bytes = get_inbound_media_max_bytes()
content_length = response.headers.get("content-length")
if content_length:
try:
declared_size = int(content_length)
except ValueError:
logger.debug("Ignoring invalid Content-Length for inbound %s: %r", media_type, content_length)
else:
validate_inbound_media_size(declared_size, media_type=media_type, max_bytes=max_bytes)
chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
total += len(chunk)
validate_inbound_media_size(total, media_type=media_type, max_bytes=max_bytes)
chunks.append(chunk)
return b"".join(chunks)
def _cache_dir_accessors(kind: str, constant_name: str, new_subpath: str, old_name: str):
"""``(get_<kind>_cache_dir, cleanup_<kind>_cache)`` pair. The getter resolves fresh via
get_hermes_dir (active profile) unless a test monkeypatched the module constant away from
its import-time default, and creates the directory; ``cleanup(max_age_hours=24)`` deletes
older files and returns the count."""
def get_dir() -> Path:
d = get_hermes_dir(new_subpath, old_name)
current = globals().get(constant_name)
default = _CACHE_DIR_IMPORT_DEFAULTS.get(constant_name)
if current is not None and default is not None and current != default:
d = Path(current)
d.mkdir(parents=True, exist_ok=True)
return d
def cleanup(max_age_hours: int = 24) -> int:
return _cleanup_cache_dir(get_dir(), max_age_hours)
get_dir.__name__ = get_dir.__qualname__ = f"get_{kind}_cache_dir"
cleanup.__name__ = cleanup.__qualname__ = f"cleanup_{kind}_cache"
return get_dir, cleanup
get_image_cache_dir, cleanup_image_cache = _cache_dir_accessors(
"image", "IMAGE_CACHE_DIR", "cache/images", "image_cache")
def _looks_like_image(data: bytes) -> bool:
"""Return True if *data* starts with a known image magic-byte sequence."""
return len(data) >= 4 and (data[:8] == b"\x89PNG\r\n\x1a\n" or data[:3] == b"\xff\xd8\xff"
or data[:6] in {b"GIF87a", b"GIF89a"} or data[:2] == b"BM"
or (data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP"))
def _write_cache_file(cache_dir: Path, prefix: str, ext: str, data: bytes) -> str:
"""Write ``data`` to ``<cache_dir>/<prefix>_<uuid12><ext>``; return the path string."""
filepath = cache_dir / f"{prefix}_{uuid.uuid4().hex[:12]}{ext}"
filepath.write_bytes(data)
return str(filepath)
def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
"""Save raw image bytes to the cache and return the absolute path; raises
ValueError when *data* isn't an image (e.g. an upstream HTML error page)."""
validate_inbound_media_size(len(data), media_type="image")
if not _looks_like_image(data):
snippet = data[:80].decode("utf-8", errors="replace")
raise ValueError(f"Refusing to cache non-image data as {ext} (starts with: {snippet!r})")
return _write_cache_file(get_image_cache_dir(), "img", ext, data)
async def cache_image_from_bytes_async(data: bytes, ext: str = ".jpg") -> str:
"""Cache image bytes without blocking the caller's event loop."""
return await asyncio.to_thread(cache_image_from_bytes, data, ext)
async def _cache_media_from_url(url: str, ext: str, retries: int, *, media_type: str, accept: str,
cache_fn, log_label: str) -> str:
"""Shared downloader behind ``cache_*_from_url``: SSRF-checked (pre-flight + per-redirect;
raises ValueError), size-capped, linear-backoff retries on timeouts / 429 / 5xx."""
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
import httpx
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}")
headers = {"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)", "Accept": accept}
async with create_ssrf_safe_async_client(
timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]},
) as client:
for attempt in range(retries + 1):
try:
async with client.stream("GET", url, headers=headers) as response:
response.raise_for_status()
content = await _read_httpx_body_with_limit(response, media_type=media_type)
return await asyncio.to_thread(cache_fn, content, ext)
except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 429:
raise
if attempt < retries:
wait = 1.5 * (attempt + 1)
logger.debug("%s cache retry %d/%d for %s (%.1fs): %s", log_label, attempt + 1,
retries, safe_url_for_log(url), wait, exc)
await asyncio.sleep(wait)
continue
raise
async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> str:
"""Download an image URL into the image cache; return the absolute path."""
return await _cache_media_from_url(
url, ext, retries, media_type="image", accept="image/*,*/*;q=0.8",
cache_fn=cache_image_from_bytes, log_label="Media")
def _cleanup_cache_dir(cache_dir: Path, max_age_hours: int) -> int:
"""Delete files in *cache_dir* older than *max_age_hours*; return the count removed."""
cutoff = time.time() - (max_age_hours * 3600)
removed = 0
for f in cache_dir.iterdir():
if f.is_file() and f.stat().st_mtime < cutoff:
with contextlib.suppress(OSError):
f.unlink()
removed += 1
return removed
# Audio cache utilities (same pattern as images; feeds the STT tool).
AUDIO_CACHE_DIR = get_hermes_dir("cache/audio", "audio_cache")
get_audio_cache_dir, cleanup_audio_cache = _cache_dir_accessors(
"audio", "AUDIO_CACHE_DIR", "cache/audio", "audio_cache")
def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
"""Save raw audio bytes to the cache (container-sniffed ext); return the path."""
# tools.audio_container is the ONE owner of container detection (outbound TTS repair + here).
from tools.audio_container import sniff_audio_ext
validate_inbound_media_size(len(data), media_type="audio")
return _write_cache_file(get_audio_cache_dir(), "audio", sniff_audio_ext(data, ext), data)
async def cache_audio_from_bytes_async(data: bytes, ext: str = ".ogg") -> str:
"""Cache audio bytes without blocking the caller's event loop."""
return await asyncio.to_thread(cache_audio_from_bytes, data, ext)
async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> str:
"""Download an audio URL into the audio cache; return the absolute path."""
return await _cache_media_from_url(
url, ext, retries, media_type="audio", accept="audio/*,*/*;q=0.8",
cache_fn=cache_audio_from_bytes, log_label="Audio")
# Video cache utilities (same pattern; referenced by local path).
VIDEO_CACHE_DIR = get_hermes_dir("cache/videos", "video_cache")
get_video_cache_dir, cleanup_video_cache = _cache_dir_accessors(
"video", "VIDEO_CACHE_DIR", "cache/videos", "video_cache")
SUPPORTED_VIDEO_TYPES = {
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
".mkv": "video/x-matroska", ".avi": "video/x-msvideo"}
def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
"""Save raw video bytes to the cache and return the absolute file path."""
validate_inbound_media_size(len(data), media_type="video")
return _write_cache_file(get_video_cache_dir(), "video", ext, data)
async def cache_video_from_bytes_async(data: bytes, ext: str = ".mp4") -> str:
"""Cache video bytes without blocking the caller's event loop."""
return await asyncio.to_thread(cache_video_from_bytes, data, ext)
# Document / screenshot cache utilities (same pattern; referenced by local path).
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
get_document_cache_dir, cleanup_document_cache = _cache_dir_accessors(
"document", "DOCUMENT_CACHE_DIR", "cache/documents", "document_cache")
get_screenshot_cache_dir, cleanup_screenshot_cache = _cache_dir_accessors(
"screenshot", "SCREENSHOT_CACHE_DIR", "cache/screenshots", "browser_screenshots")
# Import-time defaults; _resolve_cache_dir compares against these to detect a test monkeypatch.
_CACHE_DIR_IMPORT_DEFAULTS = {
"IMAGE_CACHE_DIR": IMAGE_CACHE_DIR, "AUDIO_CACHE_DIR": AUDIO_CACHE_DIR,
"VIDEO_CACHE_DIR": VIDEO_CACHE_DIR, "DOCUMENT_CACHE_DIR": DOCUMENT_CACHE_DIR,
"SCREENSHOT_CACHE_DIR": SCREENSHOT_CACHE_DIR}
# Launch-time homes: fine for the static ALLOW roots below (per-profile cache roots are
# enumerated at check time), never for the credential DENY side — see _credential_home_roots.
_HERMES_HOME = get_hermes_home()
_HERMES_ROOT = get_default_hermes_root()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
# Strict mode = allowlist+recency validation; off by default (the denylist still blocks
# credential / system paths). Set true on public-facing gateways.
MEDIA_DELIVERY_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT"
# Canonical cache subdirs of deliverable artifacts; also enumerates per-profile cache roots.
_MEDIA_DELIVERY_CACHE_SUBDIRS = ("images", "audio", "videos", "documents", "screenshots")
MEDIA_DELIVERY_SAFE_ROOTS = (
IMAGE_CACHE_DIR, AUDIO_CACHE_DIR, VIDEO_CACHE_DIR, DOCUMENT_CACHE_DIR, SCREENSHOT_CACHE_DIR,
*(_HERMES_HOME / d for d in (
"image_cache", "audio_cache", "video_cache", "document_cache", "browser_screenshots")),
# Canonical cache layout, alongside the legacy *_cache dirs (installs may have both).
*(_HERMES_HOME / "cache" / d for d in _MEDIA_DELIVERY_CACHE_SUBDIRS))
# Recency window (s) for trusting fresh files: artifacts land seconds before delivery,
# pre-existing host files (/etc/passwd, ~/.ssh/id_rsa) are days/months old.
_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS = 600
# Hard denylist even for "recent" files (credentials, system state, /proc); the cache-dir
# allowlist still beats it.
_MEDIA_DELIVERY_DENIED_PREFIXES = (
"/etc", "/proc", "/sys", "/dev", "/root", "/boot", "/var/log", "/var/lib", "/var/run")
# Credential / config dirs denied under $HOME (Library/Keychains = macOS), resolved at check time.
_MEDIA_DELIVERY_DENIED_HOME_SUBPATHS = (
".ssh", ".aws", ".gnupg", ".kube", ".docker", ".config", ".azure", ".gcloud",
"Library/Keychains")
def _sqlite_files(name: str) -> tuple[str, ...]:
"""A SQLite store plus its WAL/SHM/rollback-journal sidecars."""
return (name, f"{name}-wal", f"{name}-shm", f"{name}-journal")
# Credential stores at the HERMES_HOME root, denied per-file so skills/, logs/ and agent-written
# files stay deliverable (cache subdirs are allowlisted BEFORE this). A superset of the
# agent/file_safety.py read+write denies so exfil never trails the read guard. google_token.json's mtime bumps every turn (defeats the
# recency window); pairing/ and mcp-tokens/ (live OAuth tokens) are denied as whole trees.
_ROOT_CREDENTIAL_PATHS = (
".env", "auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json",
"google_token.json", "google_oauth_pending.json", os.path.join("auth", "google_oauth.json"),
"webhook_subscriptions.json", os.path.join("cache", "bws_cache.json"),
os.path.join("cache", "bws_cache.enc.json"), "pairing", "mcp-tokens",
# Whole conversation history (every secret ever pasted into a chat) and the copied browser
# cookie/login store; sessions/ is the legacy transcript dir. SQLite sidecars are listed
# too: WAL mode touches state.db-wal on every write, so recency trust alone would leak them.
"sessions", "browser-profile", *_sqlite_files("state.db"), *_sqlite_files("kanban.db"))
def _profile_cache_roots() -> List[Path]:
"""Per-profile cache roots ``<root>/profiles/<name>/cache/{images,...}`` (the static safe
roots cover only the active HERMES_HOME). Enumerated at check time so profiles created after
startup count and are allowlisted BEFORE the ``/root`` denylist (HERMES_HOME symlinked).
``HERMES_HOME=/opt/data``) while the model emits a profile-scoped path silently fails delivery.
Enumerated dynamically at check time so profiles created after startup are covered, and so the resolved
profile path is allowlisted *before* the ``/root`` system denylist is consulted (which otherwise wins
when HERMES_HOME is symlinked under a denied prefix and $HOME is not that prefix). See issue #31733.
"""
return [p / "cache" / subdir for p in _profile_dirs() for subdir in _MEDIA_DELIVERY_CACHE_SUBDIRS]
def _profile_dirs() -> List[Path]:
"""Every ``<root>/profiles/<name>`` directory, read at check time."""
try:
return [p for p in (_HERMES_ROOT / "profiles").iterdir() if p.is_dir()]
except OSError:
return []
def _credential_home_roots() -> List[Path]:
"""Every Hermes home whose credential stores the denylist must cover: the ACTIVE home
(the per-turn HERMES_HOME override under ``gateway.multiplex_profiles``), the shared root
and every ``<root>/profiles/*``. Enumerated at check time like ``_profile_cache_roots`` on
the allow side — a denylist frozen at import covers only the launch profile, so a
``MEDIA:<root>/profiles/<other>/.env`` emitted in any profile's turn would upload it."""
return list(dict.fromkeys((get_hermes_home(), _HERMES_ROOT, *_profile_dirs())))
def _kanban_root() -> Path:
"""Kanban is root-shared across profiles by design (``kanban_db.kanban_home``)."""
return Path(os.environ.get("HERMES_KANBAN_HOME", "").strip() or _HERMES_ROOT).expanduser()
def _kanban_board_dirs() -> List[Path]:
"""Every directory under ``<root>/kanban/boards`` (lax on purpose: the DENY side must catch a
board whatever its name; the allow side filters further)."""
with contextlib.suppress(OSError):
return [p for p in (_kanban_root() / "kanban" / "boards").iterdir() if p.is_dir()]
return []
def _kanban_attachment_roots() -> List[Path]:
"""Return durable Kanban attachment roots without importing kanban_db."""
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
if override:
return [Path(override).expanduser()]
roots = [_kanban_root() / "kanban" / "attachments"]
roots.extend(path / "attachments" for path in _kanban_board_dirs()
if not path.is_symlink() and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", path.name)
and (path / "kanban.db").is_file())
return roots
def _media_delivery_allowed_roots() -> List[Path]:
"""Return roots from which model-emitted local media may be delivered."""
from gateway.media_policy import media_delivery_allow_dirs
operator_roots = (
root for chunk in media_delivery_allow_dirs().split(os.pathsep)
for raw_root in chunk.split(",")
if (root := Path(os.path.expanduser(raw_root.strip()))).is_absolute())
return [*map(Path, MEDIA_DELIVERY_SAFE_ROOTS), *_profile_cache_roots(),
*_kanban_attachment_roots(), *operator_roots]
def _media_delivery_recency_seconds() -> float:
"""Recency window (seconds) for trusting fresh files; 0 = pure-allowlist mode."""
from gateway.media_policy import media_delivery_trust_recent, media_delivery_trust_recent_seconds
if not media_delivery_trust_recent():
return 0.0
custom = media_delivery_trust_recent_seconds().strip()
default = float(_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS)
return _or_default(lambda: max(0.0, float(custom)) if custom else default, default)
def _kanban_board_db_paths() -> List[Path]:
"""Named-board ``kanban.db`` stores (+ sidecars): they sit beside the ATTACHMENTS dir
``_kanban_attachment_roots`` allowlists and hold every task, comment and run transcript."""
return [board / name for board in _kanban_board_dirs() for name in _sqlite_files("kanban.db")]
def _media_delivery_denied_paths() -> List[Path]:
"""Return absolute denylist paths under which delivery is never allowed."""
home = Path(os.path.expanduser("~"))
return [*map(Path, _MEDIA_DELIVERY_DENIED_PREFIXES),
*(home / sub for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS),
*(r / rel for r in _credential_home_roots() for rel in _ROOT_CREDENTIAL_PATHS),
*_kanban_board_db_paths()]
def _resolve_path(path: Path, *, strict: bool = False, expand: bool = False) -> Optional[Path]:
"""``path[.expanduser()].resolve(strict)`` or None when it fails (OSError / RuntimeError /
ValueError — embedded NUL, symlink loop, undeterminable home, missing file under ``strict``)."""
try:
return (path.expanduser() if expand else path).resolve(strict=strict)
except (OSError, RuntimeError, ValueError):
return None
def _path_under_denied_prefix(resolved: Path) -> bool:
"""True if ``resolved`` lives under a deny-listed system path — except a denied prefix that
IS the running user's own home: ``/root`` is listed so a non-root gateway can't deliver
another user's home, but a root-run gateway's own deliverables live under ``$HOME=/root``.
Credential sub-dirs (``~/.ssh``, ``~/.hermes/.env``) stay blocked (more-specific entries)."""
home = _resolve_path(Path(os.path.expanduser("~")))
for denied in _media_delivery_denied_paths():
resolved_denied = _resolve_path(denied, expand=True)
if resolved_denied is None:
continue
hit = resolved == resolved_denied or _path_is_within(resolved, resolved_denied)
if hit and resolved_denied != home:
return True
return False
def _file_is_recently_produced(resolved: Path, window_seconds: float) -> bool:
"""True if mtime is within ``window_seconds`` — a session-scoped trust signal: agents
produce artifacts seconds before sending; pre-existing host files are days/months old."""
if window_seconds <= 0:
return False
try:
return (time.time() - resolved.stat().st_mtime) <= window_seconds
except OSError:
return False
def _path_is_within(path: Path, root: Path) -> bool:
with contextlib.suppress(ValueError):
path.relative_to(root)
return True
return False
def _tenv(name: str, default: str = "") -> str:
"""Scope-aware TERMINAL_* read: the per-turn scope carries the ACTIVE profile's settings while
os.getenv reads whatever a prior turn pinned into the process env. Only ImportError falls
back — a refusal scope must raise rather than rebuild another profile's policy from the env."""
try:
from tools.terminal_scope import terminal_env
except ImportError:
return os.getenv(name, default)
return terminal_env(name, default)
def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]:
"""Parse ``TERMINAL_DOCKER_VOLUMES`` (JSON list of ``host:container[:mode]``) into
``(host_path, container_path)``; named volumes / non-absolute hosts can't resolve here."""
raw = _tenv("TERMINAL_DOCKER_VOLUMES", "").strip()
try:
import json as _json
parsed = _json.loads(raw) if raw else []
except Exception:
return []
mounts: List[Tuple[Path, Path]] = []
for entry in parsed if isinstance(parsed, list) else ():
spec = entry.strip() if isinstance(entry, str) else ""
# Prefer the first ':/' so absolute container paths are unambiguous.
sep = spec.find(":/")
if sep <= 0:
continue
container_raw = spec[sep + 1:].split(":", 1)[0] # starts with /
# Skip named volumes (no absolute/drive host path).
host_expanded = os.path.expanduser(spec[:sep])
if not (host_expanded.startswith("/") or (len(host_expanded) > 1 and host_expanded[1] == ":")):
continue
host_path, container_path = _resolve_path(Path(host_expanded)), Path(container_raw)
if host_path is not None and container_path.is_absolute():
mounts.append((host_path, container_path))
return mounts
def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]:
"""Candidate host sandbox dir names for the delivering session, best first. Mirrors
``_resolve_container_task_id`` (tools/terminal_tool.py): containers are PROFILE-scoped
(``default``, else ``profile:<name>``); legacy ``session:<key>`` sandboxes stay as a fallback.
The key is passed explicitly because delivery runs after the turn's contextvars were cleared.
Takes the key explicitly because the delivery pipeline runs after ``_handle_message_with_agent`` cleared
the turn's session contextvars (#93950) — an ambient lookup here would silently collapse onto
``default`` and miss the session's real sandbox.
"""
try:
from tools.environments.path_utils import sanitize_task_id_for_path
except Exception:
return ["default"]
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name() or "default"
except Exception:
profile = "default"
candidates: List[str] = []
# Explicit trusted-profiles opt-in: one shared container identity.
if shared := _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip():
candidates.append(sanitize_task_id_for_path(f"shared:{shared}"))
if profile != "default":
candidates.append(sanitize_task_id_for_path(f"profile:{profile}"))
candidates.append("default")
if session_key: # bug-window legacy layout: per-session sandboxes
candidates.append(sanitize_task_id_for_path(f"session:{session_key}"))
return candidates
_TRUTHY = {"1", "true", "yes", "on"}
def _docker_env_active() -> bool:
return _tenv("TERMINAL_ENV", "").strip().lower() == "docker"
def _docker_persistent_active() -> bool:
"""Docker backend with persistent containers (the default) enabled."""
return _docker_env_active() and _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() in _TRUTHY
def _docker_persistent_sandbox_roots(session_key: str, leaf: str) -> List[Path]:
"""Existing ``<sandbox>/docker/<candidate>/<leaf>`` host dirs in candidate order;
the translator tries each until the file resolves. Empty unless Docker + persistent."""
if not _docker_persistent_active():
return []
try:
from tools.environments.base import get_sandbox_dir
base = get_sandbox_dir() / "docker"
return [cand for name in _docker_sandbox_dir_candidates(session_key)
if (cand := (base / name / leaf).resolve(strict=False)).is_dir()]
except Exception:
return []
def _default_docker_workspace_host_roots(session_key: str = "") -> List[Path]:
"""Existing host candidates for ``/workspace``: the explicit cwd mount
(``TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE``) if set, else the persistent sandbox layouts."""
if not _docker_persistent_active():
return []
if _tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in _TRUTHY:
cwd = _tenv("TERMINAL_CWD") or os.getcwd()
try:
host = Path(os.path.expanduser(cwd)).resolve(strict=False)
except (OSError, RuntimeError, ValueError):
return []
return [host] if host.is_dir() else []
return _docker_persistent_sandbox_roots(session_key, "workspace")
def _cache_dir_container_mounts() -> List[Tuple[Path, Path]]:
"""(host, container) pairs for the auto-mounted Hermes cache dirs (``/root/.hermes/...`` in
MEDIA tags); longer prefixes than the ``/root`` home mount, so longest-prefix match wins."""
if not _docker_env_active():
return []
try:
from tools.credential_files import get_cache_directory_mounts
return [(Path(m["host_path"]), Path(m["container_path"])) for m in get_cache_directory_mounts()]
except Exception:
return []
def _warn_unresolved_docker_media(candidate: Path, session_key: str, reason: str) -> None:
"""Name WHY a container-absolute MEDIA path failed translation (otherwise the only signal is
the generic "Skipping unsafe MEDIA directive path" line). Docker-only; host rejections quiet.
See #93950.
"""
if not _docker_env_active():
return
logger.warning("Docker MEDIA path %s did not resolve to a host sandbox file (%s%s); "
"the producing container's sandbox directory may not exist yet or "
"was pruned", _log_safe_path(str(candidate)), reason,
f", session_key={session_key}" if session_key else "")
def _translate_docker_container_media_path(candidate: Path, session_key: str = "") -> Optional[Path]:
"""Container-absolute path -> host path via longest-prefix match over ``docker_volumes``, the
auto-mounted cache dirs (``/root/.hermes/...``), persistent ``/workspace`` and ``/root``."""
if not candidate.is_absolute():
return None
# In-process gateways (Desktop, `hermes serve`) may not have bridged terminal.* config into
# TERMINAL_* env yet; the bridge is idempotent.
with contextlib.suppress(Exception):
from tools.terminal_tool import _ensure_terminal_env_bridged
_ensure_terminal_env_bridged()
mounts = [*_parse_docker_volume_mounts(), *_cache_dir_container_mounts()]
mounted = {c.as_posix() for _, c in mounts}
# Synthetic /workspace mounts: profile-scoped layout first, then legacy per-session.
if "/workspace" not in mounted:
mounts.extend((root, Path("/workspace")) for root in _default_docker_workspace_host_roots(session_key))
# Synthetic /root mounts catch stray home writes (/root/out.png; cache mounts are longer
# prefixes). /root/.hermes/* that missed a cache mount is the container's credential surface —
# translating it via the home mount would dodge the host denylist.
if "/root" not in mounted and not candidate.as_posix().startswith("/root/.hermes"):
mounts.extend(
(root, Path("/root")) for root in _docker_persistent_sandbox_roots(session_key, "home"))
if not mounts:
_warn_unresolved_docker_media(candidate, session_key, "no sandbox mounts resolved")
return None
# Longest container-prefix match; equal-length prefixes are tried in insertion order.
candidate_posix = candidate.as_posix()
matched = [(host_root, container_root, len(prefix)) for host_root, container_root in mounts
for prefix in (container_root.as_posix().rstrip("/") or "/",)
if candidate_posix == prefix or candidate_posix.startswith(prefix + "/")]
if not matched:
_warn_unresolved_docker_media(candidate, session_key, "no mounted prefix matches")
return None
for host_root, container_root, _score in sorted(matched, key=lambda m: -m[2]):
translated = _resolve_path(host_root / candidate.relative_to(container_root), strict=True)
if translated is not None and (
translated == host_root or _path_is_within(translated, host_root)):
return translated
_warn_unresolved_docker_media(candidate, session_key, "host file missing from sandbox")
return None
def validate_media_delivery_path(path: str, session_key: str = "") -> Optional[str]:
"""Safe absolute file path for native media delivery, else None. Default: any existing
regular file outside the credential / system denylist (symmetric with inbound). Strict
(``HERMES_MEDIA_DELIVERY_STRICT=1``, public bots where prompt injection must not exfiltrate
host secrets): MUST be under a Hermes cache, an operator root (``HERMES_MEDIA_ALLOW_DIRS``),
or freshly produced within the recency window. Symlinks are resolved before any check."""
candidate = _normalize_media_tag_path(path)
if not candidate:
return None
try:
expanded = Path(os.path.expanduser(candidate))
except (OSError, RuntimeError, ValueError):
# expanduser raises ValueError("embedded null byte") for a ~\x00 path.
return None
if not expanded.is_absolute():
return None
# Docker agents emit MEDIA:/workspace/... — map container paths to host paths first.
resolved = _translate_docker_container_media_path(expanded, session_key=session_key)
if resolved is None:
resolved = _resolve_path(expanded, strict=True)
if resolved is None or not resolved.is_file():
return None
# Cache / operator allowlist is trusted unconditionally, regardless of mode.
for root in _media_delivery_allowed_roots():
resolved_root = _resolve_path(root, expand=True)
if resolved_root is not None and _path_is_within(resolved, resolved_root):
return str(resolved)
# Non-strict (default): anything not denylisted (/etc, /proc, ~/.ssh, Hermes-root secrets).
from gateway.media_policy import media_delivery_strict
if not media_delivery_strict():
return None if _path_under_denied_prefix(resolved) else str(resolved)
# Strict: recency trust for fresh files (pandoc -o /tmp/x.pdf); denylist still applies.
window = _media_delivery_recency_seconds()
if (window > 0 and not _path_under_denied_prefix(resolved)
and _file_is_recently_produced(resolved, window)):
return str(resolved)
return None
# Control chars + Unicode line separators (NEL, LS, PS) that log aggregators treat as breaks: a
# model-emitted path must not forge a log line.
_LOG_UNSAFE_CHARS = re.compile(r"[\x00-\x1f\x7f\x85\u2028\u2029]")
def _log_safe_path(path: str) -> str:
"""Return a single-line, length-bounded path for log output."""
return _LOG_UNSAFE_CHARS.sub("?", str(path))[:200]
def _validated_delivery_path(raw_path, session_key: str, label: str,
dropped: Optional[List[dict]] = None) -> Optional[str]:
"""``validate_media_delivery_path`` plus the shared "Skipping unsafe ..." warning. A path the
host cannot see is retried against the active remote sandbox (ssh/modal/...; #466). When
``dropped`` is a list, a rejected path is appended as ``{"path", "reason"}`` so the caller can
report the drop instead of booking a delivery that never happened (#115908)."""
raw = str(raw_path)
safe_path = validate_media_delivery_path(raw, session_key=session_key)
if not safe_path:
from gateway.media_fetch import fetch_remote_media
safe_path = fetch_remote_media(raw)
if not safe_path:
# Say WHY: a path that does not exist on the host is the common case (a model hallucinated or
# a sandbox path failed to translate) and is not a security rejection.
reason = "not found on this host" if not _existing_regular_file(raw) else "denied by the delivery policy"
logger.warning("Skipping %s (%s): %s", label, reason, _log_safe_path(raw))
if dropped is not None:
dropped.append({"path": raw, "reason": reason})
return safe_path
def _existing_regular_file(raw: str) -> bool:
try:
return Path(os.path.expanduser(raw)).is_file()
except (OSError, RuntimeError, ValueError):
return False
SUPPORTED_DOCUMENT_TYPES = {
".pdf": "application/pdf", ".md": "text/markdown", ".txt": "text/plain", ".csv": "text/csv",
".log": "text/plain", ".json": "application/json", ".xml": "application/xml",
".yaml": "application/yaml", ".yml": "application/yaml", ".toml": "application/toml",
".ini": "text/plain", ".cfg": "text/plain", ".zip": "application/zip",
".doc": "application/msword", ".xls": "application/vnd.ms-excel",
".ppt": "application/vnd.ms-powerpoint",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ts": "text/plain", ".py": "text/plain", ".sh": "text/plain"}
# Files safe to inline into the prompt when small. An extension gate, NOT a blind UTF-8 decode
# (PDF/zip/docx can start with decodable ASCII). Non-members are still cached by path.
_TEXT_INJECT_EXTENSIONS = {
".txt", ".md", ".markdown", ".csv", ".tsv", ".log", ".json", ".jsonl", ".ndjson", ".xml",
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env", ".properties", ".html", ".htm",
".css", ".scss", ".sass", ".less", ".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".c", ".h", ".cpp", ".cc", ".hpp", ".cs",
".java", ".kt", ".go", ".rs", ".rb", ".php", ".pl", ".lua", ".r", ".jl", ".swift", ".m",
".scala", ".clj", ".ex", ".exs", ".erl", ".sql", ".graphql", ".proto", ".tf", ".hcl",
".dockerfile", ".makefile", ".cmake", ".gradle", ".rst", ".tex", ".srt", ".vtt", ".diff",
".patch"}
# Image exts platforms may deliver as "documents" (file-picker uploads); routed to the image cache.
SUPPORTED_IMAGE_DOCUMENT_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp",
".gif": "image/gif"}
# Media-delivery ext allowlist — SINGLE SOURCE OF TRUTH for both extractors and the cleanup
# regexes: a tag is stripped only when deliverable, unknown-ext paths survive in the body.
# Both extractors that turn response text into native attachments derive their extension set from this
# tuple: * ``extract_media()`` — explicit ``MEDIA:<path>`` tags * ``extract_local_files()`` — bare
# absolute/home paths the agent mentions Historically these two carried independently-maintained extension
# lists. ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
# ``extract_local_files`` had a broad one. Combined with the unconditional ``MEDIA:\\s*\\S+`` cleanup at the
# dispatch sites, that mismatch created a silent black hole: a ``MEDIA:/report.md`` tag failed the narrow
# extract_media match, got stripped from the body by the loose cleanup regex, and was then invisible to
# extract_local_files — the file was never delivered (issue #34517). Keeping one list eliminates the drift;
# building the cleanup regexes from the same set means a tag is only stripped when its extension is one we
# can actually deliver, so an unknown-extension path survives in the body instead of vanishing. Covers
# images (inline), video (inline where supported), audio (voice/audio), documents/spreadsheets/presentations
# (send_document), archives, and rendered web output. The dispatch partition (image vs video vs document)
# lives in ``gateway/run.py``. ---------------------------------------------------------------------------
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg", # images (embed inline)
".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp", # video (embed inline where supported)
".mp3", ".m2a", ".wav", ".ogg", ".opus", ".m4a", ".flac", # audio (voice/audio where supported)
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub", # documents (file attachments)
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml", # spreadsheets/data
".kmz", ".kml", ".geojson", ".gpx", # geospatial / GIS
".pptx", ".ppt", ".odp", ".key", # presentations
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa", # archives
".html", ".htm") # web / rendered output
# Bare extensions (no dot) longest-first so a shorter ext never matches as a prefix of a longer one.
_MEDIA_EXT_ALTERNATION = "|".join(sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True))
# Anchored ``MEDIA:<path>`` cleanup pattern (dispatch path + streaming consumer). Strips only tags
# whose path ends in a deliverable extension (optionally quoted/backticked); unknown-ext tags stay
# for the bare-path detector. Rules: anchors ``~/``, ``/``, ``X:\`` / ``X:/``; up to 3 quote/
# emphasis markers each side (code/blockquote contexts are masked earlier); non-greedy with
# ``MEDIA:`` as a boundary so glued tags (``MEDIA:/a.pngMEDIA:/b.png``) never merge; sentence-final
# ``.`` is a boundary only before whitespace/EOL (``data.csv.`` -> ``data.csv``, ``archive.tar.gz``
# extends past ``.tar``); CJK full-width punctuation terminates paths (``早报.pdf(782.6 KB)``).
# A ``MEDIA:`` tag with an unknown extension is left in the text so it can still be picked up by the
# bare-path detector (extract_local_files) downstream rather than silently deleted. Path anchors: ``~/``
# (Unix home-relative), ``/`` (Unix absolute), ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
# Emphasis tolerance: models routinely wrap the tag in Markdown emphasis (``**MEDIA:/x.pdf**``,
# ``*MEDIA:/x.pdf*``, ``_MEDIA:/x.pdf_``) when they present a file to the user. The old single-quote anchor
# (``[`"']?``) and the closing lookahead (which lacked ``*``/``_``) failed to match such tags, so the file
# was silently never delivered and the literal ``MEDIA:`` text leaked into the chat. Allow a short run of
# emphasis/quote markers on both sides so the tag is recognised regardless of cosmetic Markdown. Code-block
# / inline-code / blockquote contexts are still neutralised earlier by ``_mask_protected_spans`` (#35695),
# so example tags remain non-deliverable. The trailing lookahead also accepts ``MEDIA:`` as a boundary, so
# the next tag stops the current match cleanly (#68773). The whitespace guard keeps multi-part extensions
# intact — for ``archive.tar.gz`` the ``.`` after ``tar`` is followed by ``g``, so the match must extend to
# ``.gz`` instead of stopping early at ``.tar``. CJK full-width punctuation accepted as MEDIA path
# terminators, mirroring the ASCII set in the looka below. Chinese-language agent output naturally writes
# ``MEDIA:D:\path\早报.pdf(782.6 KB)`` or ``MEDIA:...pdf:内容`` — without these, the lookahead fails and the
# attachment is silently dropped (#88038).
_MEDIA_CJK_TERMINATORS = "()〈〉《》:,。;!?、\u201c\u201d\u2018\u2019【】"
MEDIA_TAG_CLEANUP_RE = re.compile(
r'''[`"'*_]{0,3}MEDIA:\s*'''
r'''(?P<path>`[^`\n]+?`|"[^"\n]+?"|'[^'\n]+?'|'''
r'''(?:~/|/|[A-Za-z]:[/\\])\S+?(?:[^\S\n]+\S+?)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
r'''(?=[\s`"'*_,;:)\]}\[''' + _MEDIA_CJK_TERMINATORS + r''']|MEDIA:|\.(?:\s|$)|$)[`"'*_]{0,3}\.?''',
re.IGNORECASE)
# Extension-less (Caddyfile) / unknown-ext (.py, .log) tags deliver only after
# ``validate_media_delivery_path`` accepts them, so injected paths that don't validate stay visible.
# The bare path class is whitespace-bounded (a tag glued to the next ``MEDIA:`` or prose must not
# absorb it); spaced paths are recovered by ``_match_extensionless_path`` with on-disk validation.
# Paths NOT covered by MEDIA_TAG_CLEANUP_RE's extension alternation — both extension-less files (Caddyfile,
# Dockerfile, Makefile) and files with an unknown extension (.py, .log, .weirdext, ...) — are validated and
# delivered via MEDIA_EXTENSIONLESS_TAG_RE. Every ``MEDIA:`` path is therefore deliverable regardless of
# file type (#36060): known extensions extract unconditionally via the anchored pattern above, everything
# else extracts only after ``validate_media_delivery_path`` accepts it (exists on disk, not under the
# credential/system denylist, strict-mode rules honored), so prompt-injection paths that do not validate are
# left visible instead of silently dropped. The path class uses a tempered-greedy token (``[^\s\n`"']+?``
# followed by a ``(?=...)`` lookahead) instead of the prior ``[^\s\n`"']+`` so a tag glued to the next
# ``MEDIA:`` keyword (``MEDIA:/a.pngMEDIA:/b.png``) or to arbitrary following text (``MEDIA:/a.pngSome
# text``) cannot silently absorb the next path — that earlier behavior merged the two paths into one invalid
# string and dropped the file (#68773). The bare form stays non-greedy and whitespace-bounded — spaced paths
# are NOT absorbed at the regex level, because greedy space-tolerance would reintroduce the #68773 bug class
# (gluing the next MEDIA: tag or trailing prose into one invalid path). Instead, unknown-extension paths
# containing spaces (``MEDIA:/data/map data.kmz``, ``C:\...\My Documents\x.log``) are recovered by
# ``_match_extensionless_path`` (#24032): when the bare match fails validation, the candidate is
# progressively extended forward across single spaces — bounded, stopping at newline / the next ``MEDIA:``
# keyword — and the first extension that validates on disk wins.
MEDIA_EXTENSIONLESS_TAG_RE = re.compile(
r'''[`"'*_]{0,3}MEDIA:\s*'''
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
r'''(?:~/|/|[A-Za-z]:[/\\])[^\s\n`"']+?)'''
r'''(?=[`"'\s,;:)\]}''' + _MEDIA_CJK_TERMINATORS + r''']|MEDIA:|$)'''
r'''[`"'*_]{0,3}\s*''',
re.IGNORECASE)
def _match_extensionless_path(scan_text: str, match: "re.Match") -> Optional[Tuple[str, int]]:
"""Extensionless MEDIA tag match -> validated on-disk ``(safe_path, end_offset)`` or None: the
captured path first, then extended across single spaces (max 8 tokens, never past a newline
or the next ``MEDIA:``).
When that fails validation, the candidate is progressively extended forward across single spaces
(validation-gated, bounded at 8 tokens, never past a newline or a subsequent ``MEDIA:`` keyword) so
unknown-extension paths containing spaces deliver (#24032). Returns ``(safe_path, end_offset)`` where
``end_offset`` is the index in ``scan_text`` just past the matched path, or ``None`` when nothing
validates.
"""
path = _normalize_media_tag_path(match.group("path"))
if not path:
return None
safe = validate_media_delivery_path(path)
if safe:
return safe, match.end("path")
start = match.start("path")
segment = scan_text[start:].split("\n", 1)[0]
nxt = segment.find("MEDIA:", 1)
if nxt != -1:
segment = segment[:nxt]
pos = match.end("path") - start
for _ in range(8):
token = re.match(r"[ \t]*[^ \t]+", segment[pos:])
if not token:
break
tok_end = pos + token.end()
safe = validate_media_delivery_path(_normalize_media_tag_path(segment[:tok_end]))
if safe:
return safe, start + tok_end
pos = tok_end
return None
def _normalize_media_tag_path(raw: str) -> str:
path = str(raw or "").strip()
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
path = path[1:-1].strip()
return path.lstrip("`\"'").rstrip("`\"',.;:)}]")
def _path_lacks_deliverable_extension(path: str) -> bool:
"""True when ``path`` has no extension or one outside MEDIA_DELIVERY_EXTS — such paths
take the validated delivery pass so nonexistent / denylisted ones stay visible.
``path`` — either the basename has no extension at all (Caddyfile, Makefile, …) or the extension is not
in MEDIA_DELIVERY_EXTS (.py, .log, .weirdext, …). Such paths route through the validated delivery pass
(``validate_media_delivery_path``) instead of the unconditional one, so every file type is deliverable
(#36060) while nonexistent / denylisted paths stay visible in the text.
"""
return Path(path).suffix.lower() not in MEDIA_DELIVERY_EXTS
def _has_media_directives(text: str) -> bool:
return "MEDIA:" in text or "[[audio_as_voice]]" in text or "[[as_document]]" in text
# A provider can leak its exact end-of-sequence control token glued to the last MEDIA path
# (``MEDIA:/x.png<|eos|>``). Neither tag regex accepts ``<`` as a path terminator — deliberately,
# since widening the delimiter set re-opens the glue classes #68773/#88038 — so the attachment was
# silently dropped (#111046). The one exact token is recognised here, at the seam every scan shares
# (extraction, display strip, stream cleanup), and only when it terminates the whole response.
_TERMINAL_SENTINEL = "<|eos|>"
def _terminal_sentinel_start(text: str) -> int:
"""Offset where the run of exact ``<|eos|>`` tokens closing ``text`` (trailing whitespace
ignored) begins, else -1; the run ends at ``len(text.rstrip())``."""
end = start = len(text.rstrip())
while start >= len(_TERMINAL_SENTINEL) and text[start - len(_TERMINAL_SENTINEL):start] == _TERMINAL_SENTINEL:
start -= len(_TERMINAL_SENTINEL)
return start if start < end else -1
def _mask_media_scan_text(text: str) -> str:
"""Offset-preserving mask of protected spans (code, quotes, JSON string values) and of a
terminal ``<|eos|>`` sentinel, so a tag glued to it ends on whitespace like any other.
BasePlatformAdapter is defined later in this module; resolved at call time."""
A = BasePlatformAdapter
masked = A._mask_json_string_media(A._mask_protected_spans(text))
start = _terminal_sentinel_start(text)
if start >= 0:
masked = _blank_spans(masked, [(start, len(text.rstrip()))])
return masked
def _deliverable_tag_spans(text: str) -> list:
"""Spans to delete from ``text``: its deliverable MEDIA tags (located on the masked copy)
plus a terminal ``<|eos|>`` sentinel, which is a control token and never user content."""
spans = _real_media_tag_spans(_mask_media_scan_text(text))
start = _terminal_sentinel_start(text)
if spans and start >= 0:
spans.append((start, len(text.rstrip())))
return spans
def _extensionless_media_matches(masked: str):
"""Yield ``(match, safe_path, end_offset)`` for every extension-less / unknown-extension
MEDIA tag in ``masked`` that ``validate_media_delivery_path`` accepts."""
for match in MEDIA_EXTENSIONLESS_TAG_RE.finditer(masked):
path = _normalize_media_tag_path(match.group("path"))
if path and _path_lacks_deliverable_extension(path):
resolved = _match_extensionless_path(masked, match)
if resolved is not None:
yield match, resolved[0], resolved[1]
def _real_media_tag_spans(masked: str) -> list:
"""(start, end) spans of deliverable MEDIA tags on a masked copy: known-extension tags
unconditionally, extension-less / unknown ones only if validate_media_delivery_path accepts."""
spans: list = [m.span() for m in MEDIA_TAG_CLEANUP_RE.finditer(masked)]
spans.extend((match.start(), end) for match, _, end in _extensionless_media_matches(masked))
return spans
_FENCED_CODE_RE = re.compile(r'```[^\n]*\n.*?```', re.DOTALL)
_INLINE_CODE_RE = re.compile(r'`[^`\n]+`')
def _code_spans(content: str) -> list:
"""(start, end) spans of fenced code blocks and inline code in ``content``."""
return [m.span() for rx in (_FENCED_CODE_RE, _INLINE_CODE_RE) for m in rx.finditer(content)]
def _blank_spans(text: str, spans: list) -> str:
"""Replace every non-newline char inside ``spans`` with a space (offsets preserved)."""
chars = list(text)
for start, end in spans:
chars[start:end] = [c if c == '\n' else ' ' for c in chars[start:end]]
return ''.join(chars)
def _delete_spans(text: str, spans: list) -> str:
"""Delete ``spans`` from ``text``, merging overlapping/nested ones first so multi-pattern
matches over the same tag never double-delete adjacent text."""
if not spans:
return text
merged: list = []
for s, e in sorted(spans):
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
chars = list(text)
for start, end in reversed(merged):
del chars[start:end]
return "".join(chars)
def _strip_media_tag_directives(text: str) -> str:
"""Remove MEDIA: tags and [[audio_as_voice]] / [[as_document]] markers so they never render
as text (backstop after ``extract_media``). Protected spans are mask-located only — tags
inside them are neither stripped nor mangled, matching ``extract_media`` so display and
delivery agree. Empty/None text is returned as-is.
See #16434.
"""
if not text or not _has_media_directives(text):
return text
cleaned = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
return _delete_spans(cleaned, _deliverable_tag_spans(cleaned))
def cache_document_from_bytes(data: bytes, filename: str) -> str:
"""Save raw document bytes to the cache as ``doc_{uuid12}_{original_name}`` and return
the absolute path; raises ValueError if the sanitized path escapes the cache directory."""
cache_dir = get_document_cache_dir()
# Sanitize: strip directory components, null bytes, and control characters
safe_name = (Path(filename).name if filename else "document").replace("\x00", "").strip()
if not safe_name or safe_name in {".", ".."}:
safe_name = "document"
filepath = cache_dir / f"doc_{uuid.uuid4().hex[:12]}_{safe_name}"
# Final safety check: ensure path stays inside cache dir
if not filepath.resolve().is_relative_to(cache_dir.resolve()):
raise ValueError(f"Path traversal rejected: {filename!r}")
filepath.write_bytes(data)
return str(filepath)
async def cache_document_from_bytes_async(data: bytes, filename: str) -> str:
"""Cache document bytes without blocking the caller's event loop."""
return await asyncio.to_thread(cache_document_from_bytes, data, filename)
# Unified media caching: classify attachment bytes by ext/MIME, route to cache_*_from_bytes.
@dataclass
class CachedMedia:
"""Result of caching one attachment's bytes."""
path: str # absolute cache path, agent-visible (sandbox-translated)
media_type: str # MIME type recorded on the MessageEvent
kind: str # "image" | "video" | "audio" | "document"
display_name: str # human-readable name for transcript notes
def context_note(self) -> str:
"""One-line transcript annotation pointing the agent at the file."""
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
# MIME -> extension reverse lookup; FIRST match across image, video, document tables wins
# (built in reverse so the earliest extension is the one that survives).
_MIME_TO_EXT: Dict[str, str] = {
mime: ext
for table in (SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, SUPPORTED_IMAGE_DOCUMENT_TYPES)
for ext, mime in reversed(table.items())}
def _resolve_media_ext(filename: str, mime_type: str) -> str:
"""Best-effort file extension from filename, then MIME fallback."""
ext = os.path.splitext(filename)[1].lower() if filename else ""
return ext or _MIME_TO_EXT.get((mime_type or "").lower(), "")
def cache_media_bytes(data: bytes, *, filename: str = "", mime_type: str = "",
default_kind: Optional[str] = None) -> Optional[CachedMedia]:
"""Classify and cache raw attachment bytes -> CachedMedia (None only for images failing
validation). ``default_kind`` biases ambiguous ext/MIME (Telegram native photo, no name);
anything not image/video/audio is a document."""
from tools.credential_files import to_agent_visible_cache_path
ext = _resolve_media_ext(filename, mime_type)
mime = (mime_type or "").lower()
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
# (kind, ext->mime table, default ext, cache fn, whether a matching caller MIME passes through)
for kind, table, default_ext, cache_fn, passthrough in (
("image", SUPPORTED_IMAGE_DOCUMENT_TYPES, ".jpg", cache_image_from_bytes, True),
("video", SUPPORTED_VIDEO_TYPES, ".mp4", cache_video_from_bytes, False),
("audio", _AUDIO_MIME_TYPES, ".ogg", cache_audio_from_bytes, True)):
if not (mime.startswith(f"{kind}/") or ext in table or default_kind == kind):
continue
kind_ext = ext if ext in table else default_ext
try:
path = cache_fn(data, ext=kind_ext)
except ValueError:
if kind != "image":
raise
return None
out_mime = mime if passthrough and mime.startswith(f"{kind}/") else table[kind_ext]
return CachedMedia(to_agent_visible_cache_path(path), out_mime, kind, display)
# Any other type is cached and surfaced as a path — an authorized user's uploads must never be
# silently dropped. Unknown types get octet-stream so the agent reaches for terminal tools.
fallback_name = filename or (f"document{ext}" if ext else "document.bin")
path = cache_document_from_bytes(data, fallback_name)
out_mime = SUPPORTED_DOCUMENT_TYPES.get(ext) or mime or "application/octet-stream"
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "document", display or fallback_name)
async def cache_media_bytes_async(
data: bytes,
*,
filename: str = "",
mime_type: str = "",
default_kind: Optional[str] = None,
) -> Optional[CachedMedia]:
"""Classify and cache attachment bytes without blocking the event loop."""
return await asyncio.to_thread(
cache_media_bytes,
data,
filename=filename,
mime_type=mime_type,
default_kind=default_kind,
)
@dataclass
class TextDebounceState:
event: MessageEvent
task: asyncio.Task | None
first_ts: float
last_ts: float
def cancel_timer(self, *, unless: "asyncio.Task | None" = None) -> None:
"""Cancel the pending flush timer (if live and not ``unless``)."""
if self.task is not None and self.task is not unless and not self.task.done():
self.task.cancel()
def _append_text(existing: Optional[str], new: Optional[str]) -> str:
"""``existing\\nnew`` when both non-empty; the non-empty one otherwise."""
return f"{existing}\n{new}" if existing else new
@dataclass
class _ExtractedResponse:
"""Deliverable parts of a handler response (see ``_extract_response_content``)."""
text_content: str
images: list
media_files: list
local_files: list
force_document_attachments: bool
pre_extract: str
_PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?gateway[.!?\s]*$", re.IGNORECASE),
re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?hermes\s+gateway[.!?\s]*$", re.IGNORECASE),
re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE))
def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
"""Rewrite a tiny set of DM plaintext admin phrases (exact matches only) into slash commands so
``restart gateway`` never reaches the LLM/tool path (a self-restart from inside the running
agent leaves the gateway stuck in ``draining`` waiting on that agent)."""
with contextlib.suppress(Exception):
if event is None or event.message_type != MessageType.TEXT:
return
text = (event.text or "").strip()
if not text or text.startswith("/"):
return
if getattr(getattr(event, "source", None), "chat_type", None) != "dm":
return
if any(pattern.match(text) for pattern in _PLAINTEXT_GATEWAY_RESTART_PATTERNS):
event.text = "/restart"
@dataclass
class ExecApprovalPrompt:
"""One exec-approval prompt, ready for a platform to render natively (see
``BasePlatformAdapter.send_exec_approval``). ``actions`` rows are ``(label, choice, style)``
with ``choice`` in ``once`` / ``session`` / ``always`` / ``deny`` — the vocabulary
``tools.approval.resolve_gateway_approval`` accepts — and ``style`` in ``primary`` /
``danger`` / ``""``."""
chat_id: str
session_key: str
text: str
actions: List[Tuple[str, str, str]]
command: str
description: str
smart_denied: bool
metadata: Optional[Dict[str, Any]] = None
@property
def choices(self) -> List[str]:
return [choice for _, choice, _ in self.actions]
@dataclass
class SendResult:
"""Result of sending a message."""
success: bool
message_id: Optional[str] = None
error: Optional[str] = None
# Adapter-specific metadata. Contract: Telegram edit-overflow partials set
# raw_response["partial_overflow"] so the stream consumer sends the missing tail.
raw_response: Any = None
retryable: bool = False # transient connection error — base retries automatically
retry_after: Optional[float] = None # server-requested delay (Telegram FloodWait) beats our backoff
# Extra ids (send order) when a payload was split; ``message_id`` is then the LAST id so
# later edits target the newest chunk.
continuation_message_ids: tuple = ()
# SEND_ERROR_KINDS member (failures only) via :func:`classify_send_error`, so consumers
# branch without substring-matching ``error``.
error_kind: Optional[str] = None
# Longest server ``retry_after`` ``_send_with_retry`` will sleep inline. Longer penalties return the
# typed failure so the delivery ledger owns the wait (#91969: a 97-minute FloodWait slept verbatim
# pinned the send coroutine and froze inbound on every platform).
_SEND_RETRY_INLINE_WAIT_CAP_SECS = 60.0
# Platform-neutral send-failure kinds for ``SendResult.error_kind``: too_long (size cap),
# bad_format (markup rejected; plain-text retry fixes), forbidden (the bot CANNOT reach the user),
# not_found (chat/thread/message gone), rate_limited, transient (connection-level, retry-safe),
# unknown.
SEND_ERROR_KINDS = frozenset(
{"too_long", "bad_format", "forbidden", "not_found", "rate_limited", "transient", "unknown"})
# ``not_found`` substrings by blast radius: chat-level = target dead; thread/topic/message-level
# leaves the parent chat reachable.
_CHAT_LEVEL_NOT_FOUND_SUBSTRINGS = ("chat not found",)
_SUBCHAT_NOT_FOUND_SUBSTRINGS = (
"message to edit not found", "message to reply not found", "thread not found", "topic_deleted",
"message_id_invalid")
def _error_blob(exc: Optional[BaseException] = None, error_text: str = "") -> str:
"""Lowercased blob (error_text + str(exc) + exception class name) that both
send-error classifiers match against — one builder so they can never drift."""
parts = [error_text] if error_text else []
if exc is not None:
parts.extend(p for p in (str(exc), exc.__class__.__name__) if p)
return " ".join(parts).lower()
def _any_in(blob: str, *needles: str) -> bool:
return any(n in blob for n in needles)
# Ordered (kind, predicate) table for classify_send_error — first match wins.
_SEND_ERROR_CLASSIFIERS: Tuple[Tuple[str, Callable[[str], bool]], ...] = (
("too_long", lambda b: _any_in(b, "message_too_long", "too long", "message is too long")),
("bad_format", lambda b: (
_any_in(b, "can't parse entities", "cant parse entities", "can't find end", "unsupported start tag")
or ("entity" in b and "parse" in b)
or ("bad request" in b and "entit" in b))),
("forbidden", lambda b: _any_in(
b, "forbidden", "bot was blocked", "blocked by the user", "user is deactivated",
"not enough rights", "have no rights", "not a member")),
("not_found", lambda b: _any_in(b, *_CHAT_LEVEL_NOT_FOUND_SUBSTRINGS, *_SUBCHAT_NOT_FOUND_SUBSTRINGS)),
("rate_limited", lambda b: _any_in(b, "flood", "too many requests", "retry after", "rate limit")),
("transient", lambda b: _any_in(b, *_RETRYABLE_ERROR_PATTERNS, "connecttimeout")))
def classify_send_error(exc: Optional[BaseException], error_text: str = "") -> str:
"""Map a send exception / error string to a :data:`SEND_ERROR_KINDS` value; anything
unrecognized is ``"unknown"`` so an unclassified failure is never mistaken for a benign one."""
blob = _error_blob(exc, error_text)
return next((kind for kind, matches in _SEND_ERROR_CLASSIFIERS if matches(blob)), "unknown")
def is_chat_level_not_found(exc: Optional[BaseException] = None, error_text: str = "") -> bool:
"""Whether a ``not_found`` failure means the *whole chat* is gone (only that marks a target
dead; a deleted topic / edited-away message leaves the parent chat reachable). When both
markers are present the sub-chat reading wins."""
blob = _error_blob(exc, error_text)
return (not _any_in(blob, *_SUBCHAT_NOT_FOUND_SUBSTRINGS)
and _any_in(blob, *_CHAT_LEVEL_NOT_FOUND_SUBSTRINGS))
class EphemeralReply(str):
"""System-notice reply that auto-deletes after ``ttl_seconds`` on platforms implementing
``delete_message`` (others leave it). ``None`` ttl uses ``display.ephemeral_system_ttl``
(``0`` disables globally). Subclassing ``str`` keeps it transparent to everything that
treats handler results as text; ``isinstance`` lets the send path schedule deletion."""
ttl_seconds: Optional[int]
def __new__(cls, text: str, ttl_seconds: Optional[int] = None):
instance = super().__new__(cls, text)
instance.ttl_seconds = ttl_seconds
return instance
@property
def text(self) -> str:
"""The underlying text (explicit form of ``str(reply)``)."""
return str.__str__(self)
def merge_pending_message_event(pending_messages: Dict[str, MessageEvent], session_key: str,
event: MessageEvent, *, merge_text: bool = False) -> None:
"""Store or merge a pending event: photo bursts/albums merge into the queued event so the next
turn sees the whole burst; with ``merge_text`` rapid TEXT follow-ups append instead of
replace."""
existing = pending_messages.get(session_key)
if existing:
existing_type = getattr(existing, "message_type", None)
existing_is_photo = existing_type == MessageType.PHOTO
incoming_is_photo = event.message_type == MessageType.PHOTO
both_photo = existing_is_photo and incoming_is_photo
incoming_has_media = bool(event.media_urls)
def _padded_inline_flags(msg: MessageEvent) -> List[Optional[bool]]:
flags = list(getattr(msg, "media_text_inlined", []) or [])
return flags + [None] * (len(msg.media_urls) - len(flags))
incoming_inline_flags: List[Optional[bool]] = []
if incoming_has_media:
existing.media_text_inlined = _padded_inline_flags(existing)
incoming_inline_flags = _padded_inline_flags(event)
# A photo burst always absorbs; otherwise merge only when media is involved on either
# side. Captions merge in every absorbing case.
if both_photo or existing.media_urls or incoming_has_media:
if both_photo or incoming_has_media:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
existing.media_text_inlined.extend(incoming_inline_flags)
if event.text:
existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
existing.absorb_reply_expected(event)
if existing_is_photo or incoming_is_photo:
existing.message_type = MessageType.PHOTO
elif existing_type == MessageType.TEXT and event.message_type != MessageType.TEXT:
existing.message_type = event.message_type
# Drop the *derived* STT cache (event changed); the echo ledger must survive or
# notes echo twice.
for attr in ("_gateway_pending_stt_text", "_gateway_pending_stt_transcripts"):
if hasattr(existing, attr):
delattr(existing, attr)
return
both_text = existing_type == MessageType.TEXT and event.message_type == MessageType.TEXT
if merge_text and both_text:
if event.text:
existing.text = _append_text(existing.text, event.text)
existing.absorb_reply_expected(event)
return
pending_messages[session_key] = event
# Transient *connection* failures worth retrying. Plain/read/write "timeout" excluded on purpose:
# the send may have reached the server (retry = duplicate); "connecttimeout" never connected.
_RETRYABLE_ERROR_PATTERNS = (
"connecterror", "connectionerror", "connectionreset", "connectionrefused", "connecttimeout",
"network", "broken pipe", "remotedisconnected", "eoferror")
# Handler result: str (reply), ``EphemeralReply`` (auto-delete) or None (already delivered).
MessageHandler = Callable[[MessageEvent], Awaitable[Optional[Union[str, "EphemeralReply"]]]]
def resolve_channel_prompt(config_extra: dict, channel_id: str, parent_id: str | None = None) -> str | None:
"""Per-channel ephemeral prompt from ``config.extra["channel_prompts"]``: exact *channel_id*
first, then *parent_id* (threads inherit the parent prompt). Blank prompts count as absent."""
prompts = config_extra.get("channel_prompts") or {}
if not isinstance(prompts, dict):
return None
for key in (channel_id, parent_id):
prompt = prompts.get(key) if key else None
if prompt is not None and (prompt := str(prompt).strip()):
return prompt
return None
def resolve_channel_skills(
config_extra: dict, channel_id: str, parent_id: str | None = None) -> list[str] | None:
"""Auto-loaded skill(s) for a channel/thread from ``channel_skill_bindings`` (entries
``{id: "<channel/forum id>", skills: [...]}``; ``skill: "<name>"`` also accepted). Exact
*channel_id* first, then *parent_id* (threads inherit). Deduplicated ordered list or None."""
bindings = config_extra.get("channel_skill_bindings") or []
if not isinstance(bindings, list) or not bindings:
return None
ids_to_check = {str(key) for key in (channel_id, parent_id) if key}
if not ids_to_check:
return None
for entry in bindings:
if not isinstance(entry, dict) or str(entry.get("id", "")) not in ids_to_check:
continue
skills = entry.get("skills") or entry.get("skill")
if isinstance(skills, str):
return [skills.strip()] if skills.strip() else None
if isinstance(skills, list) and skills:
seen = dict.fromkeys(
nm for name in skills if isinstance(name, str) and (nm := name.strip()))
return list(seen) or None
return None
def _split_post_delivery_entry(entry: Any) -> Tuple[Optional[int], Any]:
"""``(generation, callback)`` from a post-delivery slot; legacy bare callbacks have no
generation."""
return entry if isinstance(entry, tuple) and len(entry) == 2 else (None, entry)
def _lazy_attr(obj: Any, name: str, factory: Callable[[], Any]) -> Any:
"""``getattr(obj, name)`` or create it via ``factory`` — the getattr-guard for
tests that build adapters via ``object.__new__`` and never run ``__init__``."""
value = getattr(obj, name, None)
if value is None:
value = factory()
setattr(obj, name, value)
return value
_strip_media_directives = _strip_media_tag_directives
class BasePlatformAdapter(ABC):
"""Base class for platform adapters: connect/auth, receive, send, handle media."""
# ``format_message`` renders ``` fences as real code blocks (tool-progress then sends a bare
# fenced terminal command; plain-text platforms get the preview).
supports_code_blocks: bool = False
# Typing indicator renders TEXT (status line); the gateway then feeds set_status_text().
supports_status_text: bool = False
def set_status_text(self, chat_id: str, text: Optional[str]) -> None:
"""Set or clear (``None``) the live working-state phrase for a chat. In-memory only: the
next typing refresh renders it; a no-op store on adapters that never read
``_status_text``."""
store = _lazy_attr(self, "_status_text", dict)
if text:
store[str(chat_id)] = text
else:
store.pop(str(chat_id), None)
# Can wake a fresh turn AFTER a turn ends (detached-subagent completions); False for stateless
# adapters (API server). Propagated to ``HERMES_SESSION_ASYNC_DELIVERY`` so tools never promise
# a delivery they can't keep.
supports_async_delivery: bool = True
# ``send()`` chunks natively via ``truncate_message()`` -> the router skips its truncation.
splits_long_messages: bool = False
# Prefix users can always TYPE for Hermes commands ("!" where the client eats a leading "/").
typed_command_prefix: str = "/"
# ``in_channel`` continuable-cron surface: job delivered FLAT, plain replies continue it via
# the whole-channel bucket ``(platform, chat_id, None)``; needs a flat-reply outbound gate too
# (Slack ``reply_in_thread: false``). False fails SAFE -> ``thread``.
supports_inchannel_continuable: bool = False
# A human can answer "session restored — what next?"; webhook-style platforms set False so
# auto-resume finishes the work instead of asking nobody.
# The startup auto-resume turn (``_schedule_resume_pending_sessions`` → the ``_is_resume_pending``
# branch in ``_handle_message_with_agent``) reads this to pick its guidance: interactive platforms
# (Telegram, Slack, Discord DMs, …) get "report the restore and ask what the user wants next";
# non-interactive event platforms (webhook) get "finish the interrupted work" because nobody is there to
# answer, and an acknowledgement would silently abandon the task (#57056). Read generically via
# ``getattr(adapter, "interactive_resume", True)`` — no per-platform branching at the call site.
interactive_resume: bool = True
# Port-binding adapter that answers ``/p/<profile>/...`` for every served profile on the default
# listener under ``gateway.multiplex_profiles``. Declared per adapter (not in a central list) so
# ``hermes gateway migrate`` can tell "URL changes" from "this profile would be skipped" as new
# HTTP-inbound adapters gain the prefix.
serves_profile_prefix: bool = False
# Back-reference to the running ``GatewayRunner`` (set by gateway/run.py); ``build_source``
# resolves the inbound profile via ``runner._profile_name_for_source``.
gateway_runner = None # type: ignore[assignment]
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
self._message_handler: Optional[MessageHandler] = None
self._no_message_handler_logged: bool = False
self._reaction_handler: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
# Runner-owned boundary for normalized events: auth/profile state never lives in an adapter.
self._platform_event_handler: Optional[Callable[[Dict[str, Any], Any], Awaitable[None]]] = None
# Rewrites ``event.source.thread_id`` before session keying (Telegram DM topics).
self._topic_recovery_fn: Optional[Callable[[Any], Optional[str]]] = None
self._running, self._fatal_error_retryable = False, True
self._fatal_error_code: Optional[str] = None
self._fatal_error_message: Optional[str] = None
self._fatal_error_handler: Optional[Callable[["BasePlatformAdapter"], Awaitable[None] | None]] = None
# Strong refs to shielded fatal-error handler tasks that outlive their carrier task
# (asyncio keeps only weak refs); without them the loop can GC the detached handler
# mid-flight — the "handler killed mid-flight" class (#81335).
# See #81335.
self._detached_fatal_tasks: set = set()
# Lock takeover armed only for the initial connect of ``gateway run --replace``.
self._platform_lock_takeover_allowed = self._platform_lock_takeover_attempted = False
# Per-session interrupt Event + owner Task: without the owner map an old task's finally
# could drop a newer guard.
self._active_sessions: Dict[str, asyncio.Event] = {}
self._pending_messages: Dict[str, MessageEvent] = {}
# Consecutive in-band drains per session of the just-dispatched event bouncing straight
# back into the queue (session busy elsewhere); drives the drain back-off (#123229).
self._requeue_counts: Dict[str, int] = {}
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
self._session_tasks: Dict[str, asyncio.Task] = {}
# Busy-text policy is a per-profile config decision the runner installs after construction
# (``_wire_adapter_handlers``); a constructor-time process-env read would freeze the launch
# profile's values into every profile's adapter under multiplexing (#116893). Defaults here
# only cover a pre-sync read so it never silently queues.
self._busy_text_mode: str = "interrupt"
self._busy_text_debounce_seconds: float = DEFAULT_BUSY_TEXT_DEBOUNCE_SECONDS
self._busy_text_hard_cap_seconds: float = DEFAULT_BUSY_TEXT_HARD_CAP_SECONDS
# ``human_delay`` pacing range in ms, or None (off); per-profile config installed by the
# runner, never process env (#116895).
self._human_delay_range_ms: Optional[tuple[int, int]] = None
self._text_debounce: dict[str, TextDebounceState] = {}
# handle_message() tasks; shutdown cancels them so a replaced gateway stops working.
self._background_tasks: set[asyncio.Task] = set()
# Post-delivery one-shots per session_key: bare callback (legacy) or ``(generation,
# callback)`` so a stale run can't clear a fresher run's callback.
self._post_delivery_callbacks: Dict[str, Any] = {}
self._expected_cancelled_tasks: set[asyncio.Task] = set()
self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None
# Owning multiplex profile (None on primary); see _session_key_profile.
self._owner_profile: Optional[str] = None
# Set by the runner on a secondary's port-binding adapter: serve via the default profile's
# shared listener (/p/<profile>/...) instead of binding a port (gateway/platforms/shared_ingress.py).
self._shared_listener_profile: Optional[str] = None
# Registered by GatewayRunner (see set_authorization_check).
self._authorization_check: Optional[Callable[[str, Optional[str], Optional[str]], bool]] = None
# Auto-TTS on voice input: ``voice.auto_tts`` default plus per-chat /voice on|tts / off.
self._auto_tts_default: bool = False
self._auto_tts_enabled_chats, self._auto_tts_disabled_chats = set(), set()
# Turn keys where streaming TTS already delivered audio; whole-file auto-TTS skips them.
# When the gateway streaming-TTS consumer successfully delivers audio, it adds the turn key here so
# the base adapter's whole-file auto-TTS path skips the duplicate. Cleared after the turn completes.
# See #60671.
self._streaming_tts_completed_turns: set[str] = set()
# Chats whose typing indicator is paused (approval waits); _keep_typing skips them.
self._typing_paused: set = set()
# Per-chat status phrase; the regular _keep_typing refresh renders it (no extra API calls).
self._status_text: Dict[str, str] = {}
@property
def message_len_fn(self) -> Callable[[str], int]:
"""Length function for message size; override where the platform counts
differently from ``len`` (Telegram: UTF-16 code units)."""
return len
def max_message_length_for_chat(self, chat_id: str) -> int:
"""Per-chat max length in ``message_len_fn_for_chat`` units: the adapter's
``MAX_MESSAGE_LENGTH`` (4096 when absent); the relay adapter overrides since it fronts N
platforms with different caps."""
return _or_default(lambda: int(getattr(self, "MAX_MESSAGE_LENGTH", 4096) or 4096), 4096)
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]:
"""Per-chat length function (companion to max_message_length_for_chat); the relay
adapter overrides it per the chat's fronting platform."""
return self.message_len_fn
@property
def enforces_own_access_policy(self) -> bool:
"""Whether this adapter enforces its own config-driven access policy at intake
(``dm_policy``/``group_policy``/``allow_from``: WeCom, Weixin, QQBot, WhatsApp…). The
gateway env allowlist runs *after* the adapter; without one it trusts this flag ONLY when
the effective policy is a real ``"allowlist"`` — never ``"open"`` (the default), which would
be a network-exposed fail-open (SECURITY.md §2.6). Open access still requires
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS``."""
return False
@property
def authorization_is_upstream(self) -> bool:
"""Whether inbound was already authorized by a TRUSTED UPSTREAM (relay only): the Team
Gateway connector authenticates the WebSocket and resolves owner-only author binding BEFORE
delivery, so the no-allowlist default-deny would be wrong. Authorization DELEGATED, not
ABSENT — every network-exposed direct adapter leaves it False."""
return False
def supports_draft_streaming(
self, chat_type: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
chat_id: Optional[str] = None) -> bool:
"""Whether native streaming-draft updates (``send_draft``) work for this chat type;
``chat_id`` lets the relay adapter answer per negotiated capabilities. Consumers
fall back to ``send`` + ``edit_message`` when False or ``send_draft`` raises."""
return False
def prefers_fresh_final_streaming(self, content: str, metadata: Optional[Dict[str, Any]] = None) -> bool:
"""Whether the stream consumer should finalize with a *fresh* final message (best-effort
deleting the preview) instead of final-editing it (Telegram: keeps rich rendering)."""
return False
def streaming_overflow_limit(self) -> Optional[int]:
"""Max single-message length (``message_len_fn`` units) the stream consumer may
accumulate before splitting, for rich send/draft paths exceeding the legacy cap
(Telegram Rich Messages: 32,768 vs 4,096). ``None`` = use ``MAX_MESSAGE_LENGTH``."""
return None
async def send_draft(self, chat_id: str, draft_id: int, content: str,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send or update an animated streaming-draft preview. Reuse one non-zero ``draft_id``
across a response so the platform animates (different responses need different ids). Drafts
have no message_id (no edit/reply/delete) — the final answer goes out as a regular ``send``.
Must be overridden by adapters returning True from :meth:`supports_draft_streaming`."""
raise NotImplementedError(f"{type(self).__name__} does not implement send_draft")
# ── Structured stream-event rendering (gateway/stream_events.py): presentation-only,
# nothing rendered here is persisted, so what an adapter "eats" never changes history.
def render_message_event(self, event: Any, sink: Any) -> None:
"""Render a MessageChunk / MessageStop / Commentary onto the sink (a
GatewayStreamConsumer), mapping 1:1 onto its existing primitives."""
from gateway.stream_events import MessageChunk, MessageStop, Commentary
if isinstance(event, MessageChunk) and event.text:
sink.on_delta(event.text)
elif isinstance(event, MessageStop) and not event.final:
# Intermediate stop (text → tool → text) = segment break; the terminal stop is finish().
sink.on_segment_break()
elif isinstance(event, Commentary) and event.text:
sink.on_commentary(event.text)
def format_tool_event(self, event: Any, *, mode: str = "all", preview_max_len: int = 40) -> Optional[str]:
"""Rendered chrome for a ToolCallChunk, or None to eat it (adapters without editing/rich
text override to None). ``mode``: tool-progress mode ("all"/"new"/"verbose");
``preview_max_len`` mirrors ``tool_preview_length`` (0 = no cap in verbose)."""
from gateway.stream_events import ToolCallChunk
if not isinstance(event, ToolCallChunk):
return None
from agent.display import get_tool_emoji, prepare_tool_preview
head = f"{get_tool_emoji(event.tool_name, default='⚙️')} {event.tool_name}"
if mode == "verbose" and event.args:
import json
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
if preview_max_len > 0 and len(args_str) > preview_max_len:
args_str = args_str[:preview_max_len - 3] + "..."
return f"{head}({list(event.args.keys())})\n{args_str}"
if not event.preview:
return f"{head}..."
if mode == "verbose":
return f'{head}: "{event.preview}"'
# "all" / "new": short capped preview (default 40; progress bubbles persist as messages).
cap = preview_max_len if preview_max_len > 0 else 40
prepared = prepare_tool_preview(
event.tool_name, event.args, fallback=event.preview, max_len=cap)
return f'{head}: "{self.format_tool_preview(prepared)}"'
def format_tool_preview(self, preview: "ToolPreview") -> str:
"""Platform-native formatting of a compact tool preview; rich-text adapters may use
the preview's metadata (e.g. a URL shortened for display)."""
return preview.text
has_fatal_error = property(lambda self: self._fatal_error_message is not None)
fatal_error_message = property(lambda self: self._fatal_error_message)
fatal_error_code = property(lambda self: self._fatal_error_code)
fatal_error_retryable = property(lambda self: self._fatal_error_retryable)
def _should_auto_tts_for_chat(self, chat_id: str) -> bool:
"""Whether auto-TTS fires for ``chat_id``: explicit ``/voice on|tts`` wins,
then explicit ``/voice off``, then the global ``voice.auto_tts`` default.
Decision layers (Issue #16007): 1. Explicit ``/voice on`` or ``/voice tts`` → always fire (even if
``voice.auto_tts`` is False). 2. 3.
"""
return chat_id in self._auto_tts_enabled_chats or (
chat_id not in self._auto_tts_disabled_chats and bool(self._auto_tts_default))
def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None:
self._fatal_error_handler = handler
#: Published when an adapter is installed and running but its receive
#: path is not yet confirmed (e.g. Telegram polling has not proven a
#: getUpdates round-trip). Same ``retrying`` platform_state the runner
#: uses for queued reconnects, so readers see "not delivering" (#101391).
DEGRADED_STATUS_MESSAGE = "connected but not yet confirmed active; recovering in background"
@property
def send_path_degraded(self) -> bool:
"""True while connect() succeeded but delivery is not confirmed.
Adapters with a separately-proven receive path override this; the
default adapter is either connected or not.
"""
return False
def _mark_connected(self, *, listener_base: Optional[str] = None) -> None:
"""``listener_base`` (``http://host:port``) is stamped by port-binders after a REAL bind: under the
multiplexer it is the shared listener a served profile's ``/p/<profile>/`` mirror hangs off, and
what the dashboard/Desktop report as that profile's api_server/webhook URL."""
self._running = True
self._fatal_error_code = self._fatal_error_message = None
self._fatal_error_retryable = True
if self.send_path_degraded:
self._mark_degraded()
else:
extra = {"listener_base": listener_base} if listener_base else {}
self._write_runtime_status_safe(
"connected", platform_state="connected", error_code=None, error_message=None, **extra)
def _mark_degraded(self) -> None:
"""Publish ``retrying`` for a running adapter whose delivery path is unproven."""
self._write_runtime_status_safe(
"connected_degraded",
platform_state="retrying",
error_code=None,
error_message=self.DEGRADED_STATUS_MESSAGE,
)
def _mark_disconnected(self) -> None:
self._running = False
if not self.has_fatal_error:
self._write_runtime_status_safe(
"disconnected", platform_state="disconnected", error_code=None, error_message=None)
def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None:
self._running = False
self._fatal_error_code, self._fatal_error_message = code, message
self._fatal_error_retryable = retryable
self._write_runtime_status_safe("fatal", platform_state="fatal", error_code=code, error_message=message)
def _write_runtime_status_safe(self, context: str, **kwargs) -> None:
"""Publish runtime status; log preparation failures without disrupting the adapter."""
try:
from gateway.status import publish_runtime_status
# Multiplexed adapters share the status file; the runner stamps
# ``<profile>:<platform>``.
platform_key = getattr(self, "_runtime_status_platform_key", None) or self.platform.value
publish_runtime_status(platform=platform_key, **kwargs)
except Exception as exc:
logged = _lazy_attr(self, "_status_write_logged", set) # object.__new__ in tests
first = (self.platform.value, context) not in logged
logged.add((self.platform.value, context))
(logger.warning if first else logger.debug)(
"Failed to write runtime status (%s) for %s: %s" + (" (further failures at debug level)" if first else ""),
context, self.platform.value, exc)
async def _notify_fatal_error(self) -> None:
handler = self._fatal_error_handler
if not handler:
return
result = handler(self)
if asyncio.iscoroutine(result):
# Detached + shielded: often awaited from an adapter-owned task (e.g. Telegram
# ``_polling_error_task``) that the gateway fatal handler's ``disconnect()``
# cancels; unshielded, the handler died mid-flight — adapter popped from the
# gateway map but never queued for background reconnect, leaving a zombie
# gateway with no platforms and no pending retries (#81335).
# See #81335.
task = asyncio.ensure_future(result)
# Strong ref: asyncio only keeps weak refs to tasks ("save a reference ... to
# avoid a task disappearing mid-execution"); matches
# GatewayRunner._handle_adapter_fatal_error.
_tasks = _lazy_attr(self, "_detached_fatal_tasks", set)
_tasks.add(task)
task.add_done_callback(_tasks.discard)
try:
await asyncio.shield(task)
except asyncio.CancelledError:
# Carrier cancelled (our own teardown inside the handler): let it finish detached.
if not task.done():
task.add_done_callback(_consume_detached_handler_exception)
raise
def _acquire_platform_lock(self, scope: str, identity: str, resource_desc: str) -> bool:
"""Acquire a scoped lock for this adapter; True on success. A live cross-HERMES_HOME
holder is replaced only when the runner armed this adapter for its initial
``--replace`` connect (the status module validates ownership and terminates)."""
from gateway.status import (
acquire_scoped_lock, scoped_lock_owner_label, take_over_scoped_lock_holder)
self._platform_lock_scope, self._platform_lock_identity = scope, identity
lock_meta = {"platform": self.platform.value}
acquired, existing = acquire_scoped_lock(scope, identity, metadata=lock_meta)
if acquired:
return True
if (self._platform_lock_takeover_allowed and not self._platform_lock_takeover_attempted
and isinstance(existing, dict)):
# Consume the authority before any I/O: at most one termination attempt per connect.
self._platform_lock_takeover_allowed = False
self._platform_lock_takeover_attempted = True
owner_pid = take_over_scoped_lock_holder(existing)
if owner_pid is not None:
logger.warning(
"[%s] %s was held by gateway PID %d — explicit --replace handoff completed",
self.name, resource_desc, owner_pid)
acquired, existing = acquire_scoped_lock(scope, identity, metadata=lock_meta)
if acquired:
logger.info("[%s] Acquired %s after taking over PID %d", self.name, resource_desc, owner_pid)
return True
owner_pid = existing.get('pid') if isinstance(existing, dict) else None
# Scoped locks are machine-global: name the owning profile so the operator knows WHICH
# gateway.
owner_profile = scoped_lock_owner_label(existing)
pid_part = f" (PID {owner_pid})" if owner_pid else ""
holder = f" by the '{owner_profile}' profile gateway{pid_part}" if owner_profile else pid_part
remedy = (f" Stop that gateway first (hermes --profile {owner_profile} gateway stop)."
if owner_profile else " Stop the other gateway first.")
message = f"{resource_desc} already in use{holder}.{remedy}"
logger.error('[%s] %s', self.name, message)
self._set_fatal_error(f'{scope}_lock', message, retryable=True)
return False
def _release_platform_lock(self) -> None:
"""Release the scoped lock acquired by _acquire_platform_lock."""
identity = getattr(self, '_platform_lock_identity', None)
if not identity:
return
from gateway.status import release_scoped_lock
release_scoped_lock(self._platform_lock_scope, identity)
self._platform_lock_identity = None
# Plugin handler factories wired on the live native client: ``(plugin, qualname)`` keys, reset when
# the native client is rebuilt. ``None`` = ``connect()`` has not wired yet (class defaults so
# subclasses that skip ``super().__init__`` still re-wire safely).
_plugin_handler_native: Any = None
_plugin_handlers_wired: Optional[set] = None
def _wire_plugin_handlers(self, native: Any = None) -> None:
"""Invoke plugin-registered native handler factories (``ctx.register_platform_handler``)
with ``(native, adapter)``; adapters call this from ``connect()`` once the native
client exists and :meth:`rewire_plugin_handlers` re-runs it for plugins loaded later.
Idempotent per native client: a factory is keyed by ``(plugin, qualname)`` and skipped once
wired on this ``native`` (a force re-discovery hands back NEW function objects for the same
plugin, so identity alone would double-register). Each factory is isolated so a bad plugin
can't block connecting."""
try:
from hermes_cli.plugins import get_plugin_manager
factories = get_plugin_manager().get_platform_handler_factories(
getattr(self.platform, "value", str(self.platform)))
except Exception as e: # pragma: no cover - defensive
logger.warning("[%s] Could not load plugin handler factories: %s", self.name, e)
return
if self._plugin_handler_native is not native or self._plugin_handlers_wired is None:
# A rebuilt native client (transient-init rebuild, reconnect) starts with nothing wired.
self._plugin_handler_native = native
self._plugin_handlers_wired = set()
for factory, plugin_name in factories:
key = (plugin_name, getattr(factory, "__qualname__", None) or repr(factory))
if key in self._plugin_handlers_wired:
continue
try:
factory(native, self)
logger.info("[%s] Wired native handlers from plugin '%s'", self.name, plugin_name)
except Exception as exc:
logger.error("[%s] Plugin '%s' handler factory raised: %s", self.name, plugin_name,
exc, exc_info=True)
# A raising factory is recorded too: re-wire must not re-raise it on every plugin load.
self._plugin_handlers_wired.add(key)
def rewire_plugin_handlers(self) -> None:
"""Register handlers of plugins loaded AFTER ``connect()`` wired the first batch (#87770);
the gateway runner calls this on every plugin-loaded event. Safe to call repeatedly: only
factories not yet wired on the live native client run. Before ``connect()`` has wired once
there is nothing to re-wire — connect will pick everything up. Adapters with extra plugin
registries (Slack action handlers) extend this."""
if self._plugin_handlers_wired is None:
return
self._wire_plugin_handlers(self._plugin_handler_native)
@property
def name(self) -> str:
"""Human-readable name for this adapter."""
return self.platform.value.title()
@property
def is_connected(self) -> bool:
"""Check if adapter is currently connected."""
return self._running
def set_message_handler(self, handler: MessageHandler) -> None:
"""Set the incoming-message handler (MessageEvent -> optional response str)."""
self._message_handler = handler
def set_platform_event_handler(
self, handler: Optional[Callable[[Dict[str, Any], Any], Awaitable[None]]]) -> None:
"""Install the gateway-owned normalized platform-event boundary (stable dicts + internal
``SessionSource``); the runner owns authorization and plugin dispatch: no callback = fail
closed."""
self._platform_event_handler = handler
def set_topic_recovery_fn(self, fn: Optional[Callable[[Any], Optional[str]]]) -> None:
"""Install a thread_id-recovery hook (Telegram DM topic mode): called with ``event.source``
before session keying; a non-None return replaces ``source.thread_id``. ``None`` clears
it."""
self._topic_recovery_fn = fn
def _apply_topic_recovery(self, event: MessageEvent) -> None:
"""Rewrite ``event.source.thread_id`` in place if the hook returns one."""
recover = getattr(self, "_topic_recovery_fn", None)
source = getattr(event, "source", None)
if recover is None or source is None:
return
try:
recovered = recover(source)
if recovered is None or str(recovered) == str(source.thread_id or ""):
return
except Exception:
logger.debug("topic recovery hook failed", exc_info=True)
return
try:
from gateway.session_identity import replace_source
event.source = replace_source(source, thread_id=str(recovered)) # keeps the pinned identity
except Exception:
logger.debug("topic recovery rewrite failed", exc_info=True)
def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:
"""Set an optional handler for messages arriving during active sessions."""
self._busy_session_handler = handler
def set_reaction_handler(self, handler: Optional[Callable[[Dict[str, Any]], Awaitable[None]]]) -> None:
"""Set the handler for platform-native emoji-reaction events: a normalised dict
(``platform``, ``event_name`` "reaction:added"/"reaction:removed", ``reaction``,
``user_id``, ``item_user_id``, ``channel_id``, ``message_ts``, ``event_ts``, ``raw_event``)
fanned out via ``HookRegistry.emit``."""
self._reaction_handler = handler
def set_authorization_check(
self, callback: Optional[Callable[[str, Optional[str], Optional[str]], bool]]) -> None:
"""Register ``(user_id, chat_type, chat_id) -> bool``; adapters pulling external context
(Slack thread replies) use it to flag non-allowlisted senders as unverified background."""
self._authorization_check = callback
def _is_sender_authorized(self, user_id: Optional[str], chat_type: Optional[str] = None,
chat_id: Optional[str] = None, *, is_bot: bool = False,
thread_id: Optional[str] = None) -> Optional[bool]:
"""True/False from the registered check, or None when no check exists ("trust unknown",
legacy). ``is_bot``/``thread_id`` are forwarded as keywords only when set so legacy
three-positional callbacks keep working. Only literal booleans propagate: a truthy
non-boolean is "unknown", never an authorization that gates a credentialed side effect."""
if not user_id or self._authorization_check is None:
return None
extra: Dict[str, Any] = {}
if is_bot:
extra["is_bot"] = True
if thread_id is not None:
extra["thread_id"] = thread_id
try:
result = self._authorization_check(user_id, chat_type, chat_id, **extra)
except Exception:
logger.warning("[%s] Authorization check raised for user %s; treating as unknown",
self.name, user_id, exc_info=True)
return None
if result is True or result is False:
return result
logger.warning("[%s] Authorization check returned %s for user %s; treating as unknown",
self.name, type(result).__name__, user_id)
return None
def set_session_store(self, session_store: Any) -> None:
"""Set the session store (e.g. Slack checks for an active thread session
before handling un-mentioned replies)."""
self._session_store = session_store
def set_owner_profile(self, profile_name: Optional[str]) -> None:
"""Declare the owning multiplex profile (secondary profiles only); read by
:meth:`_session_key_profile` so adapter-level keys leave ``agent:main:``."""
self._owner_profile = None if (name := (profile_name or "").strip() or None) == "default" else name
def _owner_transport_profile(self) -> Optional[str]:
"""Transport profile for :func:`resolve_identity`: the owner name, or ``None`` = derive it
from the registry (the primary's identity then spells ``"default"`` out itself)."""
owner = getattr(self, "_owner_profile", None)
return owner if isinstance(owner, str) and owner.strip() else None
def _canonicalize(self, source: Optional["SessionSource"]):
"""Pin the source's :class:`RoutingIdentity` before anything derives a key from it. Every
ingress path (fresh event, batch merge, busy path, control command, callback) calls this
FIRST. Returns the identity, or ``None`` when it cannot be resolved (a rejected route under
multiplexing marks ``source.profile_route_rejected``; ``_drop_unresolved`` reads it) or when
no runner seam exists (hand-built adapters, restored sources: the legacy readers stay)."""
if source is None:
return None
from gateway.session_identity import canonical_identity, identity_of
identity = identity_of(source)
if identity is not None:
return identity
runner = getattr(self, "gateway_runner", None)
if runner is None or not callable(getattr(runner, "_transport_owner", None)):
return None
try:
return canonical_identity(
source, runner=runner, adapter=self, transport_profile=self._owner_transport_profile())
except Exception:
# Duck-typed runners (SimpleNamespace / MagicMock rigs) have no registry to resolve
# against; the key then falls back to the pre-identity readers instead of failing ingress.
logger.debug("[%s] identity resolution failed; using legacy key readers", self.name, exc_info=True)
return None
def _drop_unresolved(self, event: "MessageEvent") -> bool:
"""True when *event* must be dropped: its identity could not be resolved because the route
targets an unserved profile. Same disposition as the runner's ingress gate — one WARNING,
never a fall-through to ``agent:main``."""
source = getattr(event, "source", None)
if self._canonicalize(source) is not None:
return False
if getattr(source, "profile_route_rejected", False) is not True:
return False
logger.warning(
"[%s] Dropping inbound event for %s: explicit profile route targets an unserved profile",
self.name, getattr(source, "chat_id", "?"))
return True
def _session_key_profile(self, source: Optional[Any] = None) -> Optional[str]:
"""Profile namespace for an adapter-derived session key. Ingress runs BEFORE the runner
stamps ``source.profile``, so without this every bot in a multiplexed gateway shares one
``agent:main:`` lane. Order: pinned ``RoutingIdentity`` → ``source.profile`` →
``_owner_profile`` → session-store resolver; getattr-guarded (object.__new__ in tests),
type-checked (no MagicMock in the key)."""
from gateway.session_identity import identity_of
identity = identity_of(source)
if identity is not None:
return identity.session_key_profile
for candidate in (
getattr(source, "profile", None) if source is not None else None,
getattr(self, "_owner_profile", None)):
if isinstance(candidate, str) and candidate.strip():
return candidate
store = getattr(self, "_session_store", None)
resolver = getattr(store, "_resolve_profile_for_key", None) if store else None
if not callable(resolver):
return None
try:
resolved = resolver(source)
except Exception:
return None
return resolved if isinstance(resolved, str) and resolved.strip() else None
# ── Inbound text batching. Chat clients split one long message into several inbound
# chunks; ``_enqueue_text_event`` merges chunks per session key and ``_flush_text_batch``
# dispatches after a quiet period (longer when the last chunk sits near the platform's
# split point, i.e. a continuation is almost certain). Adapters set the delay attrs and
# ``_SPLIT_THRESHOLD``; ``_text_batch_delay_for`` / ``_pop_text_batch`` /
# ``_dispatch_text_batch`` are the override seams for platform-specific policy.
_SPLIT_THRESHOLD: int = 4000
_text_batch_delay_seconds: float = 0.0
_text_batch_split_delay_seconds: float = 0.0
# Shared cadence for adapters that batch: a quiet period long enough to merge a client-side
# split (Telegram's measured envelope), short enough that a single short message is not
# visibly delayed (#44883). Ceilings bound a misconfigured value fed to asyncio.sleep().
_TEXT_BATCH_DEFAULT_DELAY_S: float = 0.3
_TEXT_BATCH_MAX_DELAY_S: float = 2.0
_TEXT_BATCH_DEFAULT_SPLIT_DELAY_S: float = 1.0
_TEXT_BATCH_MAX_SPLIT_DELAY_S: float = 4.0
def _coerce_float_extra(self, key: str, default: float, *, min_value: float = 0.0, max_value: Optional[float] = None) -> float:
"""Float from ``config.extra``; NaN/Inf/negative/unparseable → ``default``; clamped to ``[min_value, max_value]``."""
extra = getattr(self.config, "extra", None) or {}
try: # float(None) → TypeError → default
parsed = float(extra.get(key))
except (TypeError, ValueError):
parsed = float(default)
if not math.isfinite(parsed) or parsed < 0:
parsed = float(default)
parsed = max(parsed, min_value)
if max_value is not None and parsed > max_value:
logger.warning("%s=%s exceeds the %s ceiling; clamped", key, parsed, max_value)
parsed = max_value
return parsed
def _configure_text_batch_delays(self) -> None:
"""Read ``text_batch_delay_seconds`` / ``text_batch_split_delay_seconds`` from ``config.extra`` at the shared cadence."""
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", self._TEXT_BATCH_DEFAULT_DELAY_S, max_value=self._TEXT_BATCH_MAX_DELAY_S)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", self._TEXT_BATCH_DEFAULT_SPLIT_DELAY_S,
min_value=self._text_batch_delay_seconds, max_value=self._TEXT_BATCH_MAX_SPLIT_DELAY_S)
def _event_session_key(self, event: "MessageEvent") -> str:
"""Adapter-level session key for ``event``, profile-namespaced like the agent run."""
return self._source_session_key(event.source)
def _source_session_key(self, source: "SessionSource") -> str:
self._canonicalize(source) # identity FIRST; no key derivation before it
extra = self.config.extra
return build_session_key(
source, group_sessions_per_user=extra.get("group_sessions_per_user", True),
thread_sessions_per_user=extra.get("thread_sessions_per_user", False),
profile=self._session_key_profile(source))
def _text_batch_key(self, event: "MessageEvent") -> str:
"""Session-scoped key for text batching (subclasses may override)."""
return self._event_session_key(event)
def _enqueue_text_event(self, event: "MessageEvent") -> None:
"""Buffer a text event (merging into a pending one) and restart the flush timer."""
if self._drop_unresolved(event):
return
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
if existing is None:
existing = self._pending_text_batches[key] = event
else:
if event.text:
existing.text = _append_text(existing.text, event.text)
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
existing.absorb_reply_expected(event)
existing._last_chunk_len = len(event.text or "") # type: ignore[attr-defined]
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(self._flush_text_batch(key))
def _text_batch_delay_for(self, pending: Optional["MessageEvent"]) -> float:
"""Quiet period before ``pending`` is dispatched; near-split chunks wait longer."""
last_len = getattr(pending, "_last_chunk_len", 0) if pending is not None else 0
return self._text_batch_split_delay_seconds if last_len >= self._SPLIT_THRESHOLD else self._text_batch_delay_seconds
def _pop_text_batch(self, key: str) -> Optional["MessageEvent"]:
"""Remove and return the pending batch for ``key`` (adapters with side tables override)."""
return self._pending_text_batches.pop(key, None)
async def _dispatch_text_batch(self, event: "MessageEvent") -> None:
"""Hand a flushed batch to the pipeline (adapters with per-chat guards override)."""
await self.handle_message(event)
async def _flush_text_batch_now(self, key: str) -> None:
"""Dispatch the pending batch for ``key`` immediately (no quiet period)."""
event = self._pop_text_batch(key)
if event is not None:
await self._dispatch_text_batch(event)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for the quiet period, then dispatch the batch for ``key``.
Two races share this body. (1) ``_enqueue_text_event`` cancels the prior flush task
on each new chunk; when ``Task.cancel()`` lands after ``sleep()`` already completed,
CancelledError is delivered at the *next* await — after a superseded task would have
popped the event, so the successor finds nothing and the message is lost. The identity
check therefore runs synchronously between the sleep and the pop. (2) A cancel that
lands while the dispatch is in flight would abort the agent turn (#12444), so the
dispatch is shielded and the outer CancelledError swallowed."""
current_task = asyncio.current_task()
try:
await asyncio.sleep(self._text_batch_delay_for(self._pending_text_batches.get(key)))
owner = self._pending_text_batch_tasks.get(key)
if owner is not None and owner is not current_task:
return
event = self._pop_text_batch(key)
if event is None:
return
logger.info("[%s] Flushing text batch %s (%d chars)", self.name, key, len(event.text or ""))
await asyncio.shield(self._dispatch_text_batch(event))
except asyncio.CancelledError:
pass
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
def _history_media_paths_for_session(self, session_key: str) -> Optional[set]:
"""Return media paths already delivered in prior turns of this session
(MEDIA: tags / image_generate payloads), so an echoed old tag isn't re-sent."""
store = getattr(self, "_session_store", None)
if not store:
return None
try:
# Transcripts are keyed by session_id; map via the routing index, else the raw key.
peek = getattr(store, "peek_session_id", None)
session_id = peek(session_key) if callable(peek) else None
transcript = store.load_transcript(session_id or session_key)
except TranscriptReadError:
logger.warning(
"Transcript read failed for session %s; media dedup runs "
"with no history this turn (#100788)", session_key,
)
return None
except Exception:
return None
if not transcript:
return None
# Exclude the CURRENT TURN (from the last user message on): rows persist as produced, so a
# text_to_speech media_tag would otherwise dedup away its own attachment.
history = list(transcript)
last_user_idx = next(
(i for i in range(len(history) - 1, -1, -1) if history[i].get("role") == "user"), None)
if last_user_idx is not None:
history = history[:last_user_idx]
else:
# No user row (unusual store shape): at least drop the trailing reply.
last_reply = next((msg for msg in reversed(history) if msg.get("role") == "assistant"), None)
if last_reply is not None:
history.remove(last_reply)
if not history:
return None
from gateway.run import _collect_history_media_paths # lazy: gateway.run imports us
return _collect_history_media_paths(history)
async def _bounded_history_media_paths_for_session(self, session_key: str) -> Optional[set]:
"""Run best-effort history lookup in a bounded isolated daemon thread."""
def _fail_open(reason: str, *, exc_info: bool = False) -> None:
logger.warning(
"[%s] " + reason + " %s; delivering bare local file path(s) without history dedup",
self.name, session_key, exc_info=exc_info)
admission = _HISTORY_MEDIA_LOOKUP_ADMISSION
if not admission.acquire(blocking=False):
_fail_open("Media-delivery history lookup capacity exhausted for")
return None
loop = asyncio.get_running_loop()
result_future = loop.create_future()
def _publish_result(result=None, error=None):
if not result_future.done():
(result_future.set_exception(error) if error is not None
else result_future.set_result(result))
def _worker():
result, error = None, None
try:
result = self._history_media_paths_for_session(session_key)
except BaseException as exc:
error = exc
try:
with contextlib.suppress(RuntimeError): # loop already closed (gateway shutdown)
loop.call_soon_threadsafe(_publish_result, result, error)
finally:
admission.release()
try:
threading.Thread(target=_worker, name="media-history-lookup", daemon=True).start()
except Exception:
# start() failed (thread exhaustion): the worker never ran, so release the permit here.
admission.release()
_fail_open("Could not start media-delivery history lookup worker for", exc_info=True)
return None
try:
return await asyncio.wait_for(result_future, timeout=_HISTORY_MEDIA_LOOKUP_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
_fail_open("Timed out loading media-delivery history for")
return None
except Exception:
# Best-effort/fail-open: never let a lookup failure kill media delivery.
_fail_open("Media-delivery history lookup failed for", exc_info=True)
return None
@abstractmethod
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect and start receiving; True on success. ``is_reconnect``: the reconnect watcher is
re-establishing a dropped platform — adapters with a server-side update queue (Telegram)
must preserve it so outage-time messages aren't discarded."""
@abstractmethod
async def disconnect(self) -> None:
"""Disconnect from the platform."""
@abstractmethod
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send ``content`` (may be markdown) to a chat; returns SendResult with message id."""
# Surfaces needing an explicit finalize edit (DingTalk AI Cards): the consumer never skips it.
REQUIRES_EDIT_FINALIZE: bool = False
async def create_handoff_thread(self, parent_chat_id: str, name: str) -> Optional[str]:
"""Create a fresh thread under ``parent_chat_id`` for a CLI→platform session handoff; its id
as str, or None when unsupported/failed (the watcher then uses ``parent_chat_id``
directly)."""
return None
async def edit_message(
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult:
"""Edit a sent message (optional: success=False makes callers send anew). ``finalize`` marks
the last edit of a streamed response; surfaces with a distinct "in progress" state (DingTalk
AI Cards) close the message on it and set ``REQUIRES_EDIT_FINALIZE`` so it's routed even
when content is unchanged."""
return SendResult(success=False, error="Not supported")
async def delete_message(self, chat_id: str, message_id: str) -> bool:
"""Delete a sent message; True on success (platforms without a deletion API return False and
callers leave it). Used by the stream consumer's fresh-final cleanup to remove stale
previews.
Used by the stream consumer's fresh-final cleanup path (see openclaw/openclaw#72038) to remove
long-lived preview messages after sending the completed reply as a fresh message so the platform's
visible timestamp reflects completion time.
"""
return False
def _get_ephemeral_system_ttl_default(self) -> int:
"""Default :class:`EphemeralReply` TTL from ``display.ephemeral_system_ttl``
(``0`` = no auto-delete); non-fatal if config is unreadable."""
return _or_default(
lambda: int(_config_section("display").get("ephemeral_system_ttl", 0)), 0)
def _schedule_ephemeral_delete(self, chat_id: str, message_id: str, ttl_seconds: int) -> None:
"""Spawn a detached task that deletes ``message_id`` after ``ttl_seconds``; best-effort
(gateway restart, permission denied, Telegram 48h window) swallowed at debug level."""
async def _run_delete() -> None:
try:
await asyncio.sleep(max(1, int(ttl_seconds)))
await self.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
logger.debug("[%s] Ephemeral delete failed for %s/%s: %s", self.name, chat_id, message_id, e)
coro = _run_delete()
try:
asyncio.create_task(coro)
except RuntimeError:
# No running loop (unit tests): close the coroutine to avoid a never-awaited warning.
coro.close()
# ── ``_format_exec_approval`` templates; adapters override only the MARKUP (bold, HTML,
# fences) — the words come from ``gateway.platforms.base_exec_approval`` so every surface
# says the same thing.
_EA_HEADER: str = f"⚠️ {EA_HEADER_TEXT}\n\n"
_EA_CODE_OPEN: str = "```\n"
_EA_CODE_CLOSE: str = "\n```\n"
_EA_REASON_LABEL: str = f"{EA_REASON_LABEL_TEXT}: "
_EA_DEADLINE_PREFIX: str = "\n\n" # separates the deadline line from the reason line
_EA_SMART_DENY_LINE: str = "\n\nSmart DENY: owner override applies to this one operation only."
_EA_CMD_BUDGET: int = 3000
_EA_REASON_BUDGET: int = 0 # 0 = the reason is never truncated
@staticmethod
def _truncate_preview(text: str, budget: int, suffix: str = "...") -> str:
"""Truncate ``text`` to ``budget`` chars, appending ``suffix`` when cut."""
text = str(text or "")
return text[:budget] + suffix if len(text) > budget else text
def _ea_escape(self, text: str) -> str:
"""Escape hook for command preview/reason; HTML-mode platforms (Telegram) override."""
return text
def _ea_fit(self, text: str, budget: int, suffix: str = "...", escape: Optional[Callable[[str], str]] = None) -> str:
"""``_truncate_preview`` measured after ``escape`` (default ``_ea_escape``) in
``message_len_fn`` units: the platform cap applies to the wire payload, and escaping
expands (``&`` → ``&amp;``), so a raw-length cut can still overflow. Returns raw text (the
caller escapes); ``suffix`` rides outside ``budget`` like ``_truncate_preview``."""
text = str(text or "")
escape = escape or self._ea_escape
len_fn = self.message_len_fn
if len_fn(escape(text)) <= budget:
return text
lo, hi = 0, len(text)
while lo < hi: # escaped length is monotonic in the raw prefix, so bisect it
mid = (lo + hi + 1) // 2
if len_fn(escape(text[:mid])) <= budget:
lo = mid
else:
hi = mid - 1
return text[:lo] + suffix
def _exec_approval_cmd_budget(self, description: str, smart_denied: bool) -> int:
"""Chars of command preview that fit; platforms with a hard message cap compute it."""
return self._EA_CMD_BUDGET
def _ea_deadline_line(self) -> str:
"""The "doing nothing means it will NOT run" line, with the configured approvals.timeout."""
return self._EA_DEADLINE_PREFIX + self._ea_escape(format_approval_deadline_line(approval_timeout_seconds()))
def _format_exec_approval(
self, command: str, description: str = "dangerous command", smart_denied: bool = False) -> str:
"""Shared exec-approval prompt text: header + fenced (truncated) command + why it was
flagged + the deadline line, plus the smart-deny line. Buttons/trailing instructions stay
platform-local."""
if self._EA_REASON_BUDGET:
description = self._ea_fit(str(description or ""), self._EA_REASON_BUDGET)
cmd_preview = self._ea_fit(
str(command or ""), self._exec_approval_cmd_budget(description, smart_denied))
text = (f"{self._EA_HEADER}"
f"{self._EA_CODE_OPEN}{self._ea_escape(cmd_preview)}{self._EA_CODE_CLOSE}"
f"{self._EA_REASON_LABEL}{self._ea_escape(description)}"
f"{self._ea_deadline_line()}")
return text + self._EA_SMART_DENY_LINE if smart_denied else text
# ── Exec-approval prompt (template method). The choice set is one rule for every button
# surface — three separate "same fix × N adapters" commits motivated lifting it here.
_EA_ACTION_LABELS: Dict[str, str] = {
"once": "Allow Once", "session": "Allow Session", "always": "Always Allow", "deny": "Deny"}
_EA_ACTION_STYLES: Dict[str, str] = {"once": "primary", "deny": "danger"}
def _exec_approval_actions(
self, *, allow_permanent: bool, allow_session: bool, smart_denied: bool) -> List[Tuple[str, str, str]]:
"""``(label, choice, style)`` rows for the approval buttons. A smart deny is an owner
override for one operation only, so it offers neither the session nor the permanent tier;
the permanent tier is never offered without the session tier."""
choices = ["once"]
if not smart_denied and allow_session:
choices.append("session")
if allow_permanent:
choices.append("always")
choices.append("deny")
return [(self._EA_ACTION_LABELS[c], c, self._EA_ACTION_STYLES.get(c, "")) for c in choices]
@classmethod
def supports_exec_approval_buttons(cls) -> bool:
"""True when the adapter renders native approval buttons (overrides the prompt hook);
the runner otherwise sends the plain-text ``/approve`` prompt."""
return cls._send_exec_approval_prompt is not BasePlatformAdapter._send_exec_approval_prompt
async def send_exec_approval(
self, chat_id: str, command: str, session_key: str, description: str = "dangerous command",
metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True,
smart_denied: bool = False,
) -> SendResult:
"""Interactive exec-approval prompt; a press resolves via
``tools.approval.resolve_gateway_approval``. Text and choice set are shared; adapters
render them natively in ``_send_exec_approval_prompt``."""
prompt = ExecApprovalPrompt(
chat_id=chat_id, session_key=session_key, metadata=metadata, command=str(command or ""),
description=description, smart_denied=smart_denied,
text=self._format_exec_approval(command, description, smart_denied),
actions=self._exec_approval_actions(
allow_permanent=allow_permanent, allow_session=allow_session, smart_denied=smart_denied))
return await self._send_exec_approval_prompt(prompt)
async def _send_exec_approval_prompt(self, prompt: "ExecApprovalPrompt") -> SendResult:
"""Render ``prompt`` with the platform's native buttons; the default has none."""
return SendResult(success=False, error="Not supported")
@staticmethod
def _format_choice_page(options: list, page: int, per_page: int) -> "tuple[list, Dict[str, Any]]":
"""Shared picker pagination: clamp ``page``, slice ``options`` -> ``(page_options, meta)``
with ``page``/``total_pages``/``start``/``end``/``total``/``page_info`` (`` (N–M of T)``,
empty for one page)."""
total = len(options)
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(0, min(page, total_pages - 1))
start, end = page * per_page, min(page * per_page + per_page, total)
page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else ""
meta: Dict[str, Any] = {"page": page, "total_pages": total_pages, "start": start,
"end": end, "total": total, "page_info": page_info}
return options[start:end], meta
async def send_slash_confirm(
self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Three-option slash-command confirmation (e.g. ``/reload-mcp``). Button adapters render
Approve Once / Always Approve / Cancel and MUST resolve via
``GatewayRunner._resolve_slash_confirm(confirm_id, "once"|"always"|"cancel")``. Default (not
supported) falls through to the gateway text fallback
(``/approve``/``/always``/``/cancel``)."""
return SendResult(success=False, error="Not supported")
async def send_clarify(
self, chat_id: str, question: str, choices: Optional[list], clarify_id: str,
session_key: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Clarify prompt; button-capable adapters SHOULD override. Multiple choice (``choices``):
one button per choice plus "Other"; callbacks MUST resolve via
``tools.clarify_gateway.resolve_gateway_clarify(clarify_id, response)``, "Other" calls
``mark_awaiting_text(clarify_id)``. Open-ended: send the question as text (the gateway
text-intercept resolves the next message). Default: numbered list +
``mark_awaiting_text``. Adapters whose prompt is a persistent card MAY define
``async retire_clarify_card(clarify_id, notice)``; the gateway calls it when the clarify
ends without a click (timeout, session reset, superseding free prose)."""
if choices:
# Multi-select flag lives on the pending entry (signature stays adapter-compatible).
try:
from tools import clarify_gateway as _cg
with _cg._lock:
_is_multi = bool(getattr(_cg._entries.get(clarify_id), "multi_select", False))
except Exception:
_is_multi = False
hint = "Reply with the number, the option text, or your own answer."
if _is_multi:
hint = ("Multiple selections allowed — reply with the numbers separated by commas "
"or spaces (e.g. \"1, 3\"), the option text, or your own answer.")
numbered = [f" {i}. {choice}" for i, choice in enumerate(choices, start=1)]
text = "\n".join([f"❓ {question}", "", *numbered, "", hint])
# Text fallback: let the gateway intercept capture the typed reply.
from tools.clarify_gateway import mark_awaiting_text
mark_awaiting_text(clarify_id)
else:
text = f"❓ {question}"
return await self.send(chat_id=chat_id, content=text, metadata=metadata)
async def send_private_notice(
self, chat_id: str, user_id: Optional[str], content: str, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send a notice privately when the platform supports it; default is a normal send."""
return await self.send(chat_id=chat_id, content=content, reply_to=reply_to, metadata=metadata)
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Send a typing indicator; ``metadata`` carries platform context (Slack thread_id)."""
async def stop_typing(self, chat_id: str) -> None:
"""Stop a persistent typing indicator; override where typing runs as a loop."""
@staticmethod
def _accepts_kwarg(fn: Callable, name: str, *, var_kw: bool, unknown: bool) -> bool:
"""Whether ``fn``'s signature takes keyword ``name`` (``var_kw``: a ``**kwargs`` also
counts); ``unknown`` when the signature can't be introspected."""
try:
params = inspect.signature(fn).parameters
except (TypeError, ValueError):
return unknown
return name in params or (var_kw and any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()))
async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None:
"""Stop typing, forwarding ``metadata`` only if ``stop_typing`` accepts it (Slack AI
status is per thread, so dropping metadata could clear a sibling thread; introspecting
keeps legacy ``stop_typing(chat_id)`` adapters working)."""
if metadata and self._accepts_kwarg(
self.stop_typing, "metadata", var_kw=True, unknown=False):
await self.stop_typing(chat_id, metadata=metadata)
return
await self.stop_typing(chat_id)
async def send_multiple_images(
self, chat_id: str, images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0) -> SendResult:
"""Send ``(url, alt)`` images (``http(s)://`` or ``file://``) one by one (GIFs via
``send_animation``, local files via ``send_image_file``); override to bundle natively
(Signal). Returns success when at least one image was delivered — the outcome
the turn-level delivery tracker records; every override must return the same
aggregate, or a media-only turn on that platform reports FAILURE (#106153)."""
delivered = False
for image_url, alt_text in images:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
logger.info("[%s] Sending image: %s (alt=%s)", self.name,
safe_url_for_log(image_url), alt_text[:30] if alt_text else "")
if image_url.startswith("file://"):
from urllib.request import url2pathname
sender, url_kw = self.send_image_file, {"image_path": url2pathname(image_url[7:])}
elif self._is_animation_url(image_url):
sender, url_kw = self.send_animation, {"animation_url": image_url}
else:
sender, url_kw = self.send_image, {"image_url": image_url}
img_result = await sender(
chat_id=chat_id, **url_kw, caption=alt_text or None, metadata=metadata)
if not img_result.success:
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
else:
delivered = True
except Exception as img_err:
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
if not images:
return SendResult(success=False, error="no images to send")
return SendResult(
success=delivered,
error=None if delivered else "all images failed to send")
async def send_image(
self, chat_id: str, image_url: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send an image natively; default falls back to sending the URL as text."""
text = f"{caption}\n{image_url}" if caption else image_url
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
async def send_animation(
self, chat_id: str, animation_url: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send a GIF as a native animation (auto-plays inline); default falls back to
send_image."""
return await self.send_image(
chat_id=chat_id, image_url=animation_url, caption=caption, reply_to=reply_to, metadata=metadata)
@staticmethod
def _is_animation_url(url: str) -> bool:
"""Check if a URL points to an animated GIF (vs a static image)."""
return url.lower().split('?')[0].endswith('.gif')
@staticmethod
def extract_images(content: str) -> Tuple[List[Tuple[str, str]], str]:
"""Extract ``![alt](url)`` and ``<img src=...>`` image URLs from a response;
returns ``([(url, alt_text), ...], content with those tags removed)``."""
md_pattern = r'!\[([^\]]*)\]\((https?://[^\s\)]+)\)'
# <img src="url"> / <img src="url"></img> / <img src="url"/>
html_pattern = r'<img\s+src=["\']?(https?://[^\s"\'<>]+)["\']?\s*/?>\s*(?:</img>)?'
# Only extract URLs that look like actual images.
markers = ('.png', '.jpg', '.jpeg', '.gif', '.webp', 'fal.media', 'fal-cdn',
'replicate.delivery')
images = [(m.group(2), m.group(1)) for m in re.finditer(md_pattern, content)
if any(m.group(2).lower().endswith(ext) or ext in m.group(2).lower()
for ext in markers)]
images.extend((match.group(1), "") for match in re.finditer(html_pattern, content))
if not images:
return images, content
# Remove only the tags we extracted, not every markdown image.
extracted_urls = {url for url, _ in images}
def _remove_if_extracted(match):
url = match.group(2) if match.lastindex >= 2 else match.group(1)
return '' if url in extracted_urls else match.group(0)
cleaned = content
for pattern in (md_pattern, html_pattern):
cleaned = re.sub(pattern, _remove_if_extracted, cleaned)
return images, re.sub(r'\n{3,}', '\n\n', cleaned).strip() # leftover blank lines
async def send_voice(
self, chat_id: str, audio_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
"""Send audio as a native voice message (Telegram bubble / Discord attachment).
Default: friendly failure notice."""
return await self._send_media_fallback_notice(
"send_voice", "audio", audio_path, chat_id, caption, reply_to, metadata)
async def _send_media_fallback_notice(
self, method: str, kind: str, path: str, chat_id: str, caption: Optional[str],
reply_to: Optional[str], metadata: Optional[Dict[str, Any]], *, file_name: Optional[str] = None,
) -> SendResult:
"""Shared default for send_voice/send_video/send_document/send_image_file. The local path is
logged but NEVER echoed into chat (host layout leak); only the caller's ``file_name`` is
shown."""
logger.warning("[%s] %s fallback: native %s send unavailable for %s", self.name, method, kind, path)
text = _media_failure_text(kind, file_name)
return await self.emit_media_warning(chat_id, text, caption=caption, reply_to=reply_to, metadata=metadata,
shown_metadata=metadata)
async def emit_warning(
self, chat_id: str, content: str, *, reply_to=None, metadata=None, logical_platform=None,
) -> Optional[SendResult]:
"""Present a classified channel diagnostic in the caller's owning scope.
None means suppressed, NOT successfully sent. Transport receipts/exceptions
pass through unchanged; logs and producer state belong outside this boundary.
Existing routing/stream metadata is preserved, never inferred from text.
"""
if not self.warning_notifications_enabled(logical_platform, chat_id=chat_id, metadata=metadata):
return None
return await self.send(chat_id, content, reply_to=reply_to, metadata=metadata)
async def emit_media_warning(
self, chat_id: str, notice: str, *, caption=None, reply_to=None, metadata=None,
shown_metadata=None,
) -> SendResult:
"""Present an optional media diagnostic without losing the requested caption.
Preserve the legacy fallback-text receipt when shown (``shown_metadata`` is the exact
metadata the legacy shown path passed; default None keeps callers that sent none
byte-identical). When hidden, preserve the media failure even if the independent
caption itself was delivered.
"""
result = await self.emit_warning(chat_id, f"{caption}\n{notice}" if caption else notice,
reply_to=reply_to, metadata=shown_metadata)
if result is not None:
return result
if caption:
await self.send(chat_id, caption, reply_to=reply_to, metadata=metadata)
return SendResult(success=False, error=notice)
def warning_text(self, visible: str, hidden: Optional[str] = "", *, logical_platform=None, chat_id=None, metadata=None) -> Optional[str]:
"""Project mixed content: the diagnostic variant when visible, else the requested remainder.
For payloads that combine a requested result (caption, answer) with an automatic
diagnostic. The requested part must be present in BOTH variants; never hide it.
"""
if self.warning_notifications_enabled(logical_platform, chat_id=chat_id, metadata=metadata):
return visible
return hidden
def warning_notifications_enabled(self, logical_platform=None, *, chat_id=None, metadata=None) -> bool:
"""Presentation policy under the caller's owning profile; old plugins inherit it."""
from gateway.warning_notifications import warning_notifications_enabled
return warning_notifications_enabled(logical_platform or self.platform)
def prepare_tts_text(self, text: str) -> str:
"""Chat Markdown -> transcript-like spoken script (reasoning blocks removed,
headings/bullets flattened, units expanded). Chunking and delivery limits are the TTS tool's
job."""
try:
from tools.tts_text_normalize import prepare_spoken_text
return prepare_spoken_text(text, max_chars=None)
except Exception:
# Keep auto-TTS best-effort if the normalizer ever fails.
text = re.sub(r'<think[\s>].*?</think>', ' ', text, flags=re.DOTALL)
return re.sub(r'[*_`#\[\]()]', '', text).strip()
async def play_tts(self, chat_id: str, audio_path: str, **kwargs) -> SendResult:
"""Play auto-TTS audio; override for invisible playback (Web UI). Default: send_voice."""
return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)
# ── Streaming TTS contract: voice adapters accept PCM chunks while the LLM generates.
# Defaults report "unsupported" (whole-file fallback).
# ------------------------------------------------------------------ Streaming TTS adapter contract
# (#60671) ------------------------------------------------------------------ Voice-capable adapters
# (LiveKit, Discord voice, …) override these to accept PCM audio chunks while the LLM is still
# generating. The default implementations report "unsupported" so existing adapters are
# source-compatible and keep the whole-file auto-TTS fallback.
def supports_streaming_tts(self, chat_id: str, audio_format: AudioFormat) -> bool:
"""Return True when this adapter can accept streaming PCM for *chat_id*."""
return False
async def begin_streaming_tts(
self, chat_id: str, audio_format: AudioFormat, metadata: Optional[Dict[str, Any]] = None,
) -> Optional[StreamingTTSHandle]:
"""Open a streaming-audio session; returns an opaque handle for the
write/finish/abort calls, or ``None`` to decline (whole-file fallback)."""
return None
async def write_streaming_tts(self, handle: StreamingTTSHandle, chunk: bytes) -> None:
"""Write one PCM chunk to the adapter's outbound audio track."""
async def finish_streaming_tts(self, handle: StreamingTTSHandle, *, interrupted: bool = False) -> None:
"""Signal normal end of the audio stream."""
async def abort_streaming_tts(self, handle: StreamingTTSHandle, error: Optional[str] = None) -> None:
"""Abort the stream due to an error or cancellation. Must be idempotent: late producer
chunks after abort are silently dropped, not raised. Restores state to "not streaming"."""
def _streaming_tts_turn_key(self, session_key: str | None, turn_marker: Any = None, *, event: Any = None) -> str | None:
return streaming_tts_turn_key(session_key, turn_marker, event=event)
def _mark_streaming_tts_completed_turn(self, session_key: str | None, turn_marker: Any = None, *, event: Any = None) -> None:
turn_key = self._streaming_tts_turn_key(session_key, turn_marker, event=event)
if turn_key is not None:
_lazy_attr(self, "_streaming_tts_completed_turns", set).add(turn_key)
def _streaming_tts_turn_completed(self, session_key: str | None, turn_marker: Any = None, *, event: Any = None) -> bool:
return streaming_tts_should_skip_whole_file(
getattr(self, "_streaming_tts_completed_turns", set()), session_key, turn_marker, event=event)
async def send_video(
self, chat_id: str, video_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
"""Send a video natively (inline playable). Default: friendly failure notice."""
return await self._send_media_fallback_notice(
"send_video", "video", video_path, chat_id, caption, reply_to, metadata)
async def send_document(self, chat_id: str, file_path: str, caption: Optional[str] = None,
file_name: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
"""Send a document/file natively. Default: friendly failure notice."""
return await self._send_media_fallback_notice(
"send_document", "file", file_path, chat_id, caption, reply_to, metadata, file_name=file_name)
async def _notify_media_delivery_failure(
self, chat_id: str, media_path: str, *, is_voice: bool = False,
metadata: Optional[Dict[str, Any]] = None) -> None:
"""User-visible notice when a MEDIA attachment upload failed: the tag was
already stripped from the text, so silence would be a silent drop.
The non-streaming dispatch loop strips ``MEDIA:`` tags before sending attachments. When the
subsequent upload returns ``success=False`` (for example Discord accepted the message but attached
nothing), the user must see a failure notice instead of a silent drop (#66797).
"""
ext = Path(media_path).suffix.lower()
if is_voice or should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
text = _media_failure_text("audio")
elif ext in _VIDEO_EXTS:
text = _media_failure_text("video")
else:
text = _media_failure_text("file", os.path.basename(media_path))
try:
notice = await self.emit_warning(chat_id, text, metadata=metadata)
problem = None if notice is None or notice.success else notice.error
except Exception as notify_err:
problem = notify_err
if problem is not None:
logger.debug("[%s] Could not send media-delivery-failure notice: %s", self.name, problem)
async def send_image_file(
self, chat_id: str, image_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
"""Send a local image file natively (send_image takes a URL). Default: friendly notice."""
return await self._send_media_fallback_notice(
"send_image_file", "image", image_path, chat_id, caption, reply_to, metadata)
@staticmethod
def validate_media_delivery_path(path: str, session_key: str = "") -> Optional[str]:
"""Return a resolved path if it is safe for native attachment upload."""
return validate_media_delivery_path(path, session_key=session_key)
@staticmethod
def filter_media_delivery_paths(media_files, session_key: str = "",
dropped: Optional[List[dict]] = None) -> List[Tuple[str, bool]]:
"""Drop unsafe MEDIA paths and normalize accepted paths; ``dropped`` collects the rejects."""
return [
(safe_path, bool(is_voice)) for media_path, is_voice in media_files or []
if (safe_path := _validated_delivery_path(media_path, session_key, "MEDIA directive path", dropped))]
@staticmethod
def filter_local_delivery_paths(file_paths, session_key: str = "") -> List[str]:
"""Drop unsafe bare local file paths and normalize accepted paths."""
safe_paths = (_validated_delivery_path(p, session_key, "local file path") for p in file_paths or [])
return [p for p in safe_paths if p]
@staticmethod
def _mask_protected_spans(content: str) -> str:
"""Blank fenced code, inline code and blockquotes (length-preserving so regex offsets stay
valid) against MEDIA: false positives; backtick-quoted paths inside MEDIA: tags stay
scannable."""
spans: list = [m.span() for m in _FENCED_CODE_RE.finditer(content)]
for m in _INLINE_CODE_RE.finditer(content):
start = m.start()
if re.search(r'MEDIA:\s*$', content[max(0, start - 20):start]):
continue # This is a MEDIA path quote, not inline code
# A whole tag in inline code (`MEDIA:/path.csv`) is a real directive (models format
# paths as code): deliver IF it validates; non-existent examples stay masked.
# See #35695.
inner = m.group(0)[1:-1].strip()
if inner.upper().startswith("MEDIA:"):
candidate = _normalize_media_tag_path(inner[6:])
if candidate and validate_media_delivery_path(candidate):
continue # Real deliverable tag in inline code — keep it scannable
spans.append((start, m.end()))
spans.extend(m.span() for m in re.finditer(r'^>.*$', content, re.MULTILINE))
return _blank_spans(content, spans)
@staticmethod
def _mask_json_string_media(content: str) -> str:
"""Blank ``MEDIA:<bare-path>`` tags inside JSON string *values* (stored tool-result text
like ``{"result": "MEDIA:/x/stale.png"}``) so they are never re-delivered. Only
value-context strings (``:,{[`` before the ``"``) and bare paths (``/``, ``~/``, ``X:\\``)
count; ``MEDIA:"..."`` quoted tags and line-start/prose tags are untouched. Offsets
preserved.
Here the ``MEDIA:`` is part of stored text, not an outbound directive, but the bare-path branch of
``MEDIA_TAG_CLEANUP_RE`` would still match it and re-deliver a stale file. (Regression report
#34375.)
"""
if '"' not in content or "MEDIA:" not in content:
return content
# Value-context string: quote preceded by : , { or [; escape-aware body to the closing
# quote.
spans = [
m.span(1) for m in re.finditer(r'(?<=[:,{\[])\s*"((?:[^"\\\n]|\\.)*)"', content)
if re.search(r'MEDIA:\s*(?:~/|/|[A-Za-z]:[/\\])', m.group(1))]
return _blank_spans(content, spans)
@staticmethod
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
"""Extract ``MEDIA:<path>`` tags and strip ``[[audio_as_voice]]`` / ``[[as_document]]`` ->
``([(path, is_voice), ...], cleaned)``. Both directives are message-global;
``[[as_document]]`` (unmodified sendDocument for large images) is detected by dispatch sites
on the ORIGINAL response and only stripped here."""
media = []
has_voice_tag = "[[audio_as_voice]]" in content
cleaned = content.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
# Scan a masked copy so example/stored MEDIA paths (code, quotes, JSON values) are never
# delivered; dedupe on the expanded path so a file referenced twice uploads once.
scan_content = _mask_media_scan_text(content)
# - code blocks / inline code / blockquotes hold prose examples (#35695) - serialized JSON string
# values hold stored tool-result text (#34375) Both maskers are offset-preserving (chars ->
# spaces) so match offsets stay valid; chaining them masks the union of both protected regions.
# Dedupe on the expanded path (first occurrence wins) so the same file referenced twice in one
# response — e.g. a MEDIA tag inline AND in a summary footer — is uploaded once, not twice (#29131).
seen_paths: set = set()
def _add(path: str) -> None:
# is_voice only for audio: a voice-flagged image would leave the photo batch.
if path not in seen_paths:
seen_paths.add(path)
media.append((path, has_voice_tag and os.path.splitext(path)[1].lower() in _AUDIO_EXTS))
for match in MEDIA_TAG_CLEANUP_RE.finditer(scan_content):
path = _normalize_media_tag_path(match.group("path"))
if path:
try:
_add(os.path.expanduser(path))
except (OSError, RuntimeError, ValueError):
continue # crafted ~\x00 path: skip it, keep the rest
for _, safe_path, _ in _extensionless_media_matches(scan_content):
_add(safe_path)
# Locate tag spans on a masked copy, delete them from the unmasked text (protected spans
# survive).
if media:
spans = _deliverable_tag_spans(cleaned)
if spans:
cleaned = re.sub(r'\n{3,}', '\n\n', _delete_spans(cleaned, spans)).strip()
return media, cleaned
@staticmethod
def strip_media_directives_for_display(text: str) -> str:
"""Strip MEDIA: directives from streamed/display text. Known-extension tags are
removed unconditionally (as ``MEDIA_TAG_CLEANUP_RE``); extension-less tags only when
``validate_media_delivery_path`` accepts the path, so undeliverable paths stay visible."""
if not _has_media_directives(text):
return text
return re.sub(r'\n{3,}', '\n\n', _strip_media_tag_directives(text)).rstrip()
@staticmethod
def extract_local_files(content: str) -> Tuple[List[str], str]:
"""Bare local file paths (absolute, ``~/`` or drive-letter) with deliverable extensions ->
``(expanded_paths, cleaned_text)``. Candidates must exist on disk (URLs / hallucinated paths
ignored); paths inside fenced or inline code are skipped so code samples are never
mutilated. Dispatch by type lives in ``gateway/run.py``."""
ext_part = '|'.join(e.lstrip('.') for e in MEDIA_DELIVERY_EXTS)
# Lookbehind rejects URL/relative matches (https://…/img.png, ./foo.png).
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png) and relative paths (./foo.png)
# (?:~/|/) anchors to absolute or home-relative Unix paths (?:[A-Za-z]:[/\\]) anchors to Windows
# drive-letter paths (#34632)
path_re = re.compile(
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
re.IGNORECASE)
code_spans = _code_spans(content)
unique: dict = {} # expanded_path -> raw_match_text, deduped in discovery order
for match in path_re.finditer(content):
if any(s <= match.start() < e for s, e in code_spans):
continue
raw = match.group(0)
expanded = os.path.expanduser(raw)
if os.path.isfile(expanded):
unique.setdefault(expanded, raw)
else:
# Most common reason a promised file never arrives — log the gap.
logger.info("Skipping bare file path in reply (no file on disk): %s", _log_safe_path(raw))
if not unique:
return [], content
cleaned = content
for raw in unique.values():
cleaned = cleaned.replace(raw, '')
return list(unique), re.sub(r'\n{3,}', '\n\n', cleaned).strip()
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None,
stop_event: asyncio.Event | None = None) -> None:
"""Refresh the typing indicator every ``interval`` seconds until cancelled (platform typing
state expires after ~5s). Chats in ``_typing_paused`` are skipped (approval waits — Slack's
setStatus disables the compose box). Each ``send_typing`` is bounded by a sub-interval
timeout so one slow round-trip is abandoned before the next tick, not the bubble lapsing."""
_send_typing_timeout = max(0.25, min(1.5, interval - 0.25))
try:
while True:
if stop_event is not None and stop_event.is_set():
return
if chat_id not in self._typing_paused:
try:
await asyncio.wait_for(self.send_typing(chat_id, metadata=metadata),
timeout=_send_typing_timeout)
except asyncio.TimeoutError:
pass # Slow network — abandon this tick, stay on schedule.
except Exception as typing_err:
logger.debug("[%s] send_typing error (non-fatal): %s", self.name, typing_err)
if stop_event is None:
await asyncio.sleep(interval)
continue
loop = asyncio.get_running_loop()
deadline = loop.time() + interval
while not stop_event.is_set():
remaining = deadline - loop.time()
if remaining <= 0:
break
# Poll, not wait_for(stop_event.wait()): cancelling that wedges
# 3.11/pytest-asyncio.
await asyncio.sleep(min(0.25, remaining))
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
finally:
# A send_typing after an outer stop_typing() may have recreated the platform loop.
await self._stop_typing_quietly(chat_id, metadata)
self._typing_paused.discard(chat_id)
# getattr-guard: tests build adapters via object.__new__ without _status_text.
getattr(self, "_status_text", {}).pop(str(chat_id), None)
async def _stop_typing_refresh(
self, chat_id: str, typing_task: asyncio.Task | None = None, *, metadata=None,
timeout: float = 0.5, stop_attempts: int = 2) -> None:
"""Stop the refresh task and platform typing state as one operation."""
self._typing_paused.add(chat_id)
try:
if typing_task is not None and not typing_task.done():
typing_task.cancel()
# Slow adapter cleanup must not block delivery/shutdown.
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(typing_task), timeout=timeout)
for attempt in range(max(1, stop_attempts)):
if attempt:
await asyncio.sleep(0)
await self._stop_typing_quietly(chat_id, metadata)
finally:
self._typing_paused.discard(chat_id)
async def _stop_typing_quietly(self, chat_id: str, metadata=None) -> None:
"""Best-effort platform stop_typing; adapter errors are swallowed."""
with contextlib.suppress(Exception):
await self._stop_typing_with_metadata(chat_id, metadata)
def pause_typing_for_chat(self, chat_id: str) -> None:
"""Pause typing for a chat (approval waits); GIL-safe from the sync agent thread."""
self._typing_paused.add(chat_id)
def resume_typing_for_chat(self, chat_id: str) -> None:
"""Resume typing indicator for a chat after approval resolves."""
self._typing_paused.discard(chat_id)
async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None:
"""Signal the active session loop to stop and clear typing immediately."""
if session_key and session_key in self._active_sessions:
self._active_sessions[session_key].set()
await self._stop_typing_quietly(chat_id, metadata)
def register_post_delivery_callback(
self, session_key: str, callback: Callable, *, generation: int | None = None) -> None:
"""Register a deferred callback to fire after the main response. Same-key registrations are
chained (both fire in order, per-callback exception isolation) so independent features
coexist; ``generation`` ties it to a gateway run — stale generations never overwrite a
fresher slot."""
if not session_key or not callable(callback):
return
existing = self._post_delivery_callbacks.get(session_key)
if existing is not None:
existing_gen, existing_cb = _split_post_delivery_entry(existing)
if existing_gen is not None and generation is not None and int(generation) < int(existing_gen):
return
# Same-or-newer generation: chain so both fire in registration order.
if callable(existing_cb) and (
existing_gen is None or generation is None or int(existing_gen) == int(generation)):
callback = self._chain_callbacks(existing_cb, callback)
self._post_delivery_callbacks[session_key] = (
callback if generation is None else (int(generation), callback))
@staticmethod
def _chain_callbacks(*callbacks: Callable) -> Callable[[], Awaitable[None]]:
"""Async wrapper running ``callbacks`` in order with per-callback exception isolation;
async so coroutines returned by async hooks are awaited, not dropped."""
async def _chained() -> None:
for _cb in callbacks:
try:
_result = _cb()
if inspect.isawaitable(_result):
await _result
except Exception:
logger.debug("Post-delivery callback failed", exc_info=True)
return _chained
def pop_post_delivery_callback(
self, session_key: str, *, generation: int | None = None) -> Callable | None:
"""Pop a deferred callback, optionally requiring generation ownership."""
entry = self._post_delivery_callbacks.get(session_key) if session_key else None
if entry is None:
return None
entry_generation, callback = _split_post_delivery_entry(entry)
if generation is not None and (entry_generation is None or int(entry_generation) != int(generation)):
return None
self._post_delivery_callbacks.pop(session_key, None)
return callback if callable(callback) else None
# ── Processing lifecycle hooks (Discord 👀/✅/❌ reactions). Adapters exposing
# ``_add_reaction(chat_id, message_id, emoji)`` / ``_remove_reaction(chat_id, message_id)``
# can just set the emoji attributes; left ``None`` the hook is a no-op.
_ACK_EMOJI: Optional[str] = None
_OK_EMOJI: Optional[str] = None
_FAIL_EMOJI: Optional[str] = None
async def on_processing_start(self, event: MessageEvent) -> None:
"""Hook called when background processing begins."""
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
"""Hook called when background processing completes. Default: opt-in reaction ack — with
``_OK_EMOJI``/``_FAIL_EMOJI`` set and ``_add_reaction``/``_remove_reaction`` present, swap
the in-progress reaction for the outcome one. Remove-then-add is deterministic whether the
platform replaces or stacks a sender's reactions. CANCELLED leaves it unreacted."""
if self._OK_EMOJI is None and self._FAIL_EMOJI is None:
return
add: Any = getattr(self, "_add_reaction", None)
remove: Any = getattr(self, "_remove_reaction", None)
enabled = getattr(self, "_reactions_enabled", None)
chat_id = getattr(event.source, "chat_id", None)
message_id = getattr(event, "message_id", None)
if (not callable(add) or not callable(remove) or (callable(enabled) and not enabled())
or not chat_id or not message_id):
return
await remove(chat_id, message_id)
emoji = {ProcessingOutcome.SUCCESS: self._OK_EMOJI,
ProcessingOutcome.FAILURE: self._FAIL_EMOJI}.get(outcome)
if emoji:
await add(chat_id, message_id, emoji)
async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
"""Run a lifecycle hook without letting failures break message flow."""
hook = getattr(self, hook_name, None)
if not callable(hook):
return
try:
await hook(*args, **kwargs)
except Exception as e:
logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
@staticmethod
def _is_retryable_error(error: Optional[str]) -> bool:
"""Return True if the error string looks like a transient network failure."""
lowered = (error or "").lower()
return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS)
@staticmethod
def _is_rate_limited_error(error: Optional[str]) -> bool:
"""Return True if the error string classifies as a rate limit / flood cap.
Single wrapper around :func:`classify_send_error` so the call sites in
:meth:`_send_with_retry` share one notion of "is this a rate limit" instead
of inline copies that could drift.
"""
return classify_send_error(None, error or "") == "rate_limited"
@staticmethod
def _is_timeout_error(error: Optional[str]) -> bool:
"""Return True for read/write timeouts — NOT retryable and NOT a plain-text
fallback trigger, because the request may already have been delivered."""
lowered = (error or "").lower()
return any(pat in lowered for pat in ("timed out", "readtimeout", "writetimeout"))
def _unwrap_ephemeral(self, response: Any) -> Tuple[Optional[str], int]:
"""Unwrap a str/None/:class:`EphemeralReply` response into ``(text, ttl)``. ``ttl > 0``
means schedule ``_schedule_ephemeral_delete`` after a successful send; forced to 0 when the
adapter doesn't override ``delete_message`` so non-supporting platforms degrade to normal
sends."""
if not isinstance(response, EphemeralReply):
return response, 0
ttl = response.ttl_seconds
if ttl is None:
ttl = _or_default(lambda: int(self._get_ephemeral_system_ttl_default()), 0, Exception)
if ttl and ttl > 0 and type(self).delete_message is BasePlatformAdapter.delete_message:
ttl = 0
return response.text, int(ttl or 0)
async def _dispatch_inline_reply(self, event: MessageEvent, *, log_cmd: Optional[str] = None) -> None:
"""Call the handler and send its reply inline, with retry, threading and
ephemeral deletion — no session lifecycle (active-session bypass paths)."""
thread_meta = _thread_metadata_for_event(event)
response = await self._message_handler(event)
text, eph_ttl = self._unwrap_ephemeral(response)
if not text:
return
if log_cmd is not None:
logger.info("[%s] Sending command '/%s' response (%d chars) to %s", self.name, log_cmd,
len(text), event.source.chat_id)
result = await self._send_with_retry(
chat_id=event.source.chat_id, content=text, reply_to=_reply_anchor_for_event(event),
metadata=_mark_notify_metadata(thread_meta))
if eph_ttl > 0 and result.success and result.message_id:
self._schedule_ephemeral_delete(event.source.chat_id, result.message_id, eph_ttl)
def _media_delivery_scope(self, source: Optional[SessionSource]):
"""Routed home + terminal policy for post-handler text, media and error delivery;
a no-op without a runner or outside multiplexing."""
resolve = getattr(self.gateway_runner, "_media_delivery_scope_for_source", None)
if not callable(resolve) or source is None:
return contextlib.nullcontext()
try:
return resolve(source)
except Exception:
logger.debug("[%s] Failed to resolve media delivery scope", self.name, exc_info=True)
return contextlib.nullcontext()
def _final_delivery_adapter(self, source: Optional[SessionSource]) -> "BasePlatformAdapter":
"""The runner's CURRENT adapter for a new final-response send: a reconnect can swap the
registry adapter mid-task; an unsent final response belongs on the replacement transport,
while message IDs, edits and deletes stay owned by the old one (nothing is migrated)."""
resolve = getattr(self.gateway_runner, "_delivery_adapter_for", None)
if not callable(resolve):
return self
try:
live_adapter = resolve(source)
except Exception:
logger.debug("[%s] Failed to resolve live adapter for final delivery", self.name)
return self
if isinstance(live_adapter, BasePlatformAdapter) and live_adapter.platform == self.platform:
return live_adapter
return self
async def _send_with_retry(
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Any = None,
max_retries: int = 2, base_delay: float = 2.0) -> "SendResult":
"""Send with exponential-backoff retry on transient network errors; permanent
failures fall back to a plain-text send, exhausted retries notify the user."""
async def _send(text: str) -> "SendResult":
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
async def _send_again(previous: "SendResult") -> "Optional[SendResult]":
"""Retry: the whole payload normally; only the undelivered remainder after a partial split
delivery (``raw_response["partial_overflow"]``). ``None`` when the adapter cannot resume — the
caller then keeps the partial failure rather than re-sending the already-visible head."""
if not self._is_partial_delivery(previous):
return await _send(content)
return await self._resume_partial_send(chat_id, previous, reply_to=reply_to, metadata=metadata)
result = await _send(content)
if result.success or self._send_retry_is_final(result):
return result
error_str = result.error or ""
# A rate-limited / flood-capped send is transient: it should back off
# (honoring the server's retry_after when present) rather than fall
# through to the plain-text fallback, which re-enters the ban and can
# truncate content. Gate on the platform-neutral classifier as well so
# platforms that surface a rate limit without a retry_after field
# (e.g. Weixin raising a bare RuntimeError) get the same treatment.
is_rate_limited = self._is_rate_limited_error(error_str)
is_network = (
result.retryable
or is_rate_limited
or result.retry_after is not None
or self._is_retryable_error(error_str)
)
# Timeouts: not safe to retry (may have delivered) and not a formatting error.
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
# A server-requested retry_after (Telegram FloodWait) overrides backoff, once per send.
server_retry_after = result.retry_after
for attempt in range(1, max_retries + 1):
backoff = server_retry_after
if backoff is None:
backoff = base_delay * (2 ** (attempt - 1))
elif backoff > _SEND_RETRY_INLINE_WAIT_CAP_SECS:
# Never hold this coroutine open for a long server penalty: a 97-minute
# FloodWait slept verbatim once froze inbound on every platform (#91969).
# Return the typed failure; the delivery ledger redelivers after the cooldown.
logger.error(
"[%s] Server asked to retry after %.0fs (> %.0fs inline cap); returning "
"typed failure for redelivery instead of sleeping: %s",
self.name, backoff, _SEND_RETRY_INLINE_WAIT_CAP_SECS, error_str,
)
return result
delay = backoff + random.uniform(0, 1)
server_retry_after = None
logger.warning("[%s] Send failed (attempt %d/%d, retrying in %.1fs): %s", self.name,
attempt, max_retries, delay, error_str)
await asyncio.sleep(delay)
resumed = await _send_again(result)
if resumed is None:
logger.warning(
"[%s] Split send partly delivered and the remainder cannot be resumed safely; "
"not re-sending the whole payload (would duplicate the visible head): %s", self.name, error_str)
return result
result = resumed
if result.success:
logger.info("[%s] Send succeeded on retry %d", self.name, attempt)
return result
error_str = result.error or ""
if self._send_retry_is_final(result):
return result
if result.retry_after is not None:
server_retry_after = result.retry_after
# The failure kind can change between attempts (a transient error may
# later surface as a flood/rate-limit, or a rate-limited send may give
# way to a permanent formatting error). Reclassify from the refreshed
# error_str on every attempt so the break/continue decision below
# reflects the current attempt, not a stale first-send classification.
is_rate_limited = self._is_rate_limited_error(error_str)
if not (
result.retryable
or is_rate_limited
or result.retry_after is not None
or self._is_retryable_error(error_str)
):
break # error switched to non-transient — fall through to plain-text fallback
else:
# All retries exhausted (loop completed without break) — notify user.
# If the final failure is a rate-limit / still carries a server
# retry_after, do NOT send the delivery-failure notice now: the notice
# send would land inside the same flood penalty and re-enter the ban
# (a fourth send at t=378 in an [0, 189, 378, 378] sequence). Return
# the typed failure so the delivery ledger owns redelivery after the
# cooldown instead — no extra sleep or request needed.
if self._is_rate_limited_error(error_str) or result.retry_after is not None:
logger.error(
"[%s] Rate-limited send exhausted retries; returning typed failure "
"for redelivery (no notice sent inside active flood penalty): %s",
self.name, error_str,
)
return result
logger.error("[%s] Failed to deliver response after %d retries: %s", self.name, max_retries, error_str)
# Not a diagnostic: the requested result itself was lost and this is its only signal.
notice = (
"\u26a0\ufe0f Message delivery failed after multiple attempts. "
"Please try again \u2014 your request was processed but the response could not be sent.")
try:
await _send(notice)
except Exception as notify_err:
logger.debug("[%s] Could not send delivery-failure notice: %s", self.name, notify_err)
return result
# Non-network / post-retry formatting failure: try plain text as fallback. A
# rate-limited error never reaches here: it classifies as network above and the
# loop only breaks on a non-transient, non-rate-limited error.
if self._is_partial_delivery(result):
# Part of a split payload is already on screen; a plain-text re-send of the whole would duplicate it.
logger.warning("[%s] Send failed after partial delivery: %s — not re-sending as plain text", self.name, error_str)
return result
logger.warning("[%s] Send failed: %s — trying plain-text fallback", self.name, error_str)
fallback_result = await self._send_plain_fallback(chat_id, content, reply_to=reply_to, metadata=metadata)
if not fallback_result.success:
logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error)
return fallback_result
def _send_retry_is_final(self, result: "SendResult") -> bool:
"""True when a failed send must be returned as-is: neither a retry nor the plain-text
fallback can fix it (a structured auth/target refusal). Default: never."""
return False
@staticmethod
def _is_partial_delivery(result: "SendResult") -> bool:
"""True when a split payload was PARTLY delivered (``raw_response["partial_overflow"]``, the
contract Telegram's send/edit-overflow paths set and the stream consumer reads): the visible
head must never be sent again."""
raw = getattr(result, "raw_response", None)
return isinstance(raw, dict) and bool(raw.get("partial_overflow"))
async def _resume_partial_send(
self, chat_id: str, result: "SendResult", *, reply_to: Optional[str], metadata: Any) -> "Optional[SendResult]":
"""Deliver only the remainder of a partially delivered split payload. ``None`` (the default) means
this adapter cannot resume; ``_send_with_retry`` then returns the partial failure instead of
re-sending the whole payload. Override only where non-delivery of the remainder is CERTAIN."""
return None
async def _send_plain_fallback(
self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> "SendResult":
"""Last-resort send after a non-transient failure; platforms whose markup is not the
likely culprit override it (Photon drops rich links instead of adding the banner)."""
return await self.send(
chat_id=chat_id, content=self.warning_text(
f"(Response formatting failed, plain text:)\n\n{content[:3500]}", content[:3500],
chat_id=chat_id, metadata=metadata),
reply_to=reply_to, metadata=metadata)
@staticmethod
def _merge_caption(existing_text: Optional[str], new_text: str) -> str:
"""Merge a new caption into existing text unless an identical (stripped) caption already
exists — exact match per caption, not substring ("Meeting" isn't swallowed by "Meeting
agenda")."""
if not existing_text:
return new_text
if new_text.strip() in [c.strip() for c in existing_text.split("\n\n")]:
return existing_text
return f"{existing_text}\n\n{new_text}".strip()
def _text_debounce_store(self) -> dict[str, TextDebounceState]:
return _lazy_attr(self, "_text_debounce", dict)
def _is_queue_text_debounce_candidate(self, event: MessageEvent) -> bool:
"""Return True for normal text eligible for queue-mode debounce."""
result = (
getattr(self, "_busy_text_mode", "interrupt") == "queue"
and event.message_type == MessageType.TEXT and not getattr(event, "internal", False)
and not event.is_command() and bool((event.text or "").strip()))
if result:
logger.debug("[%s] Queue-text debounce candidate accepted: session=%s text_len=%d",
self.name, getattr(event, "session_key", "?"), len(event.text or ""))
return result
def _can_merge_text_debounce_events(self, existing: MessageEvent, event: MessageEvent) -> bool:
"""Return True when two text debounce events came from the same sender."""
def _identity(candidate: MessageEvent) -> tuple[str, ...] | None:
source = getattr(candidate, "source", None)
if source is None:
return None
platform = _platform_name(getattr(source, "platform", None))
sender = getattr(source, "user_id_alt", None) or getattr(source, "user_id", None)
if sender:
return (platform, str(sender))
if getattr(source, "chat_type", None) in {"dm", "private"} and getattr(source, "chat_id", None):
return (platform, "dm", str(source.chat_id))
return None
existing_sender = _identity(existing)
return existing_sender is not None and existing_sender == _identity(event)
def _text_debounce_delay(self, session_key: str) -> float:
"""Return bounded busy-text debounce delay for ``session_key``."""
state = self._text_debounce_store().get(session_key)
if state is None:
return 0.0
deadline = min(state.last_ts + self._busy_text_debounce_seconds,
state.first_ts + self._busy_text_hard_cap_seconds)
return max(0.0, deadline - time.monotonic())
async def _queue_text_debounce(self, session_key: str, event: MessageEvent) -> None:
"""Buffer normal queue-mode busy text and schedule a bounded flush."""
store = self._text_debounce_store()
state = store.get(session_key)
if state is not None and not self._can_merge_text_debounce_events(state.event, event):
# Preserve sender attribution: flush the buffer as the next turn, new sender starts
# fresh.
await self._flush_text_debounce_now(session_key)
state = store.get(session_key)
if state is not None and not self._can_merge_text_debounce_events(state.event, event):
existing_pending = self._pending_messages.get(session_key)
if existing_pending is not None and self._can_merge_text_debounce_events(existing_pending, event):
merge_pending_message_event(self._pending_messages, session_key, event, merge_text=True)
return
now = time.monotonic()
if state is None:
state = TextDebounceState(event=event, task=None, first_ts=now, last_ts=now)
store[session_key] = state
else:
if event.text:
state.event.text = _append_text(state.event.text, event.text)
state.event.absorb_reply_expected(event)
latest_message_id = getattr(event, "message_id", None)
latest_anchor = latest_message_id or getattr(event, "reply_to_message_id", None)
if latest_message_id is not None:
state.event.message_id = str(latest_message_id)
if latest_anchor is not None and hasattr(state.event, "reply_to_message_id"):
state.event.reply_to_message_id = str(latest_anchor)
state.last_ts = now
state.cancel_timer()
delay = self._text_debounce_delay(session_key)
state.task = asyncio.create_task(self._flush_text_debounce(session_key, delay))
async def _flush_text_debounce(self, session_key: str, delay: float) -> None:
"""Timer task that flushes the debounced text buffer."""
try:
await asyncio.sleep(delay)
await self._flush_text_debounce_now(session_key)
except asyncio.CancelledError:
return
finally:
current = asyncio.current_task()
state = self._text_debounce_store().get(session_key)
if state is not None and state.task is current:
state.task = None
async def _flush_text_debounce_now(self, session_key: str) -> bool:
"""Force-flush one debounced busy-text burst into the pending slot."""
store = self._text_debounce_store()
state = store.get(session_key)
if state is None:
return False
state.cancel_timer(unless=asyncio.current_task())
state.task = None
pending = self._pending_messages.get(session_key)
if pending is not None and not self._can_merge_text_debounce_events(pending, state.event):
return False
store.pop(session_key, None)
merge_pending_message_event(self._pending_messages, session_key, state.event, merge_text=True)
return True
def _discard_text_debounce(self, session_key: str) -> None:
"""Cancel and drop pending text debounce state for control commands."""
state = self._text_debounce_store().pop(session_key, None)
if state is not None:
state.cancel_timer()
# ── Session task + guard ownership helpers: paired with the _session_tasks owner map so
# reconciliation is deterministic across completion, /stop /new /reset, and stale-lock heal.
def _release_session_guard(self, session_key: str, *, guard: Optional[asyncio.Event] = None) -> None:
"""Release the session guard; with ``guard`` given, only if the entry is still that exact
Event (an old task's unwind must not clear the guard a reset-like command swapped in)."""
current_guard = self._active_sessions.get(session_key)
if current_guard is None or (guard is not None and current_guard is not guard):
return
del self._active_sessions[session_key]
def _session_task_is_stale(self, session_key: str) -> bool:
"""True if the recorded owner task for ``session_key`` has exited. No owner task at all is
NOT stale (guards installed outside handle_message, as tests do, must not be healed)."""
done = getattr(self._session_tasks.get(session_key), "done", None)
return bool(done and done())
def _heal_stale_session_lock(self, session_key: str) -> bool:
"""Clear a stale session lock; True if healed. On-entry safety net: without it a split-brain
(guard held, nothing processing) traps the chat in "Interrupting..." until restart."""
if session_key not in self._active_sessions or not self._session_task_is_stale(session_key):
return False
logger.warning("[%s] Healing stale session lock for %s (owner task is done/absent)",
self.name, session_key)
self._active_sessions.pop(session_key, None)
self._pending_messages.pop(session_key, None)
self._requeue_counts.pop(session_key, None)
self._session_tasks.pop(session_key, None)
self._discard_text_debounce(session_key)
return True
def _start_session_processing(self, event: MessageEvent, session_key: str, *,
interrupt_event: Optional[asyncio.Event] = None) -> bool:
"""Spawn a background processing task under the session guard; True on success. If
``create_task`` is stubbed with a non-Task sentinel (tests), the guard is rolled back
(False)."""
guard = interrupt_event or asyncio.Event()
self._active_sessions[session_key] = guard
task = asyncio.create_task(self._process_message_background(event, session_key))
if not self._track_session_task(session_key, task):
self._session_tasks.pop(session_key, None)
self._release_session_guard(session_key, guard=guard)
return False
return True
def _track_session_task(self, session_key: str, task: Any) -> bool:
"""Record ``task`` as the session owner and track it for shutdown; False when
``create_task`` was stubbed with an unhashable sentinel (tests) — the owner entry is left
for the caller."""
self._session_tasks[session_key] = task
try:
self._background_tasks.add(task)
except TypeError:
return False
if hasattr(task, "add_done_callback"):
task.add_done_callback(self._background_tasks.discard)
task.add_done_callback(self._expected_cancelled_tasks.discard)
return True
async def cancel_session_processing(self, session_key: str, *, release_guard: bool = True,
discard_pending: bool = True) -> None:
"""Cancel in-flight processing for one session. ``release_guard=False`` keeps the guard so
reset-like commands finish atomically; the await is bounded (5s) so a wedged finally can't
stall."""
self._requeue_counts.pop(session_key, None)
task = self._session_tasks.pop(session_key, None)
if task is not None and not task.done():
logger.debug("[%s] Cancelling active processing for session %s", self.name, session_key)
self._expected_cancelled_tasks.add(task)
task.cancel()
try:
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
except asyncio.CancelledError:
pass
except asyncio.TimeoutError:
logger.warning("[%s] Cancelled task for %s did not exit within 5s; "
"unblocking dispatch and letting the task unwind in the background",
self.name, session_key)
except Exception:
logger.debug("[%s] Session cancellation raised while unwinding %s", self.name,
session_key, exc_info=True)
if discard_pending:
self._pending_messages.pop(session_key, None)
self._discard_text_debounce(session_key)
if release_guard:
self._release_session_guard(session_key)
async def _drain_pending_after_session_command(
self, session_key: str, command_guard: asyncio.Event) -> None:
"""Tail of /stop, /new, /reset: release the command-scoped guard, then
spawn a fresh processing task for any follow-up queued meanwhile."""
await self._flush_text_debounce_now(session_key)
pending_event = self._pending_messages.pop(session_key, None)
self._release_session_guard(session_key, guard=command_guard)
if pending_event is not None:
self._start_session_processing(pending_event, session_key)
async def _dispatch_active_session_command(self, event: MessageEvent, session_key: str, cmd: str) -> None:
"""Dispatch a reset-like bypass command (/stop, /new, /reset): keep a guard installed while
the runner handles it (follow-ups stay queued), cancel the old task AFTER the response,
drain once."""
logger.debug("[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key)
current_guard = self._active_sessions.get(session_key)
command_guard = asyncio.Event()
self._active_sessions[session_key] = command_guard
try:
# Send BEFORE cancelling so cancellation side effects can't drop the "/new"
# confirmation.
await self._dispatch_inline_reply(event, log_cmd=cmd)
await self.cancel_session_processing(session_key, release_guard=False, discard_pending=False)
except Exception:
# On failure restore the original guard so the session isn't left half-reset.
if self._active_sessions.get(session_key) is command_guard:
if session_key in self._session_tasks and current_guard is not None:
self._active_sessions[session_key] = current_guard
else:
self._release_session_guard(session_key, guard=command_guard)
raise
await self._drain_pending_after_session_command(session_key, command_guard)
async def handle_message(self, event: MessageEvent) -> None:
"""Process an incoming message; returns quickly by spawning a background
task so new messages (and interrupts) can arrive while an agent runs."""
event._gateway_accepted = False
if not self._message_handler:
# No handler = every inbound silently discarded on an adapter that still polls and sends;
# say so once per adapter (#102260).
if not getattr(self, "_no_message_handler_logged", False):
self._no_message_handler_logged = True
logger.error(
"[%s] Dropping inbound message: no gateway message handler "
"is installed on this adapter. The adapter is connected and "
"can send, but every inbound message is discarded.",
self.name,
)
return
if event.allow_gateway_control:
coerce_plaintext_gateway_command(event)
# Identity FIRST: every key below (routing check, guard lookup, batch lane) derives from it.
if self._drop_unresolved(event):
return
expected_session_key = str((event.metadata or {}).get("gateway_session_key") or "").strip()
# Explicitly routed events already name their destination; recovering a
# different topic would redirect them and yield before the session claim.
if (not expected_session_key and getattr(self, "_topic_recovery_fn", None) is not None
and event.source.platform == Platform.TELEGRAM and event.source.chat_type == "dm"):
await asyncio.to_thread(self._apply_topic_recovery, event)
session_key = self._event_session_key(event)
if expected_session_key and session_key != expected_session_key:
logger.warning("Dropping internally routed event: expected session=%s derived=%s",
expected_session_key, session_key)
return
# On-entry self-heal: clear a guard whose owner task already exited.
if session_key in self._active_sessions:
self._heal_stale_session_lock(session_key)
if session_key in self._active_sessions:
await self._handle_message_while_active(event, session_key)
return
# Guard installed synchronously BEFORE the task spawns so a second message can't race in.
event._gateway_accepted = self._start_session_processing(event, session_key)
async def _handle_message_while_active(self, event: MessageEvent, session_key: str) -> None:
"""Route a message that arrived while ``session_key`` is busy: bypass
commands / clarify replies dispatch inline, everything else is queued."""
# Bypass commands run inline: queued they'd leak as user text (/new) or deadlock
# (/approve, /deny — the agent is blocked on Event.wait). Dispatch inline by
# calling the message handler directly and sending the response. Do NOT use
# _process_message_background — it manages session lifecycle and its cleanup
# races with the running task (split-brain, see PR #4926).
# Certain commands must bypass the active-session guard and be dispatched directly to the gateway
# runner. Without this, they are queued as pending messages and either: See #4926.
self._canonicalize(event.source) # identity FIRST (direct callers may skip handle_message)
cmd = event.get_command()
from hermes_cli.commands import (is_interrupt_then_dispatch, should_bypass_active_session)
if should_bypass_active_session(cmd):
try:
# /stop, /new, /reset: cancel + response + drain; other bypasses don't cancel.
if cmd and is_interrupt_then_dispatch(cmd):
self._discard_text_debounce(session_key)
await self._dispatch_active_session_command(event, session_key, cmd)
else:
logger.debug("[%s] Command '/%s' bypassing active-session guard for %s",
self.name, cmd, session_key)
await self._dispatch_inline_reply(event)
except Exception as e:
logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return
# Clarify bypass: while blocked on clarify_tool the next message must reach the
# text-intercept so numeric/exact/"Other" answers resolve it and unblock the agent.
# Otherwise it lands in _pending_messages as a follow-up turn and the answer is
# discarded. Same shape as the /approve deadlock fix (PR #4926): agent thread
# blocked on Event.wait, message must reach the resolver before being a new turn.
# See #4926.
if not cmd and event.allow_gateway_control:
try:
from tools import clarify_gateway as _clarify_mod
_has_text_clarify = _clarify_mod.get_pending_for_session(
session_key, include_choice_prompts=True) is not None
except Exception:
_has_text_clarify = False
if _has_text_clarify:
logger.debug("[%s] Routing message to clarify text-intercept for %s", self.name, session_key)
try:
await self._dispatch_inline_reply(event)
except Exception as e:
logger.error("[%s] Clarify text-intercept dispatch failed: %s", self.name, e, exc_info=True)
return
if self._busy_session_handler is not None:
try:
handled = await self._busy_session_handler(event, session_key)
except Exception as e:
logger.error("[%s] Busy-session handler failed: %s", self.name, e, exc_info=True)
# It may have stored the event before raising: queuing or starting it again below
# would run it twice.
handled = event._gateway_accepted is True
# The handler awaits (profile scope load, compression-lock read). If the owner task
# finished meanwhile, it found the slot empty and released the guard, so nothing would
# drain what the handler queued: start that now. If the handler left this event to the
# base path instead (returned False, or raised before storing it) and nothing is
# queued, start this event.
if session_key not in self._active_sessions:
orphan = self._pending_messages.pop(session_key, None)
if orphan is not None:
self._start_session_processing(orphan, session_key)
elif not handled:
event._gateway_accepted = self._start_session_processing(event, session_key)
return
if handled:
return
# Without a runner FIFO, do not merge a wake into an occupied human slot
# (or collapse distinct wakes into one turn). Its caller can retry admission.
if event.internal and session_key in self._pending_messages:
return
# Photo bursts/albums: queue without interrupting; they run after the current task.
if event.message_type == MessageType.PHOTO:
logger.debug("[%s] Queuing photo follow-up for session %s without interrupt", self.name, session_key)
merge_pending_message_event(self._pending_messages, session_key, event)
event._gateway_accepted = True
return
if self._is_queue_text_debounce_candidate(event):
logger.debug("[%s] New text message while session %s is active — "
"debouncing follow-up (busy_text_mode=queue, window=%.2fs)", self.name,
session_key, self._busy_text_debounce_seconds)
await self._queue_text_debounce(session_key, event)
else:
logger.debug("[%s] New message while session %s is active — queuing follow-up "
"(no interrupt, will cascade after current turn)", self.name, session_key)
merge_pending_message_event(self._pending_messages, session_key, event,
merge_text=event.message_type == MessageType.TEXT)
event._gateway_accepted = True
def _get_human_delay(self) -> float:
"""Random human-like pacing delay (s) from this adapter's ``human_delay`` config range
(ms), installed per profile by the runner (``_wire_adapter_handlers``); ``None`` = off."""
bounds = self._human_delay_range_ms
if not bounds:
return 0.0
lo, hi = bounds
return random.uniform(lo / 1000.0, hi / 1000.0)
async def _synthesize_auto_tts(self, text_content: str) -> Tuple[List[str], Optional[str]]:
"""Synthesize auto-TTS audio -> ``(existing_paths, requested_path)``; empty/None on failure
(logged, never raised). Path built platform-aware HERE: HERMES_SESSION_PLATFORM is cleared
post-handler."""
paths: List[str] = []
requested_path = None
try:
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
import json as _json
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
raise ValueError("Empty text after markdown cleanup")
requested_path = build_auto_tts_output_path(self.platform)
tts_data = _json.loads(await asyncio.to_thread(
text_to_speech_tool, text=speech_text, output_path=requested_path))
if tts_data.get("success", True):
raw_tts_paths = tts_data.get("file_paths") or [tts_data.get("file_path")]
paths = [str(path) for path in raw_tts_paths if path and Path(path).exists()]
except Exception as tts_err:
logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err)
return paths, requested_path
def _wants_auto_tts(self, event: MessageEvent, session_key: str, interrupt_event: asyncio.Event,
text_content: str, media_files: list) -> bool:
"""Auto-TTS on voice input (voice-first), gated by /voice or voice.auto_tts;
skipped when streaming TTS already delivered audio this turn."""
generation = getattr(interrupt_event, "_hermes_run_generation", None)
return bool(
self._should_auto_tts_for_chat(event.source.chat_id)
and event.message_type == MessageType.VOICE and text_content and not media_files
and not self._streaming_tts_turn_completed(session_key, generation, event=event))
async def _play_tts_file(
self, event: MessageEvent, text_content: str, tts_path: str, first: bool,
metadata: Dict[str, Any], record_delivery: Callable) -> bool:
"""Play one synthesized TTS file. Returns True when the ORIGINAL reply text rode
along as a Telegram caption (first file, ≤1024 chars) so the text send is skipped."""
caption = None
if first and self.platform == Platform.TELEGRAM and text_content and text_content[:1024] == text_content:
caption = text_content
tts_result = await self.play_tts(
chat_id=event.source.chat_id, audio_path=tts_path, caption=caption, metadata=metadata)
record_delivery(tts_result)
return bool(caption and getattr(tts_result, "success", False))
async def _record_delivery_obligation(
self, event: MessageEvent, session_key: str, text_content: str,
delivery_adapter: "BasePlatformAdapter", is_ephemeral_response: bool) -> Optional[str]:
"""Ledger the final response BEFORE the send so a crash before platform ACK redelivers on
next boot; best-effort, skips slash-command and ephemeral replies. Returns the obligation id
or None."""
if is_ephemeral_response or str(event.text or "").lstrip().startswith(
("/", self.typed_command_prefix or "!")):
return None
try:
from gateway.delivery_ledger import (
compute_obligation_id, ledger_enabled, mark_attempting, record_obligation)
if not await asyncio.to_thread(ledger_enabled):
return None
source = event.source
# ``ledger_message_id`` wins when set: a queued chain's final answers the last message
# of the chain, not the event that opened it (see ``MessageEvent.ledger_message_id``).
_ledger_id = getattr(event, "ledger_message_id", None)
if _ledger_id is None:
_ledger_id = getattr(event, "message_id", "")
obligation_id = compute_obligation_id(
session_key, str(_ledger_id or ""), text_content)
await asyncio.to_thread(
record_obligation, obligation_id=obligation_id, session_key=session_key,
platform=str(getattr(source.platform, "value", source.platform)),
chat_id=source.chat_id, thread_id=getattr(source, "thread_id", None),
content=text_content,
adapter_profile=getattr(delivery_adapter, "_owner_profile", None))
await asyncio.to_thread(mark_attempting, obligation_id)
return obligation_id
except Exception:
logger.debug("delivery ledger record failed", exc_info=True)
return None
async def _finalize_delivery_obligation(
self, obligation_id: str, result: Any, event: MessageEvent,
delivery_adapter: "BasePlatformAdapter") -> None:
"""Mark the ledger row delivered/failed (best-effort). On ``send_path_degraded`` with a
replacement adapter live, trigger another redelivery sweep (the watcher's may have run
before this failure landed; atomic claiming keeps it idempotent). On any other rejection arm
the runner's timed redelivery, so the reply goes out once the flood penalty or the retry
backoff has passed instead of waiting for the next restart (#91653)."""
try:
from gateway.dead_targets import classify_dead_error
from gateway.delivery_ledger import is_reconnect_only, mark_delivered, mark_failed
if getattr(result, "success", False):
await asyncio.to_thread(mark_delivered, obligation_id)
return
error = str(getattr(result, "error", "") or "")
await asyncio.to_thread(mark_failed, obligation_id, error)
if is_reconnect_only(error):
redeliver = getattr(
self.gateway_runner, "_redeliver_failed_obligations_for_platform", None)
live = self._final_delivery_adapter(event.source)
if live is not delivery_adapter and callable(redeliver):
await redeliver(event.source.platform,
profile=getattr(delivery_adapter, "_owner_profile", None))
elif classify_dead_error(error) is None: # a dead chat is never retried: no timer to wake
schedule = getattr(self.gateway_runner, "_schedule_flood_redelivery", None)
if callable(schedule):
schedule(event.source.platform,
profile=getattr(delivery_adapter, "_owner_profile", None))
except Exception:
logger.debug("delivery ledger update failed", exc_info=True)
async def _deliver_media_attachments(
self, event: MessageEvent, media_files: list, local_files: list, *,
force_document_attachments: bool, human_delay: float, metadata: Dict[str, Any],
record_delivery: Callable) -> None:
"""Deliver MEDIA-tag files and detected local files by type: images batched via
``send_multiple_images`` unless ``[[as_document]]``; otherwise audio → send_voice (MEDIA
tags only, never bare local files), video → send_video, else send_document. Every failure is
reported. Each send feeds ``record_delivery`` so media-only turns report SUCCESS."""
from urllib.parse import quote as _quote
def _as_image(path: str) -> bool:
return Path(path).suffix.lower() in _IMAGE_EXTS and not force_document_attachments
_image_paths = [p for p, is_voice in media_files if not is_voice and _as_image(p)]
_image_paths += [p for p in local_files if _as_image(p)]
if _image_paths:
await self._send_image_batch(
event, [(f"file://{_quote(p)}", "") for p in _image_paths], metadata, human_delay,
record_delivery)
chat_id = event.source.chat_id
async def _send_one(path: str, *, is_voice: bool, media_tag: bool) -> SendResult:
"""MEDIA-tag files (``media_tag``) may route to send_voice; bare local files never
do."""
ext = Path(path).suffix.lower()
if media_tag and should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
result = await self.send_voice(chat_id=chat_id, audio_path=path, metadata=metadata, is_voice=is_voice)
elif ext in _VIDEO_EXTS:
if media_tag:
logger.info("[%s] Sending video attachment (%s) to %s", self.name, ext, chat_id)
result = await self.send_video(chat_id=chat_id, video_path=path, metadata=metadata)
else:
result = await self.send_document(chat_id=chat_id, file_path=path, metadata=metadata)
if not result.success:
logger.warning("[%s] Failed to send %s (%s): %s", self.name,
"media" if media_tag else "local file", ext, result.error)
await self._notify_media_delivery_failure(chat_id, path, is_voice=is_voice, metadata=metadata)
return result
queue = [(p, v, True) for p, v in media_files if v or not _as_image(p)]
if queue:
logger.info("[%s] Delivering %d non-image MEDIA attachment(s)", self.name, len(queue))
queue += [(p, False, False) for p in local_files if not _as_image(p)]
for path, is_voice, media_tag in queue:
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
record_delivery(await _send_one(path, is_voice=is_voice, media_tag=media_tag))
except Exception as err:
record_delivery(SendResult(success=False, error=str(err)))
if media_tag:
logger.warning("[%s] Error sending media: %s", self.name, err)
else:
logger.error("[%s] Error sending local file %s: %s", self.name, path, err)
async def _send_image_batch(
self, event: MessageEvent, images: list, metadata: Dict[str, Any], human_delay: float,
record_delivery: Callable) -> None:
"""Batch-send images; a failure is logged (never raised) so other attachments still go.
The batch result feeds ``record_delivery`` so media-only turns report their real
outcome instead of FAILURE."""
try:
result = await self.send_multiple_images(
chat_id=event.source.chat_id, images=images, metadata=metadata, human_delay=human_delay)
except Exception as batch_err:
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
record_delivery(SendResult(success=False, error=str(batch_err)))
return
record_delivery(result)
async def send_final_ledgered(
self, event: MessageEvent, session_key: str, text_content: str, metadata: Dict[str, Any], *,
reply_to: Optional[str], is_ephemeral_response: bool = False,
) -> "tuple[SendResult, BasePlatformAdapter]":
"""The delivery-ledger bracket every final text goes through, on the CURRENT transport
(a reconnect may have replaced this adapter): record the obligation before the send,
send with retry, finalize from the result — so a refused final (flood control, a dead
transport) leaves a ledger row the boot sweep / runtime redelivery can act on. ``event``
supplies the source and the ledger identity (``ledger_message_id`` or ``message_id``).
Returns the result with the adapter that sent it: that adapter owns ``result.message_id``
(an ephemeral delete must go to the same transport)."""
delivery_adapter = self._final_delivery_adapter(event.source)
logger.info("[%s] Sending response (%d chars) to %s", delivery_adapter.name,
len(text_content), event.source.chat_id)
obligation_id = await self._record_delivery_obligation(
event, session_key, text_content, delivery_adapter, is_ephemeral_response)
if obligation_id is not None:
await self._release_turn_marker(event) # the ledger now owns the crash recovery
result = await delivery_adapter._send_with_retry(
chat_id=event.source.chat_id, content=text_content, reply_to=reply_to, metadata=metadata)
if obligation_id is not None:
await self._finalize_delivery_obligation(obligation_id, result, event, delivery_adapter)
return result, delivery_adapter
async def _release_turn_marker(self, event: MessageEvent) -> None:
"""Clear the crash-recovery marker the runner handed to this delivery lifecycle
(``_turn_marker_handoff``): only once the final reply is ledgered or nothing more is owed,
so no kill leaves a persisted reply with neither marker nor ledger row. Idempotent."""
if getattr(event, "_turn_marker_handoff", False) and getattr(event, "_gateway_active_turn_token", None):
await self.gateway_runner._clear_durable_active_turn(event)
async def _send_final_text(
self, event: MessageEvent, session_key: str, text_content: str, metadata: Dict[str, Any],
is_ephemeral_response: bool, ephemeral_ttl: int, record_delivery: Callable) -> None:
"""Normal-lane final: the ledger bracket plus the message-id owner's ephemeral delete."""
result, delivery_adapter = await self.send_final_ledgered(
event, session_key, text_content, metadata,
reply_to=_reply_anchor_for_event(event), is_ephemeral_response=is_ephemeral_response)
record_delivery(result)
if ephemeral_ttl and ephemeral_ttl > 0 and result.success and result.message_id:
delivery_adapter._schedule_ephemeral_delete(event.source.chat_id, result.message_id, ephemeral_ttl)
async def _notify_turn_error(self, event: MessageEvent, e: BaseException) -> Optional[dict]:
"""Tell the user a turn failed rather than leaving radio silence (last resort:
a failing notice is logged, never raised). Returns the thread metadata used."""
_thread_metadata = None
try:
_thread_metadata = _thread_metadata_for_event(event)
error_detail = str(e)[:300] if str(e) else "no details available"
# Only the policy reads bind the routed profile; the send stays in the launch scope
# as before, so delivery bookkeeping keeps landing where boot-time recovery reads it.
with self._media_delivery_scope(event.source):
content = None if diagnostic_wake_muted(event) else self.warning_text(
f"Sorry, I encountered an error ({type(e).__name__}).\n{error_detail}\n"
"Try again or use /reset to start a fresh session.",
"Sorry, I encountered an error.",
logical_platform=event.source.platform, chat_id=event.source.chat_id, metadata=_thread_metadata)
if content is None:
return _thread_metadata
await self.send(chat_id=event.source.chat_id, content=content, metadata=_thread_metadata)
except Exception as notify_err:
logger.error(
"[%s] Failed to send error notification to user: %s", self.name, notify_err, exc_info=True)
return _thread_metadata
async def _deliver_attachments(self, event: MessageEvent, extracted: "_ExtractedResponse",
metadata: Dict[str, Any], *, anything_sent: bool,
record_delivery: Callable) -> None:
"""Send extracted image URLs, MEDIA files and bare local files (human-paced),
then fail loudly if a non-empty response produced nothing deliverable. Attachment
results feed ``record_delivery`` so the turn outcome reflects them."""
human_delay = self._get_human_delay()
images, media_files, local_files = extracted.images, extracted.media_files, extracted.local_files
if images:
logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
await self._send_image_batch(event, images, metadata, human_delay, record_delivery)
await self._deliver_media_attachments(
event, media_files, local_files,
force_document_attachments=extracted.force_document_attachments,
human_delay=human_delay, metadata=metadata, record_delivery=record_delivery)
if not (anything_sent or images or local_files or media_files) and extracted.pre_extract.strip():
logger.error("[%s] response_delivery_dropped: non-empty response "
"(%d chars) produced no delivered message or attachment "
"for %s (empty after extract, recovery yielded nothing).", self.name,
len(extracted.pre_extract), event.source.chat_id)
def _start_typing_refresh(self, event: MessageEvent, interrupt_event: asyncio.Event,
metadata: Optional[dict]) -> Optional[asyncio.Task]:
"""Spawn the typing-refresh task, or None when ``typing_indicator=False``.
``stop_event`` is passed only when the (possibly overridden) ``_keep_typing`` accepts it."""
# A scheduled heartbeat is proactive work: no typing indicator until it has something to say.
if not getattr(self.config, "typing_indicator", True) or getattr(event, "_heartbeat_session_id", None):
return None
kwargs: Dict[str, Any] = {"metadata": metadata}
if self._accepts_kwarg(self._keep_typing, "stop_event", var_kw=False, unknown=True):
kwargs["stop_event"] = interrupt_event
return asyncio.create_task(self._keep_typing(event.source.chat_id, **kwargs))
async def _extract_response_content(self, response: str, event: MessageEvent, session_key: str,
*, is_ephemeral_response: bool) -> "_ExtractedResponse":
"""Split a handler response into deliverable text + attachments. Order matters: MEDIA tags →
image URLs → residual directives → bare local paths (skipped for ephemeral notices so config
paths stay text; unknown-extension MEDIA tags survive for the bare-path detector). History
dedup is bare-path only, off-loop, fail-open. An emptied non-empty response is recovered."""
# Captured before extract_media strips it: images then go via send_document (no recompression).
force_document = "[[as_document]]" in response
pre_extract = response
# The handler's routed profile scope is gone by now; Docker MEDIA translation and the
# bare-path validator infer the sandbox from the ACTIVE profile (#109024).
with self._media_delivery_scope(event.source):
media_files, response = self.extract_media(response)
media_files = self.filter_media_delivery_paths(media_files, session_key=session_key)
images, text_content = self.extract_images(response)
# Strip any remaining internal directives from message body (fixes #1561). _strip_media_directives
# shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag with an unknown extension is intentionally left in
# the body for extract_local_files below to pick up rather than silently dropped (#34517).
text_content = _strip_media_directives(text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
local_files = []
if not is_ephemeral_response:
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files, session_key=session_key)
history = (await self._bounded_history_media_paths_for_session(session_key)
if local_files else None)
if history:
suppressed = [p for p in local_files if p in history]
if suppressed:
logger.info("[%s] Suppressing %d bare local file path(s) already delivered in "
"this session: %s", self.name, len(suppressed), suppressed)
local_files = [p for p in local_files if p not in history]
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# A2 (#29346): extraction can reduce a non-empty response to empty text with no attachment, and the
# `if text_content` guard below then drops it silently. Recover on every platform (#33842 was
# Discord-only); the guard avoids duplicating an attachment.
if not (text_content or images or local_files or media_files):
_recovered = _strip_media_directives(response).strip()
if _recovered:
logger.warning("[%s] response_delivery_recovered: extract pipeline "
"reduced a non-empty response (%d chars) to empty with "
"no attachment; delivering recovered original to %s", self.name,
len(pre_extract), event.source.chat_id)
text_content = _recovered
return _ExtractedResponse(
text_content=text_content, images=images, media_files=media_files,
local_files=local_files, force_document_attachments=force_document, pre_extract=pre_extract)
async def _fire_post_delivery_callback(self, session_key: str, interrupt_event: asyncio.Event) -> None:
"""Run the one-shot post-delivery callback (bounded, errors swallowed). The generation is
read HERE — stamped on the interrupt event DURING the handler await; an earlier snapshot
would let stale runs fire a fresher run's callbacks."""
_post_cb = self.pop_post_delivery_callback(
session_key, generation=getattr(interrupt_event, "_hermes_run_generation", None))
if callable(_post_cb):
with contextlib.suppress(asyncio.TimeoutError, Exception):
_post_result = _post_cb()
if inspect.isawaitable(_post_result):
await asyncio.wait_for(_post_result, timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS)
def _finish_session_task(self, session_key: str, interrupt_event: asyncio.Event) -> None:
"""End-of-task guard/ownership reconciliation. A late ``_pending_messages`` arrival must not
drop: re-queue it if another task already owns the session (drain handoff), else spawn the
drain task and leave it the guard. Nothing pending: release the guard only if we still own
it."""
late_pending = self._pending_messages.pop(session_key, None)
current_task = asyncio.current_task()
if late_pending is not None:
existing_task = self._session_tasks.get(session_key)
if existing_task is not None and existing_task is not current_task:
# The in-band drain (or an earlier late-arrival drain) already spawned a follow-up task that
# owns this session. Re-queue the late-arrival event so that task picks it up — avoids
# spawning two concurrent _process_message_background tasks for the same key (#17758
# follow-up: prevents the create_task path from racing with itself across the
# in-band/finally boundary).
self._pending_messages[session_key] = late_pending
else:
logger.debug(
"[%s] Late-arrival pending message during cleanup — spawning drain task",
self.name)
self._spawn_drain_task(late_pending, session_key)
elif current_task is not None and self._session_tasks.get(session_key) is current_task:
self._cleanup_finished_session_task(session_key, interrupt_event)
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
"""Background task that actually processes the message."""
delivery_attempted = delivery_succeeded = False # feeds the processing-complete hook
def _record_delivery(result):
nonlocal delivery_attempted, delivery_succeeded
if result is not None:
delivery_attempted = True
delivery_succeeded = delivery_succeeded or bool(getattr(result, "success", False))
# Reuse the interrupt event handle_message() installed; new Event only if removed externally.
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
self._active_sessions[session_key] = interrupt_event
_thread_metadata = _thread_metadata_for_event(event)
typing_task = self._start_typing_refresh(event, interrupt_event, _thread_metadata)
try:
await self._run_processing_hook("on_processing_start", event)
event._turn_marker_handoff = self.gateway_runner is not None # it can release the marker
response = await self._message_handler(event)
# A muted diagnostic wake ran for the session; its reply is not presented. The
# policy read binds the routed profile; delivery itself stays in the launch scope.
with self._media_delivery_scope(event.source):
if diagnostic_wake_muted(event):
response = None
is_ephemeral_response = isinstance(response, EphemeralReply)
# Unwrap EphemeralReply for downstream text processing; TTL applies after send.
response, _ephemeral_ttl = self._unwrap_ephemeral(response)
# None/empty is normal (streamed/queued). Suppress a stale response after an interrupt.
if response and interrupt_event.is_set() and session_key in self._pending_messages:
logger.info("[%s] Suppressing stale response for interrupted session %s", self.name,
session_key)
response = None
if not response:
logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
else:
extracted = await self._extract_response_content(
response, event, session_key, is_ephemeral_response=is_ephemeral_response)
text_content, media_files = extracted.text_content, extracted.media_files
# Final content gets notify=True; typing metadata stays unmarked (thread-strict).
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
_tts_paths, _tts_requested_path = [], None
if self._wants_auto_tts(
event, session_key, interrupt_event, text_content, media_files):
_tts_paths, _tts_requested_path = await self._synthesize_auto_tts(text_content)
# TTS plays before text; generated files are removed afterwards.
_tts_caption_delivered = False
for _tts_index, _tts_path in enumerate(_tts_paths):
try:
_tts_caption_delivered |= await self._play_tts_file(
event, text_content, _tts_path, _tts_index == 0, _final_thread_metadata,
_record_delivery)
finally:
with contextlib.suppress(OSError):
os.remove(_tts_path)
if not _tts_paths and _tts_requested_path is not None:
with contextlib.suppress(OSError):
os.remove(_tts_requested_path)
# Suspend the typing refresh before the first delivery attempt, not just in
# the turn's finally (#117300): if the final send stalls (platform accepted it
# but the HTTP ack never returns), control never reaches the finally, and
# _keep_typing keeps refreshing sendChatAction forever while the agent is
# already idle and the user can read the answer. Reuse the existing
# _typing_paused mechanism: _keep_typing skips paused chats each tick and
# _stop_typing_refresh's finally discards it, so it cannot leak into the next
# turn. No new await on the delivery path (a fire-and-forget stop task was
# measured to have no effect).
if text_content or extracted.images or extracted.media_files or extracted.local_files \
or _tts_paths or _tts_caption_delivered:
self.pause_typing_for_chat(event.source.chat_id)
if text_content and not _tts_caption_delivered:
await self._send_final_text(
event, session_key, text_content, _final_thread_metadata,
is_ephemeral_response, _ephemeral_ttl, _record_delivery)
await self._deliver_attachments(
event, extracted, _final_thread_metadata,
anything_sent=delivery_attempted or _tts_caption_delivered,
record_delivery=_record_delivery)
await self._release_turn_marker(event)
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
# Clean up the per-turn streaming-TTS flag.
self._streaming_tts_completed_turns.discard(self._streaming_tts_turn_key(
session_key, getattr(interrupt_event, "_hermes_run_generation", None),
event=event) or "")
await self._run_processing_hook(
"on_processing_complete", event,
ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE)
# Force-flush an unfired debounce timer so this task hands off to a fresh drain task.
# Clear the Event BEFORE the stop-typing await so concurrent inbound sees a live guard.
await self._flush_text_debounce_now(session_key)
if session_key in self._pending_messages:
pending_event = self._pending_messages[session_key]
delay = self._requeue_backoff_delay(session_key, pending_event, event)
if not delay: # a backed-off event stays queued until the drain task wakes
self._pending_messages.pop(session_key)
logger.debug("[%s] Processing queued follow-up message", self.name)
self._clear_session_guard(session_key)
await self._stop_typing_refresh(event.source.chat_id, typing_task, metadata=_thread_metadata)
self._spawn_drain_task(pending_event, session_key, delay=delay)
return # Drain task owns the session now.
except asyncio.CancelledError:
expected = asyncio.current_task() in self._expected_cancelled_tasks
await self._run_processing_hook(
"on_processing_complete", event,
ProcessingOutcome.CANCELLED if expected else ProcessingOutcome.FAILURE)
raise
except BaseException as e:
await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
_thread_metadata = (await self._notify_turn_error(event, e)) or _thread_metadata
# SystemExit/KeyboardInterrupt propagate; other BaseExceptions are contained.
if isinstance(e, (SystemExit, KeyboardInterrupt)):
raise
finally:
await self._release_turn_marker(event)
event._turn_marker_handoff = False # a later run of this object clears its own marker
# Stop typing BEFORE the post-delivery callback: a stuck callback must not keep it
# alive.
await self._stop_typing_refresh(event.source.chat_id, typing_task, metadata=_thread_metadata)
await self._fire_post_delivery_callback(session_key, interrupt_event)
# Callback work or a late refresh may have recreated typing — one final bounded stop.
await self._stop_typing_refresh(
event.source.chat_id, None, metadata=_thread_metadata, stop_attempts=1)
# Flush any timer that missed the in-band drain, then reconcile ownership.
await self._flush_text_debounce_now(session_key)
self._finish_session_task(session_key, interrupt_event)
_REQUEUE_BACKOFF_INITIAL_SECONDS = 0.25
# Kept at 1s: nothing wakes the back-off sleep, so a genuine message merged into the slot
# meanwhile waits out the remainder; 1 dispatch/s is still ~250x below the unbounded loop.
_REQUEUE_BACKOFF_MAX_SECONDS = 1.0
def _requeue_backoff_delay(self, session_key: str, pending_event: MessageEvent,
dispatched_event: MessageEvent) -> float:
"""Delay before re-dispatching the queued follow-up.
Only the event this task just dispatched coming straight back backs off: the same
``message_id``, or for an id-less event the same ``timestamp`` (rewrite-hook
``dataclasses.replace`` copies keep both; a genuine new message gets a fresh timestamp).
The handler put it back because the session is busy elsewhere, and re-dispatching it at
once hot-loops for the whole busy window (#123229). Any other follow-up resets the counter
and runs immediately. The first bounce stays immediate (restart auto-resume relies on one
self-bounce), then back off exponentially to a cap. Defers, never drops."""
# The identical object always matches too: its id equals itself, and when empty the
# timestamp comparison does.
same = (pending_event.message_id == dispatched_event.message_id
and (bool(pending_event.message_id)
or pending_event.timestamp == dispatched_event.timestamp))
if not same:
self._requeue_counts.pop(session_key, None)
return 0.0
attempts = self._requeue_counts.get(session_key, 0)
self._requeue_counts[session_key] = attempts + 1
if attempts == 0:
return 0.0
delay = jittered_backoff(attempts, base_delay=self._REQUEUE_BACKOFF_INITIAL_SECONDS,
max_delay=self._REQUEUE_BACKOFF_MAX_SECONDS, jitter_ratio=0.0)
(logger.info if attempts == 1 else logger.debug)(
"[%s] Handler re-queued a pending event for %s again (session busy elsewhere); "
"backing off %.2fs", self.name, session_key, delay)
return delay
def _spawn_drain_task(self, pending_event: MessageEvent, session_key: str,
delay: float = 0.0) -> None:
"""Hand the session to a fresh task for a queued follow-up — never recurse (chained
follow-ups grew the C stack to SIGSEGV). Clearing (not deleting) the Event keeps the guard
live for concurrent inbound; ownership moves so stale-lock detection works. With ``delay``
the event stays in ``_pending_messages`` and the new owner task pops the slot only after
sleeping, so a cancel/discard during the back-off needs no put-back and can't drop a
newer message."""
self._clear_session_guard(session_key)
# Capture the guard this drain owns now: a /stop//new guard swapped in during the
# back-off must survive the slot-empty exit (#48300).
guard = self._active_sessions.get(session_key)
self._track_session_task(
session_key,
asyncio.create_task(self._drain_after(pending_event, session_key, delay, guard)))
async def _drain_after(self, pending_event: MessageEvent, session_key: str, delay: float,
guard: Optional[asyncio.Event]) -> None:
if delay > 0:
await asyncio.sleep(delay)
await self._flush_text_debounce_now(session_key) # as every other task exit does
pending_event = self._pending_messages.pop(session_key, None)
if pending_event is None: # consumed elsewhere during the back-off
self._cleanup_finished_session_task(session_key, guard)
return
await self._process_message_background(pending_event, session_key)
def _clear_session_guard(self, session_key: str) -> None:
"""Clear (not delete) the session's interrupt Event so the guard stays live for inbound."""
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
def _cleanup_finished_session_task(
self, session_key: str, interrupt_event: Optional[asyncio.Event]) -> None:
"""Release a finished owner task's guard, dropping its ``_session_tasks`` entry ONLY if the
guard was released: after a concurrent guard swap the done-task entry lets
``_session_task_is_stale`` heal the orphan.
Release-then-conditional-delete is the #48300 fix: when a concurrent path (reset/new command, drain
handoff) swapped ``_active_sessions[key]`` to a different guard, ``_release_session_guard`` skips on
the guard mismatch and the lock stays installed. If we deleted ``_session_tasks`` unconditionally
(the old order), ``_session_task_is_stale`` would later see no owner task and report "not stale", so
the orphaned guard would never be healed — a permanent session deadlock. Keeping the done-task entry
when the guard survives lets the on-entry self-heal detect the stale lock and clear it on the next
inbound message.
"""
self._release_session_guard(session_key, guard=interrupt_event)
if session_key not in self._active_sessions:
self._session_tasks.pop(session_key, None)
self._requeue_counts.pop(session_key, None)
async def cancel_background_tasks(self) -> None:
"""Cancel in-flight background tasks (shutdown/replacement); 5s bound each,
stragglers are untracked and left to unwind."""
# Re-drain (max 5 rounds): a message arriving mid-gather spawns a task clear() would
# untrack.
for _ in range(5):
tasks = [task for task in self._background_tasks if not task.done()]
if not tasks:
break
for task in tasks:
self._expected_cancelled_tasks.add(task)
task.cancel()
try:
await asyncio.wait_for(
asyncio.gather(*(asyncio.shield(t) for t in tasks), return_exceptions=True),
timeout=5.0)
except asyncio.TimeoutError:
logger.warning("[%s] %d background task(s) did not exit within 5s; "
"releasing tracking and letting them unwind in the background",
self.name, sum(not t.done() for t in tasks))
break
with contextlib.suppress(Exception): # flush pending messages to disk before clearing
from gateway.shutdown_flush import flush_pending_to_file
flush_pending_to_file(self._pending_messages, reason="adapter_shutdown")
for state in self._text_debounce_store().values():
state.cancel_timer()
for bucket in (self._background_tasks, self._expected_cancelled_tasks, self._session_tasks,
self._pending_messages, self._active_sessions, self._requeue_counts,
self._text_debounce_store()):
bucket.clear()
def has_pending_interrupt(self, session_key: str) -> bool:
"""Check if there's a pending interrupt for a session."""
return session_key in self._active_sessions and self._active_sessions[session_key].is_set()
def get_pending_message(self, session_key: str) -> Optional[MessageEvent]:
"""Get and clear any pending message for a session."""
return self._pending_messages.pop(session_key, None)
def build_source(
self, chat_id: str, chat_name: Optional[str] = None, chat_type: str = "dm",
user_id: Optional[str] = None, user_name: Optional[str] = None,
thread_id: Optional[str] = None, chat_topic: Optional[str] = None,
user_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None, is_bot: bool = False,
scope_id: Optional[str] = None, guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None, message_id: Optional[str] = None,
role_authorized: bool = False, auto_thread_created: bool = False,
auto_thread_initial_name: Optional[str] = None) -> SessionSource:
"""Build a SessionSource; with ``gateway.profile_routes`` configured the matching
profile is stamped on ``source.profile`` for per-profile HERMES_HOME isolation."""
def _opt(value) -> Optional[str]:
return str(value) if value else None
fields = dict(
platform=self.platform, chat_id=str(chat_id), chat_name=chat_name, chat_type=chat_type,
user_id=None if user_id is None or user_id == "" else str(user_id),
user_name=user_name, thread_id=_opt(thread_id),
chat_topic=(chat_topic or "").strip() or None, user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt, is_bot=is_bot, scope_id=_opt(scope_id),
guild_id=_opt(guild_id), parent_chat_id=_opt(parent_chat_id),
message_id=_opt(message_id))
# Profile from configured routes, else the owning profile of a dedicated secondary bot (so no
# later ``source.profile``-less fallback can re-route the message through the default bot's routes).
owner_profile = getattr(self, "_owner_profile", None)
profile, profile_route_rejected = owner_profile, False
if self.gateway_runner is not None:
from gateway.profile_routing import ProfileRouteRejected
try:
profile = self.gateway_runner._profile_name_for_source(
SessionSource(**fields), adapter_profile=owner_profile) or owner_profile
except ProfileRouteRejected:
profile_route_rejected = True
except Exception:
logger.warning("Profile resolution failed for %s/%s, defaulting to active profile",
self.platform, chat_id, exc_info=True)
source = SessionSource(**fields, profile=profile, role_authorized=role_authorized,
auto_thread_created=auto_thread_created,
auto_thread_initial_name=auto_thread_initial_name)
# Transport-only, kept out of to_dict(): the receiving adapter is authoritative this turn
# even if profile_routes picks another runtime; the reject flag is consumed before auth.
source._transport_adapter_ref = weakref.ref(self)
source.profile_route_rejected = profile_route_rejected
return source
@abstractmethod
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a chat/channel; dict with at least ``name``
and ``type`` ("dm", "group", "channel")."""
def toolsets_for_source(self, source: "SessionSource") -> Optional[List[str]]:
"""Per-source toolset override REPLACING ``platform_toolsets.<platform>``, or None
(default); validated via ``_get_platform_tools`` (webhook adapter pins per-route)."""
return None
def format_message(self, content: str) -> str:
"""Format a message for this platform (override for e.g. Telegram
MarkdownV2); default returns content as-is."""
return content
@staticmethod
def truncate_message(content: str, max_length: int = 4096,
len_fn: Optional["Callable[[str], int]"] = None) -> List[str]:
"""Split a long message into chunks preserving code blocks: a split inside a fence closes it
at the chunk end and reopens it (same language tag) in the next; multi-chunk output gets
``(1/3)`` indicators. ``len_fn`` overrides ``len`` (``utf16_len`` for Telegram)."""
_len = len_fn or len
if _len(content) <= max_length:
return [content]
INDICATOR_RESERVE = 10 # room for " (XX/XX)"
FENCE_CLOSE = "\n```"
chunks: List[str] = []
remaining = content
carry_lang: Optional[str] = None # language tag ("" ok) when previous chunk ended mid-fence
while remaining:
prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
# Body budget after prefix/fence/indicator; floored so a tiny max_length can't stall.
headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE)
if headroom < 1:
headroom = max(1, max_length // 2)
# Remainder fits in one final chunk; close a reopened fence if still open.
if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE:
final_chunk = prefix + remaining
if carry_lang is not None and fence_state_after(remaining, True, carry_lang)[0]:
final_chunk += FENCE_CLOSE
chunks.append(final_chunk)
break
# Natural split (newline, then space); a custom _len budget maps to a codepoint offset.
_cp_limit = (
_custom_unit_to_cp(remaining, headroom, _len) if _len is not len else headroom)
region = remaining[:_cp_limit]
split_at = region.rfind("\n")
if split_at < _cp_limit // 2:
split_at = region.rfind(" ")
if split_at < 1:
# Floor at one codepoint: a zero _cp_limit (max_length 0/1, or a surrogate pair
# wider than the utf16 budget) would never shrink ``remaining``; overshooting beats
# a hang.
split_at = max(1, _cp_limit)
# Don't split inside an inline code span: an unpaired backtick breaks MarkdownV2.
candidate = remaining[:split_at]
backtick_count = candidate.count("`") - candidate.count("\\`")
if backtick_count % 2 == 1:
last_bt = candidate.rfind("`")
while last_bt > 0 and candidate[last_bt - 1] == "\\":
last_bt = candidate.rfind("`", 0, last_bt)
if last_bt > 0:
safe_split = max(
candidate.rfind(" ", 0, last_bt), candidate.rfind("\n", 0, last_bt))
if safe_split > _cp_limit // 4:
split_at = safe_split
chunk_body = remaining[:split_at]
remaining = remaining[split_at:].lstrip()
full_chunk = prefix + chunk_body
# Walk only chunk_body (not the prepended prefix) for the fence state.
in_code, lang = fence_state_after(chunk_body, carry_lang is not None, carry_lang or "")
carry_lang = lang if in_code else None
# Close the orphaned fence so the chunk stands alone.
chunks.append(full_chunk + FENCE_CLOSE if in_code else full_chunk)
if len(chunks) > 1:
chunks = [f"{chunk} ({i + 1}/{len(chunks)})" for i, chunk in enumerate(chunks)]
return chunks