Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
+26
-7
@@ -1222,17 +1222,30 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
allowed_users = matrix_cfg.get("allowed_users")
|
||||
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
|
||||
allowed_rooms = matrix_cfg.get("allowed_rooms")
|
||||
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(allowed_rooms, list):
|
||||
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
@@ -1556,8 +1569,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
||||
if matrix_password:
|
||||
matrix_config.extra["password"] = matrix_password
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
|
||||
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
|
||||
matrix_e2ee = (
|
||||
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
|
||||
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
)
|
||||
matrix_config.extra["encryption"] = matrix_e2ee
|
||||
if matrix_e2ee_mode:
|
||||
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
|
||||
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
if matrix_device_id:
|
||||
matrix_config.extra["device_id"] = matrix_device_id
|
||||
|
||||
@@ -1545,6 +1545,13 @@ class SendResult:
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
raw_response: Any = None
|
||||
# Adapter-specific metadata. Cross-layer contracts that affect delivery
|
||||
# semantics must be documented at the producer and consumer sites. Current
|
||||
# known contract: Telegram edit overflow partials set
|
||||
# raw_response["partial_overflow"] with delivered_chunks, total_chunks,
|
||||
# last_message_id, delivered_prefix, and continuation_message_ids so the
|
||||
# stream consumer can send the missing tail instead of marking a clipped
|
||||
# response complete.
|
||||
retryable: bool = False # True for transient connection errors — base will retry automatically
|
||||
# When the adapter had to split an oversized payload across multiple
|
||||
# platform messages (e.g. Telegram edit_message overflow split-and-deliver),
|
||||
|
||||
+1404
-286
File diff suppressed because it is too large
Load Diff
@@ -2348,10 +2348,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except Exception as fmt_err:
|
||||
if "not modified" not in str(fmt_err).lower():
|
||||
logger.warning(
|
||||
"[%s] Overflow split: MarkdownV2 first-chunk edit "
|
||||
"failed, falling back to plain text: %s",
|
||||
self.name, fmt_err,
|
||||
)
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=first_chunk,
|
||||
text=_strip_mdv2(first_chunk),
|
||||
)
|
||||
else:
|
||||
await self._bot.edit_message_text(
|
||||
@@ -2379,6 +2384,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# are already correctly sized). Best-effort MarkdownV2 with plain
|
||||
# fallback, mirroring send().
|
||||
continuation_ids: list[str] = []
|
||||
delivered_chunks = [first_chunk]
|
||||
prev_id = message_id
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
for chunk in chunks[1:]:
|
||||
@@ -2392,7 +2398,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
for use_markdown in (True, False) if finalize else (False,):
|
||||
try:
|
||||
text = self.format_message(chunk) if use_markdown else chunk
|
||||
if use_markdown:
|
||||
text = self.format_message(chunk)
|
||||
else:
|
||||
# Plain attempt: on finalize the MarkdownV2 attempt
|
||||
# failed, so degrade to clean stripped text, never
|
||||
# the raw chunk (raw ** / ``` markers would render
|
||||
# literally); streaming previews stay raw.
|
||||
text = _strip_mdv2(chunk) if finalize else chunk
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
@@ -2418,7 +2431,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
sent_msg = await self._bot.send_message(
|
||||
chat_id=int(chat_id),
|
||||
text=chunk,
|
||||
text=_strip_mdv2(chunk) if finalize else chunk,
|
||||
**retry_thread_kwargs,
|
||||
**self._link_preview_kwargs(),
|
||||
**self._notification_kwargs(metadata),
|
||||
@@ -2442,17 +2455,37 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
break
|
||||
if sent_msg is None:
|
||||
# Continuation failed — the user has chunk 1 + however many
|
||||
# continuations succeeded. Report success with what we got
|
||||
# so the stream consumer knows the edit landed; the
|
||||
# remaining tail is lost on this attempt and the next
|
||||
# streaming tick may retry.
|
||||
# continuations succeeded, but NOT the full response. Do not
|
||||
# report success: the stream consumer treats a successful edit
|
||||
# as final delivery on got_done, which would suppress fallback
|
||||
# delivery and leave the Telegram topic clipped after the last
|
||||
# delivered chunk.
|
||||
logger.warning(
|
||||
"[%s] Overflow split: stopped at %d/%d chunks delivered",
|
||||
self.name, 1 + len(continuation_ids), len(chunks),
|
||||
)
|
||||
break
|
||||
delivered_prefix = "".join(
|
||||
re.sub(r" \(\d+/\d+\)$", "", delivered)
|
||||
for delivered in delivered_chunks
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
message_id=prev_id,
|
||||
error="overflow_continuation_failed",
|
||||
retryable=True,
|
||||
raw_response={
|
||||
"partial_overflow": True,
|
||||
"delivered_chunks": 1 + len(continuation_ids),
|
||||
"total_chunks": len(chunks),
|
||||
"last_message_id": prev_id,
|
||||
"delivered_prefix": delivered_prefix,
|
||||
"continuation_message_ids": tuple(continuation_ids),
|
||||
},
|
||||
continuation_message_ids=tuple(continuation_ids),
|
||||
)
|
||||
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
|
||||
continuation_ids.append(new_id)
|
||||
delivered_chunks.append(chunk)
|
||||
prev_id = new_id
|
||||
|
||||
last_id = continuation_ids[-1] if continuation_ids else message_id
|
||||
@@ -3804,6 +3837,33 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return error
|
||||
|
||||
def _telegram_media_too_large_note(self, label: str, file_size: Any, max_bytes: int) -> str:
|
||||
limit_mb = max(1, max_bytes // (1024 * 1024))
|
||||
try:
|
||||
size_mb = int(file_size or 0) / (1024 * 1024)
|
||||
size_text = f"{size_mb:.1f} MB"
|
||||
except (TypeError, ValueError):
|
||||
size_text = "unknown size"
|
||||
return (
|
||||
f"[Telegram {label} skipped: file size {size_text} exceeds the "
|
||||
f"{limit_mb} MB limit. Ask the user to send a shorter voice note "
|
||||
"or a smaller audio file.]"
|
||||
)
|
||||
|
||||
def _telegram_media_size_allowed(self, source: Any, label: str) -> tuple[bool, Optional[str]]:
|
||||
"""Validate Telegram media size before downloading into memory."""
|
||||
max_bytes = int(getattr(self, "_max_doc_bytes", 20 * 1024 * 1024) or 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if size <= 0:
|
||||
return True, None
|
||||
if size <= max_bytes:
|
||||
return True, None
|
||||
return False, self._telegram_media_too_large_note(label, size, max_bytes)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -5569,6 +5629,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Download voice/audio messages to cache for STT transcription
|
||||
if msg.voice:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.voice, "voice message")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user voice (size=%s)", getattr(msg.voice, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.voice.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
|
||||
@@ -5579,6 +5645,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
|
||||
elif msg.audio:
|
||||
try:
|
||||
allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file")
|
||||
if not allowed:
|
||||
event.text = self._append_observed_note(event.text, note or "")
|
||||
logger.info("[Telegram] Skipped oversized user audio (size=%s)", getattr(msg.audio, "file_size", None))
|
||||
await self.handle_message(event)
|
||||
return
|
||||
file_obj = await msg.audio.get_file()
|
||||
audio_bytes = await file_obj.download_as_bytearray()
|
||||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
|
||||
|
||||
@@ -190,6 +190,22 @@ from gateway.platforms.base import (
|
||||
)
|
||||
|
||||
|
||||
def _file_content_hash(path: Path) -> str:
|
||||
"""Return the first 16 hex chars of the SHA-256 of *path*'s contents.
|
||||
|
||||
Used for the bridge staleness handshake: bridge.js reports its own
|
||||
source hash in ``/health`` (``scriptHash``), and the adapter compares
|
||||
it against the hash of bridge.js currently on disk. A mismatch means
|
||||
a long-lived bridge process is serving code from before an update.
|
||||
Returns ``""`` when the file can't be read.
|
||||
"""
|
||||
import hashlib
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def check_whatsapp_requirements() -> bool:
|
||||
"""
|
||||
Check if WhatsApp dependencies are available.
|
||||
@@ -372,9 +388,21 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
try:
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
# Auto-install npm dependencies when node_modules is missing OR
|
||||
# package.json changed since the last install (e.g. after
|
||||
# `hermes update` bumps the Baileys pin). The stamp file records
|
||||
# the package.json hash of the last successful install.
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
_pkg_json = bridge_dir / "package.json"
|
||||
_dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
|
||||
_pkg_hash = _file_content_hash(_pkg_json)
|
||||
_deps_fresh = False
|
||||
if (bridge_dir / "node_modules").exists():
|
||||
try:
|
||||
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
|
||||
except OSError:
|
||||
_deps_fresh = False
|
||||
if not _deps_fresh:
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
@@ -395,6 +423,11 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
if _pkg_hash:
|
||||
try:
|
||||
_dep_stamp.write_text(_pkg_hash)
|
||||
except OSError:
|
||||
pass # Stamp is an optimization; install still succeeded
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
@@ -414,12 +447,28 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
data = await resp.json()
|
||||
bridge_status = data.get("status", "unknown")
|
||||
if bridge_status == "connected":
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
# Staleness handshake: only reuse a running
|
||||
# bridge if it is serving the same bridge.js
|
||||
# that is on disk right now. A long-lived
|
||||
# bridge survives gateway restarts AND
|
||||
# `hermes update`, so without this check it
|
||||
# keeps serving pre-update code forever
|
||||
# (e.g. no inbound media download). Old
|
||||
# bridges that don't report scriptHash are
|
||||
# treated as stale by definition.
|
||||
running_hash = data.get("scriptHash", "")
|
||||
disk_hash = _file_content_hash(bridge_path)
|
||||
if running_hash and disk_hash and running_hash == disk_hash:
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
print(
|
||||
f"[{self.name}] Running bridge is stale "
|
||||
f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
|
||||
)
|
||||
else:
|
||||
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
|
||||
except Exception:
|
||||
@@ -444,6 +493,18 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
bridge_env = os.environ.copy()
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
# Pass the profile-aware cache directories so the bridge writes
|
||||
# media where the Python side reads it. Without these the bridge
|
||||
# hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
|
||||
# under HERMES_HOME overrides, profiles, and the new cache/ layout.
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir as _get_audio_dir,
|
||||
get_document_cache_dir as _get_doc_dir,
|
||||
get_image_cache_dir as _get_img_dir,
|
||||
)
|
||||
bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
|
||||
bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
|
||||
bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
|
||||
+98
-2
@@ -32,6 +32,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import site
|
||||
import sys
|
||||
import signal
|
||||
import tempfile
|
||||
@@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
|
||||
)
|
||||
|
||||
|
||||
def _ensure_windows_gateway_venv_imports() -> None:
|
||||
"""Make detached Windows gateway runs see the Hermes venv packages.
|
||||
|
||||
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
|
||||
to avoid the venv launcher respawning a visible console interpreter. That
|
||||
mode can import the source tree via cwd/PYTHONPATH but still miss optional
|
||||
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
|
||||
Patch the live process before MCP discovery so tool injection does not
|
||||
depend on every launcher preserving PYTHONPATH perfectly.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
|
||||
candidates.append(project_root / "venv")
|
||||
|
||||
seen: set[str] = set()
|
||||
for venv_dir in candidates:
|
||||
try:
|
||||
resolved_venv = venv_dir.resolve()
|
||||
except OSError:
|
||||
resolved_venv = venv_dir
|
||||
venv_key = str(resolved_venv).lower()
|
||||
if venv_key in seen:
|
||||
continue
|
||||
seen.add(venv_key)
|
||||
|
||||
site_packages = resolved_venv / "Lib" / "site-packages"
|
||||
if not site_packages.exists():
|
||||
continue
|
||||
|
||||
project_entry = str(project_root)
|
||||
site_entry = str(site_packages)
|
||||
if project_entry not in sys.path:
|
||||
sys.path.insert(0, project_entry)
|
||||
# addsitepackages() semantics matter here: pywin32, used by the MCP
|
||||
# SDK on Windows, relies on .pth processing to expose pywintypes.
|
||||
site.addsitedir(site_entry)
|
||||
if site_entry in sys.path:
|
||||
sys.path.remove(site_entry)
|
||||
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
|
||||
sys.path.insert(insert_at, site_entry)
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
|
||||
pythonpath = [project_entry, site_entry]
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
pythonpath.append(os.environ["PYTHONPATH"])
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
return
|
||||
|
||||
|
||||
def _gateway_platform_value(platform: Any) -> str:
|
||||
"""Return a normalized gateway platform value for enums or raw strings."""
|
||||
return str(getattr(platform, "value", platform) or "").strip().lower()
|
||||
@@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
watcher_env = os.environ.copy()
|
||||
# This watcher is intentionally outside the running gateway. If it
|
||||
# inherits the gateway marker, `hermes gateway restart` refuses to
|
||||
# run as a self-restart loop guard and the gateway stays stopped.
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
if site_packages.exists():
|
||||
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
pythonpath = [str(project_root), str(site_packages)]
|
||||
if watcher_env.get("PYTHONPATH"):
|
||||
pythonpath.append(watcher_env["PYTHONPATH"])
|
||||
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
return
|
||||
@@ -4268,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; "
|
||||
f"{cmd} gateway restart"
|
||||
)
|
||||
# Same marker scrub as the Windows watcher above: this watcher runs
|
||||
# `hermes gateway restart` from outside the gateway, but it inherits
|
||||
# _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard
|
||||
# refuses to run when that marker is set — silently (DEVNULL), so the
|
||||
# gateway stops and never comes back.
|
||||
watcher_env = os.environ.copy()
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
subprocess.Popen(
|
||||
[setsid_bin, "bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
@@ -4281,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
["bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
@@ -12946,6 +13025,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
# True when the previously enqueued progress line was a terminal
|
||||
# fenced code block — consecutive terminal calls then drop the
|
||||
# repeated "💻 terminal" header and render back-to-back blocks.
|
||||
last_was_terminal_block = [False]
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
@@ -13102,7 +13185,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
):
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_cmd_full = args["command"].rstrip()
|
||||
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
|
||||
# Consecutive terminal calls: drop the repeated
|
||||
# "💻 terminal" header so back-to-back commands render as
|
||||
# adjacent code blocks under a single header.
|
||||
_block_header = (
|
||||
"" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||||
)
|
||||
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
|
||||
# Single-line, capped preview for non-verbose modes.
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
@@ -13113,13 +13202,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_cmd_short = _cmd_short[:_cap - 3] + "..."
|
||||
elif _multiline:
|
||||
_cmd_short = _cmd_short + " ..."
|
||||
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
|
||||
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if _code_block_full is not None:
|
||||
last_was_terminal_block[0] = True
|
||||
progress_queue.put(_code_block_full)
|
||||
return
|
||||
last_was_terminal_block[0] = False
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13144,6 +13235,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# fenced block (built above) instead of the truncated preview.
|
||||
if _code_block_short is not None:
|
||||
msg = _code_block_short
|
||||
last_was_terminal_block[0] = True
|
||||
elif preview:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13151,8 +13243,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if len(preview) > _cap:
|
||||
preview = preview[:_cap - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
last_was_terminal_block[0] = False
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
last_was_terminal_block[0] = False
|
||||
|
||||
# Dedup: collapse consecutive identical progress messages.
|
||||
# Common with execute_code where models iterate with the same
|
||||
@@ -15909,6 +16003,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
_ensure_windows_gateway_venv_imports()
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
|
||||
@@ -294,6 +294,22 @@ def build_session_context_prompt(
|
||||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
|
||||
if context.source.platform == Platform.MATRIX:
|
||||
src = context.source
|
||||
room_name = src.chat_name or src.chat_id
|
||||
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
|
||||
lines.append("")
|
||||
lines.append(f"**Matrix Room:** {room_name}")
|
||||
lines.append(f"**Matrix Room ID:** {room_id}")
|
||||
if src.thread_id:
|
||||
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
|
||||
lines.append(f"**Matrix Thread:** {thread_id}")
|
||||
lines.append(
|
||||
"**Matrix room boundary:** Treat this turn as scoped to the current "
|
||||
"Matrix room/thread only. Do not assume unresolved references are "
|
||||
"about other Matrix rooms or projects unless the user explicitly says so."
|
||||
)
|
||||
|
||||
# User identity.
|
||||
# In shared multi-user sessions (shared threads OR shared non-thread groups
|
||||
# when group_sessions_per_user=False), multiple users contribute to the same
|
||||
@@ -1264,6 +1280,17 @@ class SessionStore:
|
||||
entries.sort(key=lambda e: e.updated_at, reverse=True)
|
||||
|
||||
return entries
|
||||
|
||||
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
|
||||
"""Return the active session entry for a persisted session ID, if any."""
|
||||
if not session_id:
|
||||
return None
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
for entry in self._entries.values():
|
||||
if entry.session_id == session_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
|
||||
"""Append a message to a session's transcript (SQLite).
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
@@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
from agent.i18n import t
|
||||
from gateway.config import HomeChannel, Platform, PlatformConfig
|
||||
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
|
||||
from gateway.session import build_session_key
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import cfg_get
|
||||
from utils import (
|
||||
atomic_json_write,
|
||||
@@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin:
|
||||
])
|
||||
if queue_depth:
|
||||
lines.append(t("gateway.status.queued", count=queue_depth))
|
||||
if source.platform == Platform.MATRIX:
|
||||
adapter = self.adapters.get(Platform.MATRIX)
|
||||
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
|
||||
thread = source.thread_id or "none"
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.matrix_scope_header"),
|
||||
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
|
||||
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
|
||||
t("gateway.status.matrix_scope_thread", thread_id=thread),
|
||||
t("gateway.status.matrix_scope_mode", scope=scope),
|
||||
t(
|
||||
"gateway.status.matrix_scope_key",
|
||||
session_key=self._redact_matrix_session_key(session_key),
|
||||
),
|
||||
])
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
|
||||
@@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _redact_matrix_session_key(session_key: str) -> str:
|
||||
"""Return a stable Matrix session-key fingerprint for shared room status."""
|
||||
text = str(session_key or "")
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
|
||||
"""Best-effort origin lookup for gateway session IDs."""
|
||||
lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
|
||||
if callable(lookup):
|
||||
entry = lookup(self.session_store, session_id)
|
||||
return getattr(entry, "origin", None) if entry is not None else None
|
||||
|
||||
# Test doubles and older stores may not expose the public lookup helper.
|
||||
# Keep the Matrix resume guard fail-closed if no origin can be resolved.
|
||||
entries = getattr(self.session_store, "_entries", {}) or {}
|
||||
for entry in entries.values():
|
||||
if getattr(entry, "session_id", None) == session_id:
|
||||
return getattr(entry, "origin", None)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
|
||||
return (
|
||||
origin is not None
|
||||
and origin.platform == Platform.MATRIX
|
||||
and current.platform == Platform.MATRIX
|
||||
and origin.chat_id == current.chat_id
|
||||
)
|
||||
|
||||
async def _handle_agents_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /agents command - list active agents and running tasks."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
@@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
name = event.get_command_args().strip()
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
parts = shlex.split(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
allow_all = "--all" in parts
|
||||
allow_cross_room = "--cross-room" in parts
|
||||
name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
|
||||
|
||||
# Strip common outer brackets/quotes users may type literally from the
|
||||
# usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
|
||||
@@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin:
|
||||
# List recent titled sessions for this user/platform
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
if not titled:
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
return t("gateway.resume.matrix_no_named_sessions")
|
||||
return t("gateway.resume.no_named_sessions")
|
||||
lines = [t("gateway.resume.list_header")]
|
||||
for idx, s in enumerate(titled[:10], start=1):
|
||||
title = s["title"]
|
||||
if source.platform == Platform.MATRIX and allow_all:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if origin:
|
||||
title = f"{title} — {origin.chat_name or origin.chat_id}"
|
||||
preview = s.get("preview", "")[:40]
|
||||
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
|
||||
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
|
||||
@@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin:
|
||||
if name.isdigit():
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
|
||||
return t("gateway.resume.list_failed", error=e)
|
||||
@@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
|
||||
|
||||
if source.platform == Platform.MATRIX:
|
||||
target_origin = self._gateway_session_origin_for_id(target_id)
|
||||
if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
|
||||
if target_origin is None:
|
||||
return t("gateway.resume.matrix_blocked_no_origin", name=name)
|
||||
return t(
|
||||
"gateway.resume.matrix_blocked_other_room",
|
||||
room=target_origin.chat_name or target_origin.chat_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# Check if already on that session
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
if current_entry.session_id == target_id:
|
||||
@@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin:
|
||||
# Count messages for context
|
||||
history = self.session_store.load_transcript(target_id)
|
||||
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
|
||||
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
|
||||
|
||||
if source.platform == Platform.MATRIX and allow_cross_room:
|
||||
return t(
|
||||
"gateway.resume.matrix_cross_room_success",
|
||||
title=title,
|
||||
room=source.chat_name or source.chat_id,
|
||||
msg_part=msg_part,
|
||||
)
|
||||
if not msg_count:
|
||||
return t("gateway.resume.resumed_no_count", title=title)
|
||||
if msg_count == 1:
|
||||
|
||||
@@ -147,8 +147,15 @@ class GatewayStreamConsumer:
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
self._last_sent_text = "" # Track last-sent text to skip redundant edits
|
||||
# True when the most recent _send_or_edit split-and-delivered across
|
||||
# continuation messages (the adapter adopted a new message id).
|
||||
self._last_edit_overflowed = False
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# True when fallback is sending only the missing tail after a partial
|
||||
# Telegram overflow delivery. In that case the already-visible prefix
|
||||
# is intentional content, not a stale preview to delete.
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||
self._final_response_sent = False
|
||||
@@ -261,6 +268,7 @@ class GatewayStreamConsumer:
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
@@ -581,14 +589,20 @@ class GatewayStreamConsumer:
|
||||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif (
|
||||
current_update_visible
|
||||
and not self._adapter_requires_finalize
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
# final edit — but only for adapters that don't
|
||||
# need an explicit finalize signal.
|
||||
# final edit for adapters that don't need an
|
||||
# explicit finalize signal, and for any adapter
|
||||
# when that edit split-and-delivered across
|
||||
# continuations: the split edit carried
|
||||
# finalize=True itself, and re-finalizing with
|
||||
# the full text would overflow-split again into
|
||||
# the adopted continuation, duplicating chunks
|
||||
# on screen.
|
||||
self._final_response_sent = True
|
||||
self._final_content_delivered = True
|
||||
elif self._message_id:
|
||||
@@ -647,11 +661,21 @@ class GatewayStreamConsumer:
|
||||
await asyncio.sleep(0.05) # Small yield to not busy-loop
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Best-effort final edit on cancellation
|
||||
# Best-effort final edit on cancellation. finalize=True so
|
||||
# REQUIRES_EDIT_FINALIZE platforms (Telegram) apply final
|
||||
# formatting — a plain edit here would leave the entire reply
|
||||
# rendered as a raw streaming preview while the success flags
|
||||
# below suppress the gateway's formatted re-send.
|
||||
# is_turn_final=False keeps _try_fresh_final from setting
|
||||
# _final_response_sent itself; this handler owns the flags.
|
||||
_best_effort_ok = False
|
||||
if self._accumulated and self._message_id:
|
||||
try:
|
||||
_best_effort_ok = bool(await self._send_or_edit(self._accumulated))
|
||||
_best_effort_ok = bool(
|
||||
await self._send_or_edit(
|
||||
self._accumulated, finalize=True, is_turn_final=False,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Only confirm final delivery if the best-effort send above
|
||||
@@ -867,11 +891,21 @@ class GatewayStreamConsumer:
|
||||
self._notify_new_message()
|
||||
|
||||
# Remove the frozen partial message so the user only sees the
|
||||
# complete fallback response. Best-effort — if the platform doesn't
|
||||
# complete fallback response. ONLY safe when the fallback re-sent
|
||||
# the FULL final text (continuation == final_text). When the
|
||||
# prefix-based dedup above sent only the missing TAIL, the partial
|
||||
# message IS the head of the answer — deleting it leaves the user
|
||||
# with only the last part of the response (the "Gemini sent only
|
||||
# the second half" symptom). Best-effort — if the platform doesn't
|
||||
# implement ``delete_message``, the delete fails (flood control still
|
||||
# active, bot lacks permission, message too old to delete), the
|
||||
# partial remains but at least the full answer was delivered.
|
||||
if stale_message_id and stale_message_id != last_message_id:
|
||||
if (
|
||||
stale_message_id
|
||||
and stale_message_id != last_message_id
|
||||
and not self._fallback_preserve_partial_messages
|
||||
and continuation == final_text
|
||||
):
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
@@ -888,6 +922,7 @@ class GatewayStreamConsumer:
|
||||
self._final_content_delivered = True
|
||||
self._last_sent_text = chunks[-1]
|
||||
self._fallback_prefix = ""
|
||||
self._fallback_preserve_partial_messages = False
|
||||
|
||||
def _is_flood_error(self, result) -> bool:
|
||||
"""Check if a SendResult failure is due to flood control / rate limiting."""
|
||||
@@ -1208,6 +1243,7 @@ class GatewayStreamConsumer:
|
||||
return True
|
||||
# Failure already disabled drafts for this run; fall through to
|
||||
# the regular edit/send path below.
|
||||
self._last_edit_overflowed = False
|
||||
try:
|
||||
if self._message_id is not None:
|
||||
if self._edit_supported:
|
||||
@@ -1264,6 +1300,7 @@ class GatewayStreamConsumer:
|
||||
and result.message_id
|
||||
and result.message_id != self._message_id
|
||||
):
|
||||
self._last_edit_overflowed = True
|
||||
self._message_id = str(result.message_id)
|
||||
self._message_created_ts = time.monotonic()
|
||||
self._last_sent_text = ""
|
||||
@@ -1274,6 +1311,35 @@ class GatewayStreamConsumer:
|
||||
self._flood_strikes = 0
|
||||
return True
|
||||
else:
|
||||
raw_response = getattr(result, "raw_response", None)
|
||||
if isinstance(raw_response, dict) and raw_response.get("partial_overflow"):
|
||||
# Telegram edited/sent one or more overflow chunks,
|
||||
# but not the complete response. Preserve the
|
||||
# visible prefix so the got_done fallback sends the
|
||||
# missing tail instead of marking a clipped topic
|
||||
# reply as final delivery.
|
||||
self._message_id = str(
|
||||
raw_response.get("last_message_id")
|
||||
or result.message_id
|
||||
or self._message_id
|
||||
)
|
||||
delivered_prefix = raw_response.get("delivered_prefix")
|
||||
if isinstance(delivered_prefix, str) and delivered_prefix:
|
||||
self._last_sent_text = delivered_prefix
|
||||
self._fallback_prefix = delivered_prefix
|
||||
self._fallback_preserve_partial_messages = text.startswith(
|
||||
delivered_prefix
|
||||
)
|
||||
else:
|
||||
self._fallback_prefix = self._visible_prefix()
|
||||
self._fallback_preserve_partial_messages = False
|
||||
self._fallback_final_send = True
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
if getattr(result, "continuation_message_ids", ()):
|
||||
self._notify_new_message()
|
||||
return False
|
||||
|
||||
# Edit failed. If this looks like flood control / rate
|
||||
# limiting, use adaptive backoff: double the edit interval
|
||||
# and retry on the next cycle. Only permanently disable
|
||||
|
||||
Reference in New Issue
Block a user