From ffe6b8dcb603763af5612d0b0993391d2a06c2cc Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 10:00:17 +0800 Subject: [PATCH] fix(feishu): card content type, text tag parsing, reply truncation, parent_id REST fallback Cherry-pick from origin/fix/feishu-card-content-and-text-parsing onto upstream/main: - card_msg_content_type='user_card_content' for interactive message detail API - Add 'text' tag to _collect_text_segments for card text extraction - Increase action lines limit from 12 to 100 - Raise reply_to_text limit from 500 to 3000 chars with Chinese truncation marker - session_id injection via event._session_id - _fetch_message_detail REST fallback for missing parent_id - History-aware reply marker Also applied async build_channel_directory from stash@{0}: - startup: await build_channel_directory - reconnect: await build_channel_directory - cron ticker: run_coroutine_threadsafe with 10s timeout --- gateway/platforms/feishu.py | 897 ++----- gateway/run.py | 4702 ++++++++--------------------------- 2 files changed, 1230 insertions(+), 4369 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 718f01e9954d3..99481ae29fd7f 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -8,41 +8,11 @@ - Gateway allowlist integration via FEISHU_ALLOWED_USERS - Persistent dedup state across restarts - Per-chat serial message processing (matches openclaw createChatQueue) -- Processing status reactions: Typing while working, removed on success, - swapped for CrossMark on failure +- Persistent ACK emoji reaction on inbound messages - Reaction events routed as synthetic text events (matches openclaw) - Interactive card button-click events routed as synthetic COMMAND events - Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker) - Verification token validation as second auth layer (matches openclaw) - -Feishu identity model ---------------------- -Feishu uses three user-ID tiers (official docs: -https://open.feishu.cn/document/home/user-identity-introduction/introduction): - - open_id (ou_xxx) — **App-scoped**. The same person gets a different - open_id under each Feishu app. Always available in - event payloads without extra permissions. - user_id (u_xxx) — **Tenant-scoped**. Stable within a company but - requires the ``contact:user.employee_id:readonly`` - scope. May not be present. - union_id (on_xxx) — **Developer-scoped**. Same across all apps owned by - one developer/ISV. Best cross-app stable ID. - -For bots specifically: - - app_id — The application's canonical credential identifier. - bot open_id — Returned by ``/bot/v3/info``. This is the bot's own - open_id *within its app context* and is what Feishu - puts in ``mentions[].id.open_id`` when someone - @-mentions the bot. Used for mention gating only. - -In single-bot mode (what Hermes currently supports), open_id works as a -de-facto unique user identifier since there is only one app context. - -Session-key participant isolation prefers ``union_id`` (via user_id_alt) -over ``open_id`` (via user_id) so that sessions stay stable if the same -user is seen through different apps in the future. """ from __future__ import annotations @@ -59,12 +29,11 @@ import threading import time import uuid -from collections import OrderedDict from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -102,9 +71,7 @@ UpdateMessageRequest, UpdateMessageRequestBody, ) - from lark_oapi.core import AccessTokenType, HttpMethod from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN - from lark_oapi.core.model import BaseRequest from lark_oapi.event.callback.model.p2_card_action_trigger import ( CallBackCard, P2CardActionTriggerResponse, @@ -131,7 +98,6 @@ BasePlatformAdapter, MessageEvent, MessageType, - ProcessingOutcome, SendResult, SUPPORTED_DOCUMENT_TYPES, cache_document_from_bytes, @@ -153,8 +119,6 @@ re.MULTILINE, ) _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") -_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$") -_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$") _MENTION_RE = re.compile(r"@_user_\d+") _MULTISPACE_RE = re.compile(r"[ \t]{2,}") _POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE) @@ -224,17 +188,7 @@ } _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback - -# Feishu reactions render as prominent badges, unlike Discord/Telegram's -# small footer emoji — a success badge on every message would add noise, so -# we only mark start (Typing) and failure (CrossMark); the reply itself is -# the success signal. -_FEISHU_REACTION_IN_PROGRESS = "Typing" -_FEISHU_REACTION_FAILURE = "CrossMark" -# Bound on the (message_id → reaction_id) handle cache. Happy-path entries -# drain on completion; the cap is a safeguard against unbounded growth from -# delete-failures, not a capacity plan. -_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024 +_FEISHU_ACK_EMOJI = "OK" # QR onboarding constants _ONBOARD_ACCOUNTS_URLS = { @@ -265,8 +219,6 @@ _PREFERRED_LOCALES = ("zh_cn", "en_us") _MARKDOWN_SPECIAL_CHARS_RE = re.compile(r"([\\`*_{}\[\]()#+\-!|>~])") _MENTION_PLACEHOLDER_RE = re.compile(r"@_user_\d+") -_MENTION_BOUNDARY_CHARS = frozenset(" \t\n\r.,;:!?、,。;:!?()[]{}<>\"'`") -_TRAILING_TERMINAL_PUNCT = frozenset(" \t\n\r.!?。!?") _WHITESPACE_RE = re.compile(r"\s+") _SUPPORTED_CARD_TEXT_KEYS = ( "title", @@ -310,36 +262,12 @@ class FeishuPostMediaRef: resource_type: str = "file" -@dataclass(frozen=True) -class FeishuMentionRef: - name: str = "" - open_id: str = "" - is_all: bool = False - is_self: bool = False - - -@dataclass(frozen=True) -class _FeishuBotIdentity: - open_id: str = "" - user_id: str = "" - name: str = "" - - def matches(self, *, open_id: str, user_id: str, name: str) -> bool: - # Precedence: open_id > user_id > name. IDs are authoritative when both - # sides have them; the next tier is only considered when either side - # lacks the current one. - if open_id and self.open_id: - return open_id == self.open_id - if user_id and self.user_id: - return user_id == self.user_id - return bool(self.name) and name == self.name - - @dataclass(frozen=True) class FeishuPostParseResult: text_content: str image_keys: List[str] = field(default_factory=list) media_refs: List[FeishuPostMediaRef] = field(default_factory=list) + mentioned_ids: List[str] = field(default_factory=list) @dataclass(frozen=True) @@ -349,14 +277,14 @@ class FeishuNormalizedMessage: preferred_message_type: str = "text" image_keys: List[str] = field(default_factory=list) media_refs: List[FeishuPostMediaRef] = field(default_factory=list) - mentions: List[FeishuMentionRef] = field(default_factory=list) + mentioned_ids: List[str] = field(default_factory=list) relation_kind: str = "plain" metadata: Dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True) class FeishuAdapterSettings: - app_id: str # Canonical bot/app identifier (credential, not from event payloads) + app_id: str app_secret: str domain_name: str connection_mode: str @@ -364,11 +292,7 @@ class FeishuAdapterSettings: verification_token: str group_policy: str allowed_group_users: frozenset[str] - # Bot's own open_id (app-scoped) — returned by /bot/v3/info. Used only for - # @mention matching: Feishu puts this value in mentions[].id.open_id when - # a user @-mentions the bot in a group chat. bot_open_id: str - # Bot's user_id (tenant-scoped) — optional, used as fallback mention match. bot_user_id: str bot_name: str dedup_cache_size: int @@ -506,77 +430,31 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int: def _build_markdown_post_payload(content: str) -> str: - rows = _build_markdown_post_rows(content) return json.dumps( { "zh_cn": { - "content": rows, + "content": [ + [ + { + "tag": "md", + "text": content, + } + ] + ], } }, ensure_ascii=False, ) -def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]: - """Build Feishu post rows while isolating fenced code blocks. - - Feishu's `md` renderer can swallow trailing content when a fenced code block - appears inside one large markdown element. Split the reply at real fence - lines so prose before/after the code block remains visible while code stays - in a dedicated row. - """ - if not content: - return [[{"tag": "md", "text": ""}]] - if "```" not in content: - return [[{"tag": "md", "text": content}]] - - rows: List[List[Dict[str, str]]] = [] - current: List[str] = [] - in_code_block = False - - def _flush_current() -> None: - nonlocal current - if not current: - return - segment = "\n".join(current) - if segment.strip(): - rows.append([{"tag": "md", "text": segment}]) - current = [] - - for raw_line in content.splitlines(): - stripped_line = raw_line.strip() - is_fence = bool( - _MARKDOWN_FENCE_CLOSE_RE.match(stripped_line) - if in_code_block - else _MARKDOWN_FENCE_OPEN_RE.match(stripped_line) - ) - - if is_fence: - if not in_code_block: - _flush_current() - current.append(raw_line) - in_code_block = not in_code_block - if not in_code_block: - _flush_current() - continue - - current.append(raw_line) - - _flush_current() - return rows or [[{"tag": "md", "text": content}]] - - -def parse_feishu_post_payload( - payload: Any, - *, - mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, -) -> FeishuPostParseResult: +def parse_feishu_post_payload(payload: Any) -> FeishuPostParseResult: resolved = _resolve_post_payload(payload) if not resolved: return FeishuPostParseResult(text_content=FALLBACK_POST_TEXT) image_keys: List[str] = [] media_refs: List[FeishuPostMediaRef] = [] + mentioned_ids: List[str] = [] parts: List[str] = [] title = _normalize_feishu_text(str(resolved.get("title", "")).strip()) @@ -587,10 +465,7 @@ def parse_feishu_post_payload( if not isinstance(row, list): continue row_text = _normalize_feishu_text( - "".join( - _render_post_element(item, image_keys, media_refs, mentions_map) - for item in row - ) + "".join(_render_post_element(item, image_keys, media_refs, mentioned_ids) for item in row) ) if row_text: parts.append(row_text) @@ -599,6 +474,7 @@ def parse_feishu_post_payload( text_content="\n".join(parts).strip() or FALLBACK_POST_TEXT, image_keys=image_keys, media_refs=media_refs, + mentioned_ids=mentioned_ids, ) @@ -650,7 +526,7 @@ def _render_post_element( element: Any, image_keys: List[str], media_refs: List[FeishuPostMediaRef], - mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, + mentioned_ids: List[str], ) -> str: if isinstance(element, str): return element @@ -668,21 +544,19 @@ def _render_post_element( escaped_label = _escape_markdown_text(label) return f"[{escaped_label}]({href})" if href else escaped_label if tag == "at": - # Post .user_id is a placeholder ("@_user_N" or "@_all"); look up - # the real ref in mentions_map for the display name. - placeholder = str(element.get("user_id", "")).strip() - if placeholder == "@_all": - # Feishu SDK sometimes omits @_all from the top-level mentions - # payload; record it here so the caller's mention list stays complete. - if mentions_map is not None and "@_all" not in mentions_map: - mentions_map["@_all"] = FeishuMentionRef(is_all=True) - return "@all" - ref = (mentions_map or {}).get(placeholder) - if ref is not None: - display_name = ref.name or ref.open_id or "user" - else: - display_name = str(element.get("user_name", "")).strip() or "user" - return f"@{_escape_markdown_text(display_name)}" + mentioned_id = ( + str(element.get("open_id", "")).strip() + or str(element.get("user_id", "")).strip() + ) + if mentioned_id and mentioned_id not in mentioned_ids: + mentioned_ids.append(mentioned_id) + display_name = ( + str(element.get("user_name", "")).strip() + or str(element.get("name", "")).strip() + or str(element.get("text", "")).strip() + or mentioned_id + ) + return f"@{_escape_markdown_text(display_name)}" if display_name else "@" if tag in {"img", "image"}: image_key = str(element.get("image_key", "")).strip() if image_key and image_key not in image_keys: @@ -720,7 +594,8 @@ def _render_post_element( nested_parts: List[str] = [] for key in ("text", "title", "content", "children", "elements"): - extracted = _render_nested_post(element.get(key), image_keys, media_refs, mentions_map) + value = element.get(key) + extracted = _render_nested_post(value, image_keys, media_refs, mentioned_ids) if extracted: nested_parts.append(extracted) return " ".join(part for part in nested_parts if part) @@ -730,7 +605,7 @@ def _render_nested_post( value: Any, image_keys: List[str], media_refs: List[FeishuPostMediaRef], - mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, + mentioned_ids: List[str], ) -> str: if isinstance(value, str): return _escape_markdown_text(value) @@ -738,17 +613,17 @@ def _render_nested_post( return " ".join( part for item in value - for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)] + for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] if part ) if isinstance(value, dict): - direct = _render_post_element(value, image_keys, media_refs, mentions_map) + direct = _render_post_element(value, image_keys, media_refs, mentioned_ids) if direct: return direct return " ".join( part for item in value.values() - for part in [_render_nested_post(item, image_keys, media_refs, mentions_map)] + for part in [_render_nested_post(item, image_keys, media_refs, mentioned_ids)] if part ) return "" @@ -759,48 +634,31 @@ def _render_nested_post( # --------------------------------------------------------------------------- -def normalize_feishu_message( - *, - message_type: str, - raw_content: str, - mentions: Optional[Sequence[Any]] = None, - bot: _FeishuBotIdentity = _FeishuBotIdentity(), -) -> FeishuNormalizedMessage: +def normalize_feishu_message(*, message_type: str, raw_content: str) -> FeishuNormalizedMessage: normalized_type = str(message_type or "").strip().lower() payload = _load_feishu_payload(raw_content) - mentions_map = _build_mentions_map(mentions, bot) if normalized_type == "text": - text = str(payload.get("text", "") or "") - # Feishu SDK sometimes omits @_all from the mentions payload even when - # the text literal contains it (confirmed via im.v1.message.get). - if "@_all" in text and "@_all" not in mentions_map: - mentions_map["@_all"] = FeishuMentionRef(is_all=True) return FeishuNormalizedMessage( raw_type=normalized_type, - text_content=_normalize_feishu_text(text, mentions_map), - mentions=list(mentions_map.values()), + text_content=_normalize_feishu_text(str(payload.get("text", "") or "")), ) if normalized_type == "post": - # The walker writes back to mentions_map if it encounters - # , so reading .values() after parsing is enough. - parsed_post = parse_feishu_post_payload(payload, mentions_map=mentions_map) + parsed_post = parse_feishu_post_payload(payload) return FeishuNormalizedMessage( raw_type=normalized_type, text_content=parsed_post.text_content, image_keys=list(parsed_post.image_keys), media_refs=list(parsed_post.media_refs), - mentions=list(mentions_map.values()), + mentioned_ids=list(parsed_post.mentioned_ids), relation_kind="post", ) - mention_refs = list(mentions_map.values()) if normalized_type == "image": image_key = str(payload.get("image_key", "") or "").strip() alt_text = _normalize_feishu_text( str(payload.get("text", "") or "") or str(payload.get("alt", "") or "") - or FALLBACK_IMAGE_TEXT, - mentions_map, + or FALLBACK_IMAGE_TEXT ) return FeishuNormalizedMessage( raw_type=normalized_type, @@ -808,7 +666,6 @@ def normalize_feishu_message( preferred_message_type="photo", image_keys=[image_key] if image_key else [], relation_kind="image", - mentions=mention_refs, ) if normalized_type in {"file", "audio", "media"}: media_ref = _build_media_ref_from_payload(payload, resource_type=normalized_type) @@ -820,7 +677,6 @@ def normalize_feishu_message( media_refs=[media_ref] if media_ref.file_key else [], relation_kind=normalized_type, metadata={"placeholder_text": placeholder}, - mentions=mention_refs, ) if normalized_type == "merge_forward": return _normalize_merge_forward_message(payload) @@ -908,7 +764,7 @@ def _normalize_interactive_message(message_type: str, payload: Dict[str, Any]) - if actions: lines.append(f"Actions: {', '.join(actions)}") - text_content = "\n".join(lines[:12]).strip() or FALLBACK_INTERACTIVE_TEXT + text_content = "\n".join(lines[:100]).strip() or FALLBACK_INTERACTIVE_TEXT return FeishuNormalizedMessage( raw_type=message_type, text_content=text_content, @@ -998,6 +854,7 @@ def _collect_text_segments(value: Any, *, in_rich_block: bool) -> List[str]: tag = str(value.get("tag", "") or value.get("type", "")).strip().lower() next_in_rich_block = in_rich_block or tag in { + "text", "plain_text", "lark_md", "markdown", @@ -1095,20 +952,8 @@ def _first_non_empty_text(*values: Any) -> str: # --------------------------------------------------------------------------- -def _normalize_feishu_text( - text: str, - mentions_map: Optional[Dict[str, FeishuMentionRef]] = None, -) -> str: - def _sub(match: "re.Match[str]") -> str: - key = match.group(0) - ref = (mentions_map or {}).get(key) - if ref is None: - return " " - name = ref.name or ref.open_id or "user" - return f"@{name}" - - cleaned = _MENTION_PLACEHOLDER_RE.sub(_sub, text or "") - cleaned = cleaned.replace("@_all", "@all") +def _normalize_feishu_text(text: str) -> str: + cleaned = _MENTION_PLACEHOLDER_RE.sub(" ", text or "") cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") cleaned = "\n".join(_WHITESPACE_RE.sub(" ", line).strip() for line in cleaned.split("\n")) cleaned = "\n".join(line for line in cleaned.split("\n") if line) @@ -1127,117 +972,6 @@ def _unique_lines(lines: List[str]) -> List[str]: return unique -# --------------------------------------------------------------------------- -# Mention helpers -# --------------------------------------------------------------------------- - - -def _extract_mention_ids(mention: Any) -> tuple[str, str]: - # Returns (open_id, user_id). im.v1.message.get hands back id as a string - # plus id_type discriminator; event payloads hand back a nested UserId - # object carrying both fields. - mention_id = getattr(mention, "id", None) - if isinstance(mention_id, str): - id_type = str(getattr(mention, "id_type", "") or "").lower() - if id_type == "open_id": - return mention_id, "" - if id_type == "user_id": - return "", mention_id - return "", "" - if mention_id is None: - return "", "" - return ( - str(getattr(mention_id, "open_id", "") or ""), - str(getattr(mention_id, "user_id", "") or ""), - ) - - -def _build_mentions_map( - mentions: Optional[Sequence[Any]], - bot: _FeishuBotIdentity, -) -> Dict[str, FeishuMentionRef]: - result: Dict[str, FeishuMentionRef] = {} - for mention in mentions or []: - key = str(getattr(mention, "key", "") or "") - if not key: - continue - if key == "@_all": - result[key] = FeishuMentionRef(is_all=True) - continue - open_id, user_id = _extract_mention_ids(mention) - name = str(getattr(mention, "name", "") or "").strip() - result[key] = FeishuMentionRef( - name=name, - open_id=open_id, - is_self=bot.matches(open_id=open_id, user_id=user_id, name=name), - ) - return result - - -def _build_mention_hint(mentions: Sequence[FeishuMentionRef]) -> str: - parts: List[str] = [] - seen: set = set() - for ref in mentions: - if ref.is_self: - continue - signature = (ref.is_all, ref.open_id, ref.name) - if signature in seen: - continue - seen.add(signature) - if ref.is_all: - parts.append("@all") - elif ref.open_id: - parts.append(f"{ref.name or 'unknown'} (open_id={ref.open_id})") - else: - parts.append(ref.name or "unknown") - return f"[Mentioned: {', '.join(parts)}]" if parts else "" - - -def _strip_edge_self_mentions( - text: str, - mentions: Sequence[FeishuMentionRef], -) -> str: - # Leading: strip consecutive self-mentions unconditionally. - # Trailing: strip only when followed by whitespace/terminal punct, so - # mid-sentence references ("don't @Bot again") stay intact. - # Leading word-boundary prevents @Al from eating @Alice. - if not text: - return text - self_names = [ - f"@{ref.name or ref.open_id or 'user'}" - for ref in mentions - if ref.is_self - ] - if not self_names: - return text - - remaining = text.lstrip() - while True: - for nm in self_names: - if not remaining.startswith(nm): - continue - after = remaining[len(nm):] - if after and after[0] not in _MENTION_BOUNDARY_CHARS: - continue - remaining = after.lstrip() - break - else: - break - - while True: - i = len(remaining) - while i > 0 and remaining[i - 1] in _TRAILING_TERMINAL_PUNCT: - i -= 1 - body = remaining[:i] - tail = remaining[i:] - for nm in self_names: - if body.endswith(nm): - remaining = body[: -len(nm)].rstrip() + tail - break - else: - return remaining - - def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: """Run the official Lark WS client in its own thread-local event loop.""" import lark_oapi.ws.client as ws_client_module @@ -1340,13 +1074,6 @@ def __init__(self, config: PlatformConfig): self._webhook_rate_counts: Dict[str, tuple[int, float]] = {} # rate_key → (count, window_start) self._webhook_anomaly_counts: Dict[str, tuple[int, str, float]] = {} # ip → (count, last_status, first_seen) self._card_action_tokens: Dict[str, float] = {} # token → first_seen_time - # Inbound events that arrived before the adapter loop was ready - # (e.g. during startup/restart or network-flap reconnect). A single - # drainer thread replays them as soon as the loop becomes available. - self._pending_inbound_events: List[Any] = [] - self._pending_inbound_lock = threading.Lock() - self._pending_drain_scheduled = False - self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing) self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing) self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat @@ -1363,9 +1090,6 @@ def __init__(self, config: PlatformConfig): # Exec approval button state (approval_id → {session_key, message_id, chat_id}) self._approval_state: Dict[int, Dict[str, str]] = {} self._approval_counter = itertools.count(1) - # Feishu reaction deletion requires the opaque reaction_id returned - # by create, so we cache it per message_id. - self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict() self._load_seen_message_ids() @staticmethod @@ -1496,12 +1220,6 @@ def _build_event_handler(self) -> Any: .register_p2_card_action_trigger(self._on_card_action_trigger) .register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat) .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat) - .register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered) - .register_p2_im_message_recalled_v1(self._on_message_recalled) - .register_p2_customized_event( - "drive.notice.comment_add_v1", - self._on_drive_comment_event, - ) .build() ) @@ -1693,14 +1411,11 @@ async def edit_message( chat_id: str, message_id: str, content: str, - *, - finalize: bool = False, ) -> SendResult: """Edit a previously sent Feishu text/post message.""" if not self._client: return SendResult(success=False, error="Not connected") - content = self.format_message(content) try: msg_type, payload = self._build_outbound_payload(content) body = self._build_update_message_body(msg_type=msg_type, content=payload) @@ -2043,22 +1758,10 @@ def format_message(self, content: str) -> str: # ========================================================================= def _on_message_event(self, data: Any) -> None: - """Normalize Feishu inbound events into MessageEvent. - - Called by the lark_oapi SDK's event dispatcher on a background thread. - If the adapter loop is not currently accepting callbacks (brief window - during startup/restart or network-flap reconnect), the event is queued - for replay instead of dropped. - """ + """Normalize Feishu inbound events into MessageEvent.""" loop = self._loop - if not self._loop_accepts_callbacks(loop): - start_drainer = self._enqueue_pending_inbound_event(data) - if start_drainer: - threading.Thread( - target=self._drain_pending_inbound_events, - name="feishu-pending-inbound-drainer", - daemon=True, - ).start() + if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): + logger.warning("[Feishu] Dropping inbound message before adapter loop is ready") return future = asyncio.run_coroutine_threadsafe( self._handle_message_event_data(data), @@ -2066,124 +1769,6 @@ def _on_message_event(self, data: Any) -> None: ) future.add_done_callback(self._log_background_failure) - def _enqueue_pending_inbound_event(self, data: Any) -> bool: - """Append an event to the pending-inbound queue. - - Returns True if the caller should spawn a drainer thread (no drainer - currently scheduled), False if a drainer is already running and will - pick up the new event on its next pass. - """ - with self._pending_inbound_lock: - if len(self._pending_inbound_events) >= self._pending_inbound_max_depth: - # Queue full — drop the oldest to make room. This happens only - # if the loop stays unavailable for an extended period AND the - # WS keeps firing callbacks. Still better than silent drops. - dropped = self._pending_inbound_events.pop(0) - try: - event = getattr(dropped, "event", None) - message = getattr(event, "message", None) - message_id = str(getattr(message, "message_id", "") or "unknown") - except Exception: - message_id = "unknown" - logger.error( - "[Feishu] Pending-inbound queue full (%d); dropped oldest event %s", - self._pending_inbound_max_depth, - message_id, - ) - self._pending_inbound_events.append(data) - depth = len(self._pending_inbound_events) - should_start = not self._pending_drain_scheduled - if should_start: - self._pending_drain_scheduled = True - logger.warning( - "[Feishu] Queued inbound event for replay (loop not ready, queue depth=%d)", - depth, - ) - return should_start - - def _drain_pending_inbound_events(self) -> None: - """Replay queued inbound events once the adapter loop is ready. - - Runs in a dedicated daemon thread. Polls ``_running`` and - ``_loop_accepts_callbacks`` until events can be dispatched or the - adapter shuts down. A single drainer handles the entire queue; - concurrent ``_on_message_event`` calls just append. - """ - poll_interval = 0.25 - max_wait_seconds = 120.0 # safety cap: drop queue after 2 minutes - waited = 0.0 - try: - while True: - if not getattr(self, "_running", True): - # Adapter shutting down — drop queued events rather than - # holding them against a closed loop. - with self._pending_inbound_lock: - dropped = len(self._pending_inbound_events) - self._pending_inbound_events.clear() - if dropped: - logger.warning( - "[Feishu] Dropped %d queued inbound event(s) during shutdown", - dropped, - ) - return - loop = self._loop - if self._loop_accepts_callbacks(loop): - with self._pending_inbound_lock: - batch = self._pending_inbound_events[:] - self._pending_inbound_events.clear() - if not batch: - # Queue emptied between check and grab; done. - with self._pending_inbound_lock: - if not self._pending_inbound_events: - return - continue - dispatched = 0 - requeue: List[Any] = [] - for event in batch: - try: - fut = asyncio.run_coroutine_threadsafe( - self._handle_message_event_data(event), - loop, - ) - fut.add_done_callback(self._log_background_failure) - dispatched += 1 - except RuntimeError: - # Loop closed between check and submit — requeue - # and poll again. - requeue.append(event) - if requeue: - with self._pending_inbound_lock: - self._pending_inbound_events[:0] = requeue - if dispatched: - logger.info( - "[Feishu] Replayed %d queued inbound event(s)", - dispatched, - ) - if not requeue: - # Successfully drained; check if more arrived while - # we were dispatching and exit if not. - with self._pending_inbound_lock: - if not self._pending_inbound_events: - return - # More events queued or requeue pending — loop again. - continue - if waited >= max_wait_seconds: - with self._pending_inbound_lock: - dropped = len(self._pending_inbound_events) - self._pending_inbound_events.clear() - logger.error( - "[Feishu] Adapter loop unavailable for %.0fs; " - "dropped %d queued inbound event(s)", - max_wait_seconds, - dropped, - ) - return - time.sleep(poll_interval) - waited += poll_interval - finally: - with self._pending_inbound_lock: - self._pending_drain_scheduled = False - async def _handle_message_event_data(self, data: Any) -> None: """Shared inbound message handling for websocket and webhook transports.""" event = getattr(data, "event", None) @@ -2198,8 +1783,8 @@ async def _handle_message_event_data(self, data: Any) -> None: if not message_id or self._is_duplicate(message_id): logger.debug("[Feishu] Dropping duplicate/missing message_id: %s", message_id) return - if self._is_self_sent_bot_message(event): - logger.debug("[Feishu] Dropping self-sent bot event: %s", message_id) + if getattr(sender, "sender_type", "") == "bot": + logger.debug("[Feishu] Dropping bot-originated event: %s", message_id) return chat_type = getattr(message, "chat_type", "p2p") @@ -2236,31 +1821,6 @@ def _on_bot_removed_from_chat(self, data: Any) -> None: logger.info("[Feishu] Bot removed from chat: %s", chat_id) self._chat_info_cache.pop(chat_id, None) - def _on_p2p_chat_entered(self, data: Any) -> None: - logger.debug("[Feishu] User entered P2P chat with bot") - - def _on_message_recalled(self, data: Any) -> None: - logger.debug("[Feishu] Message recalled by user") - - def _on_drive_comment_event(self, data: Any) -> None: - """Handle drive document comment notification (drive.notice.comment_add_v1). - - Delegates to :mod:`gateway.platforms.feishu_comment` for parsing, - logging, and reaction. Scheduling follows the same - ``run_coroutine_threadsafe`` pattern used by ``_on_message_event``. - """ - from gateway.platforms.feishu_comment import handle_drive_comment_event - - loop = self._loop - if not self._loop_accepts_callbacks(loop): - logger.warning("[Feishu] Dropping drive comment event before adapter loop is ready") - return - future = asyncio.run_coroutine_threadsafe( - handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id), - loop, - ) - future.add_done_callback(self._log_background_failure) - def _on_reaction_event(self, event_type: str, data: Any) -> None: """Route user reactions on bot messages as synthetic text events.""" event = getattr(data, "event", None) @@ -2276,12 +1836,12 @@ def _on_reaction_event(self, event_type: str, data: Any) -> None: operator_type, emoji_type, ) - # Drop bot/app-origin reactions to break the feedback loop from our - # own lifecycle reactions. A human reacting with the same emoji (e.g. - # clicking Typing on a bot message) is still routed through. + # Only process reactions from real users. Ignore app/bot-generated reactions + # and Hermes' own ACK emoji to avoid feedback loops. loop = self._loop if ( operator_type in {"bot", "app"} + or emoji_type == _FEISHU_ACK_EMOJI or not message_id or loop is None or bool(getattr(loop, "is_closed", lambda: False)()) @@ -2505,35 +2065,33 @@ def _get_chat_lock(self, chat_id: str) -> asyncio.Lock: async def _handle_message_with_guards(self, event: MessageEvent) -> None: """Dispatch a single event through the agent pipeline with per-chat serialization - before handing the event off to the agent. + and a persistent ACK emoji reaction before processing starts. - Per-chat lock ensures messages in the same chat are processed one at a - time (matches openclaw's createChatQueue serial queue behaviour). + - Per-chat lock: ensures messages in the same chat are processed one at a time + (matches openclaw's createChatQueue serial queue behaviour). + - ACK indicator: adds a CHECK reaction to the triggering message before handing + off to the agent and leaves it in place as a receipt marker. """ chat_id = getattr(event.source, "chat_id", "") or "" if event.source else "" chat_lock = self._get_chat_lock(chat_id) async with chat_lock: + message_id = event.message_id + if message_id: + await self._add_ack_reaction(message_id) await self.handle_message(event) - # ========================================================================= - # Processing status reactions - # ========================================================================= - - def _reactions_enabled(self) -> bool: - return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no") - - async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]: - """Return the reaction_id on success, else None. The id is needed later for deletion.""" - if not self._client or not message_id or not emoji_type: + async def _add_ack_reaction(self, message_id: str) -> Optional[str]: + """Add a persistent ACK emoji reaction to signal the message was received.""" + if not self._client or not message_id: return None try: - from lark_oapi.api.im.v1 import ( + from lark_oapi.api.im.v1 import ( # lazy import — keeps optional dep optional CreateMessageReactionRequest, CreateMessageReactionRequestBody, ) body = ( CreateMessageReactionRequestBody.builder() - .reaction_type({"emoji_type": emoji_type}) + .reaction_type({"emoji_type": _FEISHU_ACK_EMOJI}) .build() ) request = ( @@ -2546,92 +2104,15 @@ async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str] if response and getattr(response, "success", lambda: False)(): data = getattr(response, "data", None) return getattr(data, "reaction_id", None) - logger.debug( - "[Feishu] Add reaction %s on %s rejected: code=%s msg=%s", - emoji_type, - message_id, - getattr(response, "code", None), - getattr(response, "msg", None), - ) - except Exception: logger.warning( - "[Feishu] Add reaction %s on %s raised", - emoji_type, - message_id, - exc_info=True, - ) - return None - - async def _remove_reaction(self, message_id: str, reaction_id: str) -> bool: - if not self._client or not message_id or not reaction_id: - return False - try: - from lark_oapi.api.im.v1 import DeleteMessageReactionRequest - request = ( - DeleteMessageReactionRequest.builder() - .message_id(message_id) - .reaction_id(reaction_id) - .build() - ) - response = await asyncio.to_thread(self._client.im.v1.message_reaction.delete, request) - if response and getattr(response, "success", lambda: False)(): - return True - logger.debug( - "[Feishu] Remove reaction %s on %s rejected: code=%s msg=%s", - reaction_id, + "[Feishu] Failed to add ack reaction to %s: code=%s msg=%s", message_id, getattr(response, "code", None), getattr(response, "msg", None), ) except Exception: - logger.warning( - "[Feishu] Remove reaction %s on %s raised", - reaction_id, - message_id, - exc_info=True, - ) - return False - - def _remember_processing_reaction(self, message_id: str, reaction_id: str) -> None: - cache = self._pending_processing_reactions - cache[message_id] = reaction_id - cache.move_to_end(message_id) - while len(cache) > _FEISHU_PROCESSING_REACTION_CACHE_SIZE: - cache.popitem(last=False) - - def _pop_processing_reaction(self, message_id: str) -> Optional[str]: - return self._pending_processing_reactions.pop(message_id, None) - - async def on_processing_start(self, event: MessageEvent) -> None: - if not self._reactions_enabled(): - return - message_id = event.message_id - if not message_id or message_id in self._pending_processing_reactions: - return - reaction_id = await self._add_reaction(message_id, _FEISHU_REACTION_IN_PROGRESS) - if reaction_id: - self._remember_processing_reaction(message_id, reaction_id) - - async def on_processing_complete( - self, event: MessageEvent, outcome: ProcessingOutcome - ) -> None: - if not self._reactions_enabled(): - return - message_id = event.message_id - if not message_id: - return - - start_reaction_id = self._pending_processing_reactions.get(message_id) - if start_reaction_id: - if not await self._remove_reaction(message_id, start_reaction_id): - # Don't stack a second badge on top of a Typing we couldn't - # remove — UI would read as both "working" and "done/failed" - # simultaneously. Keep the handle so LRU eventually evicts it. - return - self._pop_processing_reaction(message_id) - - if outcome is ProcessingOutcome.FAILURE: - await self._add_reaction(message_id, _FEISHU_REACTION_FAILURE) + logger.warning("[Feishu] Failed to add ack reaction to %s", message_id, exc_info=True) + return None # ========================================================================= # Webhook server and security @@ -2680,28 +2161,28 @@ async def _process_inbound_message( chat_type: str, message_id: str, ) -> None: - text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message) - - if inbound_type == MessageType.TEXT: - text = _strip_edge_self_mentions(text, mentions) - if text.startswith("/"): - inbound_type = MessageType.COMMAND - - # Guard runs post-strip so a pure "@Bot" message (stripped to "") is dropped. + text, inbound_type, media_urls, media_types = await self._extract_message_content(message) if inbound_type == MessageType.TEXT and not text and not media_urls: - logger.debug("[Feishu] Ignoring empty text message id=%s", message_id) + logger.debug("[Feishu] Ignoring unsupported or empty message type: %s", getattr(message, "message_type", "")) return - if inbound_type != MessageType.COMMAND: - hint = _build_mention_hint(mentions) - if hint: - text = f"{hint}\n\n{text}" if text else hint + if inbound_type == MessageType.TEXT and text.startswith("/"): + inbound_type = MessageType.COMMAND reply_to_message_id = ( getattr(message, "parent_id", None) or getattr(message, "upper_message_id", None) or None ) + # WebSocket 事件可能没有 parent_id,主动调用 REST API 补全 + if not reply_to_message_id: + api_message = await self._fetch_message_detail(message_id) + if api_message: + reply_to_message_id = ( + getattr(api_message, "parent_id", None) + or getattr(api_message, "upper_message_id", None) + or None + ) reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None logger.info( @@ -2974,8 +2455,6 @@ async def _handle_webhook_request(self, request: Any) -> Any: self._on_reaction_event(event_type, data) elif event_type == "card.action.trigger": self._on_card_action_trigger(data) - elif event_type == "drive.notice.comment_add_v1": - self._on_drive_comment_event(data) else: logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown") return web.json_response({"code": 0, "msg": "ok"}) @@ -3154,20 +2633,14 @@ async def _flush_text_batch_now(self, key: str) -> None: # Message content extraction and resource download # ========================================================================= - async def _extract_message_content( - self, message: Any - ) -> tuple[str, MessageType, List[str], List[str], List[FeishuMentionRef]]: + async def _extract_message_content(self, message: Any) -> tuple[str, MessageType, List[str], List[str]]: + """Extract text and cached media from a normalized Feishu message.""" raw_content = getattr(message, "content", "") or "" raw_type = getattr(message, "message_type", "") or "" message_id = str(getattr(message, "message_id", "") or "") logger.info("[Feishu] Received raw message type=%s message_id=%s", raw_type, message_id) - normalized = normalize_feishu_message( - message_type=raw_type, - raw_content=raw_content, - mentions=getattr(message, "mentions", None), - bot=self._bot_identity(), - ) + normalized = normalize_feishu_message(message_type=raw_type, raw_content=raw_content) media_urls, media_types = await self._download_feishu_message_resources( message_id=message_id, normalized=normalized, @@ -3184,7 +2657,7 @@ async def _extract_message_content( if injected: text = injected - return text, inbound_type, media_urls, media_types, list(normalized.mentions) + return text, inbound_type, media_urls, media_types async def _download_feishu_message_resources( self, @@ -3448,22 +2921,10 @@ def _resolve_source_chat_type(*, chat_info: Dict[str, Any], event_chat_type: str return "group" async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]: - """Map Feishu's three-tier user IDs onto Hermes' SessionSource fields. - - Preference order for the primary ``user_id`` field: - 1. user_id (tenant-scoped, most stable — requires permission scope) - 2. open_id (app-scoped, always available — different per bot app) - - ``user_id_alt`` carries the union_id (developer-scoped, stable across - all apps by the same developer). Session-key generation prefers - user_id_alt when present, so participant isolation stays stable even - if the primary ID is the app-scoped open_id. - """ open_id = getattr(sender_id, "open_id", None) or None user_id = getattr(sender_id, "user_id", None) or None union_id = getattr(sender_id, "union_id", None) or None - # Prefer tenant-scoped user_id; fall back to app-scoped open_id. - primary_id = user_id or open_id + primary_id = open_id or user_id display_name = await self._resolve_sender_name_from_api(primary_id or union_id) return { "user_id": primary_id, @@ -3527,6 +2988,20 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True) return None + async def _fetch_message_detail(self, message_id: str) -> Optional[Any]: + """Fetch full message object from Feishu API (includes parent_id/root_id for replies).""" + if not self._client or not message_id: + return None + try: + request = self._build_get_message_request(message_id) + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + return None + items = getattr(getattr(response, "data", None), "items", None) or [] + return items[0] if items else None + except Exception: + return None + async def _fetch_message_text(self, message_id: str) -> Optional[str]: if not self._client or not message_id: return None @@ -3545,18 +3020,27 @@ async def _fetch_message_text(self, message_id: str) -> Optional[str]: body = getattr(parent, "body", None) msg_type = getattr(parent, "msg_type", "") or "" raw_content = getattr(body, "content", "") or "" - parent_mentions = getattr(parent, "mentions", None) if parent else None - text = self._extract_text_from_raw_content( - msg_type=msg_type, - raw_content=raw_content, - mentions=parent_mentions, - ) + text = self._extract_text_from_raw_content(msg_type=msg_type, raw_content=raw_content) self._message_text_cache[message_id] = text return text except Exception: logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True) return None + async def _fetch_message_detail(self, message_id: str) -> Optional[Any]: + """Fetch full message object from Feishu API (includes parent_id/root_id for replies).""" + if not self._client or not message_id: + return None + try: + request = self._build_get_message_request(message_id) + response = await asyncio.to_thread(self._client.im.v1.message.get, request) + if not response or getattr(response, "success", lambda: False)() is False: + return None + items = getattr(getattr(response, "data", None), "items", None) or [] + return items[0] if items else None + except Exception: + return None + def _extract_text_from_raw_content( self, *, @@ -3639,112 +3123,42 @@ def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: st normalized = normalize_feishu_message( message_type=getattr(message, "message_type", "") or "", raw_content=raw_content, - mentions=getattr(message, "mentions", None), - bot=self._bot_identity(), ) - return self._post_mentions_bot(normalized.mentions) - - def _is_self_sent_bot_message(self, event: Any) -> bool: - """Return True only for Feishu events emitted by this Hermes bot.""" - sender = getattr(event, "sender", None) - sender_type = str(getattr(sender, "sender_type", "") or "").strip().lower() - if sender_type not in {"bot", "app"}: - return False - - sender_id = getattr(sender, "sender_id", None) - sender_open_id = str(getattr(sender_id, "open_id", "") or "").strip() - sender_user_id = str(getattr(sender_id, "user_id", "") or "").strip() - - if self._bot_open_id and sender_open_id == self._bot_open_id: - return True - if self._bot_user_id and sender_user_id == self._bot_user_id: - return True + if normalized.mentioned_ids: + return self._post_mentions_bot(normalized.mentioned_ids) return False def _message_mentions_bot(self, mentions: List[Any]) -> bool: - # IDs trump names: when both sides have open_id (or both user_id), - # match requires equal IDs. Name fallback only when either side - # lacks an ID. + """Check whether any mention targets the configured or inferred bot identity.""" for mention in mentions: mention_id = getattr(mention, "id", None) - mention_open_id = (getattr(mention_id, "open_id", None) or "").strip() - mention_user_id = (getattr(mention_id, "user_id", None) or "").strip() + mention_open_id = getattr(mention_id, "open_id", None) + mention_user_id = getattr(mention_id, "user_id", None) mention_name = (getattr(mention, "name", None) or "").strip() - if mention_open_id and self._bot_open_id: - if mention_open_id == self._bot_open_id: - return True - continue # IDs differ — not the bot; skip name fallback. - if mention_user_id and self._bot_user_id: - if mention_user_id == self._bot_user_id: - return True - continue + if self._bot_open_id and mention_open_id == self._bot_open_id: + return True + if self._bot_user_id and mention_user_id == self._bot_user_id: + return True if self._bot_name and mention_name == self._bot_name: return True return False - def _post_mentions_bot(self, mentions: List[FeishuMentionRef]) -> bool: - return any(m.is_self for m in mentions) - - def _bot_identity(self) -> _FeishuBotIdentity: - return _FeishuBotIdentity( - open_id=self._bot_open_id, - user_id=self._bot_user_id, - name=self._bot_name, - ) + def _post_mentions_bot(self, mentioned_ids: List[str]) -> bool: + if not mentioned_ids: + return False + if self._bot_open_id and self._bot_open_id in mentioned_ids: + return True + if self._bot_user_id and self._bot_user_id in mentioned_ids: + return True + return False async def _hydrate_bot_identity(self) -> None: - """Best-effort discovery of bot identity for precise group mention gating - and self-sent bot event filtering. - - Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info - (no extra scopes required beyond the tenant access token). Falls back to - the application info endpoint for ``_bot_name`` only when the first probe - doesn't return it. Each field is hydrated independently — a value already - supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID / - FEISHU_BOT_NAME) is preserved and skips its probe. - """ + """Best-effort discovery of bot identity for precise group mention gating.""" if not self._client: return - if self._bot_open_id and self._bot_name: - # Everything the self-send filter and precise mention gate need is - # already in place; nothing to probe. - return - - # Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no - # extra scopes required. This is the same endpoint the onboarding wizard - # uses via probe_bot(). - if not self._bot_open_id or not self._bot_name: - try: - req = ( - BaseRequest.builder() - .http_method(HttpMethod.GET) - .uri("/open-apis/bot/v3/info") - .token_types({AccessTokenType.TENANT}) - .build() - ) - resp = await asyncio.to_thread(self._client.request, req) - content = getattr(getattr(resp, "raw", None), "content", None) - if content: - payload = json.loads(content) - parsed = _parse_bot_response(payload) or {} - open_id = (parsed.get("bot_open_id") or "").strip() - bot_name = (parsed.get("bot_name") or "").strip() - if open_id and not self._bot_open_id: - self._bot_open_id = open_id - if bot_name and not self._bot_name: - self._bot_name = bot_name - except Exception: - logger.debug( - "[Feishu] /bot/v3/info probe failed during hydration", - exc_info=True, - ) - - # Fallback probe for _bot_name only: application info endpoint. Needs - # admin:app.info:readonly or application:application:self_manage scope, - # so it's best-effort. - if self._bot_name: + if any((self._bot_open_id, self._bot_user_id, self._bot_name)): return try: request = self._build_get_application_request(app_id=self._app_id, lang="en_us") @@ -3753,17 +3167,17 @@ async def _hydrate_bot_identity(self) -> None: code = getattr(response, "code", None) if code == 99991672: logger.warning( - "[Feishu] Unable to hydrate bot name from application info. " + "[Feishu] Unable to hydrate bot identity from application info. " "Grant admin:app.info:readonly or application:application:self_manage " "so group @mention gating can resolve the bot name precisely." ) return app = getattr(getattr(response, "data", None), "app", None) app_name = (getattr(app, "app_name", None) or "").strip() - if app_name and not self._bot_name: + if app_name: self._bot_name = app_name except Exception: - logger.debug("[Feishu] Failed to hydrate bot name from application info", exc_info=True) + logger.debug("[Feishu] Failed to hydrate bot identity", exc_info=True) # ========================================================================= # Deduplication — seen message ID cache (persistent) @@ -4121,9 +3535,12 @@ def _build_get_chat_request(chat_id: str) -> Any: return SimpleNamespace(chat_id=chat_id) @staticmethod - def _build_get_message_request(message_id: str) -> Any: + def _build_get_message_request(message_id: str, card_msg_content_type: str = "user_card_content") -> Any: if "GetMessageRequest" in globals(): - return GetMessageRequest.builder().message_id(message_id).build() + req = GetMessageRequest.builder().message_id(message_id).build() + if card_msg_content_type: + req.add_query("card_msg_content_type", card_msg_content_type) + return req return SimpleNamespace(message_id=message_id) @staticmethod @@ -4473,9 +3890,6 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: Uses lark_oapi SDK when available, falls back to raw HTTP otherwise. Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure. - - Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID - that Feishu puts in @mention payloads. It is NOT the app_id. """ if FEISHU_AVAILABLE: return _probe_bot_sdk(app_id, app_secret, domain) @@ -4496,12 +3910,12 @@ def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any: def _parse_bot_response(data: dict) -> Optional[dict]: - # /bot/v3/info returns bot.app_name; legacy paths used bot_name — accept both. + """Extract bot_name and bot_open_id from a /bot/v3/info response.""" if data.get("code") != 0: return None bot = data.get("bot") or data.get("data", {}).get("bot") or {} return { - "bot_name": bot.get("app_name") or bot.get("bot_name"), + "bot_name": bot.get("bot_name"), "bot_open_id": bot.get("open_id"), } @@ -4510,18 +3924,13 @@ def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]: """Probe bot info using lark_oapi SDK.""" try: client = _build_onboard_client(app_id, app_secret, domain) - req = ( - BaseRequest.builder() - .http_method(HttpMethod.GET) - .uri("/open-apis/bot/v3/info") - .token_types({AccessTokenType.TENANT}) - .build() + resp = client.request( + method="GET", + url="/open-apis/bot/v3/info", + body=None, + raw_response=True, ) - resp = client.request(req) - content = getattr(getattr(resp, "raw", None), "content", None) - if content is None: - return None - return _parse_bot_response(json.loads(content)) + return _parse_bot_response(json.loads(resp.content)) except Exception as exc: logger.debug("[Feishu onboard] SDK probe failed: %s", exc) return None diff --git a/gateway/run.py b/gateway/run.py index 22a46e393c84d..d2c246778605c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14,7 +14,6 @@ """ import asyncio -import dataclasses import json import logging import os @@ -25,157 +24,10 @@ import tempfile import threading import time -from collections import OrderedDict -from contextvars import copy_context from pathlib import Path from datetime import datetime from typing import Dict, Optional, Any, List -# account_usage imports the OpenAI SDK chain (~230 ms). Only needed by -# /usage; we still import it at module top in the gateway because test -# patches (tests/gateway/test_usage_command.py) target -# `gateway.run.fetch_account_usage` as a module-level attribute. The -# gateway is a long-running daemon, so its boot cost matters less than -# preserving the established test-patch surface. -from agent.account_usage import fetch_account_usage, render_account_usage_lines -from hermes_cli.config import cfg_get - -# --- Agent cache tuning --------------------------------------------------- -# Bounds the per-session AIAgent cache to prevent unbounded growth in -# long-lived gateways (each AIAgent holds LLM clients, tool schemas, -# memory providers, etc.). LRU order + idle TTL eviction are enforced -# from _enforce_agent_cache_cap() and _session_expiry_watcher() below. -_AGENT_CACHE_MAX_SIZE = 128 -_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h -_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0 -# Only auto-continue interrupted gateway turns while the interruption is fresh. -# Stale tool-tail/resume markers can otherwise revive an unrelated old task -# after a gateway restart when the user's next message starts new work. -# -# The freshness signal is the timestamp of the last transcript row, which -# ``hermes_state.get_messages`` carries on every persisted message. This -# handles the two auto-continue cases uniformly: -# * resume_pending (gateway restart/shutdown watchdog marked the session) -# * tool-tail (last persisted message is a tool result the agent -# never got to reply to) -# In both cases "when did we last do anything on this transcript" is the -# correct freshness question, so one signal replaces two divergent ones. -# -# Default window: 1 hour. This comfortably covers ``agent.gateway_timeout`` -# (30 min default) plus runtime slack — a legitimate long-running turn that -# gets interrupted near its timeout boundary and is resumed shortly after -# is still classified fresh. Override via -# ``config.yaml`` ``agent.gateway_auto_continue_freshness``. -_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60 - - -def _coerce_gateway_timestamp(value: Any) -> Optional[float]: - """Best-effort conversion of stored gateway timestamps to epoch seconds. - - Missing/unparseable timestamps return None so legacy transcripts keep the - historical auto-continue behaviour instead of being silently dropped. - Accepts: datetime, epoch seconds (int/float), epoch milliseconds (when - the magnitude exceeds year-2286), ISO-8601 strings (with or without a - trailing ``Z``), and numeric strings. - """ - if value is None: - return None - if isinstance(value, datetime): - return value.timestamp() - if isinstance(value, bool): # bool is a subclass of int — skip it - return None - if isinstance(value, (int, float)): - # Some platform events use milliseconds; Hermes state rows use seconds. - return float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) - if isinstance(value, str): - text = value.strip() - if not text: - return None - try: - numeric = float(text) - return numeric / 1000.0 if numeric > 10_000_000_000 else numeric - except ValueError: - pass - try: - return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() - except ValueError: - return None - return None - - -def _auto_continue_freshness_window() -> float: - """Return the configured auto-continue freshness window in seconds. - - Reads ``HERMES_AUTO_CONTINUE_FRESHNESS`` (bridged from - ``config.yaml`` ``agent.gateway_auto_continue_freshness`` at gateway - startup, same pattern as ``HERMES_AGENT_TIMEOUT``). Falls back to the - module default when unset or malformed. Non-positive values disable - the freshness gate (restores the pre-fix "always fresh" behaviour for - users who want to opt out). - """ - raw = os.environ.get("HERMES_AUTO_CONTINUE_FRESHNESS") - if raw is None or raw == "": - return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT) - try: - return float(raw) - except (TypeError, ValueError): - return float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT) - - -def _is_fresh_gateway_interruption( - value: Any, - *, - now: Optional[float] = None, - window_secs: Optional[float] = None, -) -> bool: - """Return True when an interruption marker is fresh enough to auto-continue. - - Unknown timestamps are treated as fresh for backward compatibility with - legacy transcripts (pre-dating timestamp persistence) and with in-memory - test scaffolding that constructs history entries without timestamps. - - A non-positive ``window_secs`` disables the gate (always fresh), which - restores the pre-fix behaviour for users who opt out via config. - """ - window = ( - float(window_secs) - if window_secs is not None - else float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT) - ) - if window <= 0: - return True - timestamp = _coerce_gateway_timestamp(value) - if timestamp is None: - return True - current = time.time() if now is None else now - return current - timestamp <= window - - -def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any: - """Return the ``timestamp`` of the last usable transcript row, if any. - - Skips metadata-only rows (``session_meta``, system injections) that are - dropped before being handed to the agent. Returns ``None`` when no - usable row carries a timestamp — callers should treat that as "fresh" - for backward compatibility. - """ - if not history: - return None - for msg in reversed(history): - if not isinstance(msg, dict): - continue - role = msg.get("role") - if not role or role in ("session_meta", "system"): - continue - ts = msg.get("timestamp") - if ts is not None: - return ts - # First non-meta row without a timestamp — legacy transcript row. - # Returning None lets the caller fall through to the legacy-fresh path. - return None - return None - - # --------------------------------------------------------------------------- # SSL certificate auto-detection for NixOS and other non-standard systems. # Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported. @@ -224,7 +76,7 @@ def _ensure_ssl_certs() -> None: # Resolve Hermes home directory (respects HERMES_HOME override) from hermes_constants import get_hermes_home -from utils import atomic_yaml_write, base_url_host_matches, is_truthy_value +from utils import atomic_yaml_write, is_truthy_value _hermes_home = get_hermes_home() # Load environment variables from ~/.hermes/.env first. @@ -234,10 +86,6 @@ def _ensure_ssl_certs() -> None: _env_path = _hermes_home / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') - -_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P.+):(?P/[^:]+?)(?::(?P[^:]+))?$") -_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"} - # Bridge config.yaml values into the environment so os.getenv() picks them up. # config.yaml is authoritative for terminal settings — overrides .env. _config_path = _hermes_home / 'config.yaml' @@ -267,7 +115,6 @@ def _ensure_ssl_certs() -> None: "singularity_image": "TERMINAL_SINGULARITY_IMAGE", "modal_image": "TERMINAL_MODAL_IMAGE", "daytona_image": "TERMINAL_DAYTONA_IMAGE", - "vercel_runtime": "TERMINAL_VERCEL_RUNTIME", "ssh_host": "TERMINAL_SSH_HOST", "ssh_user": "TERMINAL_SSH_USER", "ssh_port": "TERMINAL_SSH_PORT", @@ -277,24 +124,12 @@ def _ensure_ssl_certs() -> None: "container_disk": "TERMINAL_CONTAINER_DISK", "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", - "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", - "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "sandbox_dir": "TERMINAL_SANDBOX_DIR", "persistent_shell": "TERMINAL_PERSISTENT_SHELL", } for _cfg_key, _env_var in _terminal_env_map.items(): if _cfg_key in _terminal_cfg: _val = _terminal_cfg[_cfg_key] - # Skip cwd placeholder values (".", "auto", "cwd") — the - # gateway resolves these to Path.home() later (line ~255). - # Writing the raw placeholder here would just be noise. - # Only bridge explicit absolute paths from config.yaml. - if _cfg_key == "cwd" and str(_val) in (".", "auto", "cwd"): - continue - # Expand shell tilde in cwd so subprocess.Popen never - # receives a literal "~/" which the kernel rejects. - if _cfg_key == "cwd" and isinstance(_val, str): - _val = os.path.expanduser(_val) if isinstance(_val, list): os.environ[_env_var] = json.dumps(_val) else: @@ -355,13 +190,6 @@ def _ensure_ssl_certs() -> None: os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"]) if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ: os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"]) - if ( - "gateway_auto_continue_freshness" in _agent_cfg - and "HERMES_AUTO_CONTINUE_FRESHNESS" not in os.environ - ): - os.environ["HERMES_AUTO_CONTINUE_FRESHNESS"] = str( - _agent_cfg["gateway_auto_continue_freshness"] - ) _display_cfg = _cfg.get("display", {}) if _display_cfg and isinstance(_display_cfg, dict): if "busy_input_mode" in _display_cfg and "HERMES_GATEWAY_BUSY_INPUT_MODE" not in os.environ: @@ -396,13 +224,6 @@ def _ensure_ssl_certs() -> None: except Exception: pass -# Warn if user has deprecated MESSAGING_CWD / TERMINAL_CWD in .env -try: - from hermes_cli.config import warn_deprecated_cwd_env_vars - warn_deprecated_cwd_env_vars() -except Exception: - pass - # Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs) os.environ["HERMES_QUIET"] = "1" @@ -410,14 +231,12 @@ def _ensure_ssl_certs() -> None: os.environ["HERMES_EXEC_ASK"] = "1" # Set terminal working directory for messaging platforms. -# config.yaml terminal.cwd is the canonical source (bridged to TERMINAL_CWD -# by the config bridge above). When it's unset or a placeholder, default -# to home directory. MESSAGING_CWD is accepted as a backward-compat -# fallback (deprecated — the warning above tells users to migrate). +# If the user set an explicit path in config.yaml (not "." or "auto"), +# respect it. Otherwise use MESSAGING_CWD or default to home directory. _configured_cwd = os.environ.get("TERMINAL_CWD", "") if not _configured_cwd or _configured_cwd in (".", "auto", "cwd"): - _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) - os.environ["TERMINAL_CWD"] = _fallback + messaging_cwd = os.getenv("MESSAGING_CWD") or str(Path.home()) + os.environ["TERMINAL_CWD"] = messaging_cwd from gateway.config import ( Platform, @@ -431,7 +250,6 @@ def _ensure_ssl_certs() -> None: build_session_context, build_session_context_prompt, build_session_key, - is_shared_multi_user_session, ) from gateway.delivery import DeliveryRouter from gateway.platforms.base import ( @@ -447,15 +265,49 @@ def _ensure_ssl_certs() -> None: ) -from gateway.whatsapp_identity import ( - canonical_whatsapp_identifier as _canonical_whatsapp_identifier, # noqa: F401 - expand_whatsapp_aliases as _expand_whatsapp_auth_aliases, - normalize_whatsapp_identifier as _normalize_whatsapp_identifier, -) +def _normalize_whatsapp_identifier(value: str) -> str: + """Strip WhatsApp JID/LID syntax down to its stable numeric identifier.""" + return ( + str(value or "") + .strip() + .replace("+", "", 1) + .split(":", 1)[0] + .split("@", 1)[0] + ) -logger = logging.getLogger(__name__) +def _expand_whatsapp_auth_aliases(identifier: str) -> set: + """Resolve WhatsApp phone/LID aliases using bridge session mapping files.""" + normalized = _normalize_whatsapp_identifier(identifier) + if not normalized: + return set() + session_dir = _hermes_home / "whatsapp" / "session" + resolved = set() + queue = [normalized] + + while queue: + current = queue.pop(0) + if not current or current in resolved: + continue + + resolved.add(current) + for suffix in ("", "_reverse"): + mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json" + if not mapping_path.exists(): + continue + try: + mapped = _normalize_whatsapp_identifier( + json.loads(mapping_path.read_text(encoding="utf-8")) + ) + except Exception: + continue + if mapped and mapped not in resolved: + queue.append(mapped) + + return resolved + +logger = logging.getLogger(__name__) # Sentinel placed into _running_agents immediately when a session starts # processing, *before* any await. Prevents a second message for the same @@ -465,30 +317,16 @@ def _ensure_ssl_certs() -> None: def _resolve_runtime_agent_kwargs() -> dict: - """Resolve provider credentials for gateway-created AIAgent instances. - - If the primary provider fails with an authentication error, attempt to - resolve credentials using the fallback provider chain from config.yaml - before giving up. - """ + """Resolve provider credentials for gateway-created AIAgent instances.""" from hermes_cli.runtime_provider import ( resolve_runtime_provider, format_runtime_provider_error, ) - from hermes_cli.auth import AuthError try: runtime = resolve_runtime_provider( requested=os.getenv("HERMES_INFERENCE_PROVIDER"), ) - except AuthError as auth_exc: - # Primary provider auth failed (expired token, revoked key, etc.). - # Try the fallback provider chain before raising. - logger.warning("Primary provider auth failed: %s — trying fallback", auth_exc) - fb_config = _try_resolve_fallback_provider() - if fb_config is not None: - return fb_config - raise RuntimeError(format_runtime_provider_error(auth_exc)) from auth_exc except Exception as exc: raise RuntimeError(format_runtime_provider_error(exc)) from exc @@ -503,48 +341,6 @@ def _resolve_runtime_agent_kwargs() -> dict: } -def _try_resolve_fallback_provider() -> dict | None: - """Attempt to resolve credentials from the fallback_model/fallback_providers config.""" - from hermes_cli.runtime_provider import resolve_runtime_provider - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if not cfg_path.exists(): - return None - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_providers") or cfg.get("fallback_model") - if not fb: - return None - # Normalize to list - fb_list = fb if isinstance(fb, list) else [fb] - for entry in fb_list: - if not isinstance(entry, dict): - continue - try: - runtime = resolve_runtime_provider( - requested=entry.get("provider"), - explicit_base_url=entry.get("base_url"), - explicit_api_key=entry.get("api_key"), - ) - logger.info("Fallback provider resolved: %s", runtime.get("provider")) - return { - "api_key": runtime.get("api_key"), - "base_url": runtime.get("base_url"), - "provider": runtime.get("provider"), - "api_mode": runtime.get("api_mode"), - "command": runtime.get("command"), - "args": list(runtime.get("args") or []), - "credential_pool": runtime.get("credential_pool"), - } - except Exception as fb_exc: - logger.debug("Fallback entry %s failed: %s", entry.get("provider"), fb_exc) - continue - except Exception: - pass - return None - - def _build_media_placeholder(event) -> str: """Build a text placeholder for media-only events so they aren't dropped. @@ -577,33 +373,6 @@ def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None: return adapter.get_pending_message(session_key) -_INTERRUPT_REASON_STOP = "Stop requested" -_INTERRUPT_REASON_RESET = "Session reset requested" -_INTERRUPT_REASON_TIMEOUT = "Execution timed out (inactivity)" -_INTERRUPT_REASON_SSE_DISCONNECT = "SSE client disconnected" -_INTERRUPT_REASON_GATEWAY_SHUTDOWN = "Gateway shutting down" -_INTERRUPT_REASON_GATEWAY_RESTART = "Gateway restarting" - -_CONTROL_INTERRUPT_MESSAGES = frozenset( - { - _INTERRUPT_REASON_STOP.lower(), - _INTERRUPT_REASON_RESET.lower(), - _INTERRUPT_REASON_TIMEOUT.lower(), - _INTERRUPT_REASON_SSE_DISCONNECT.lower(), - _INTERRUPT_REASON_GATEWAY_SHUTDOWN.lower(), - _INTERRUPT_REASON_GATEWAY_RESTART.lower(), - } -) - - -def _is_control_interrupt_message(message: Optional[str]) -> bool: - """Return True when an interrupt message is internal control flow.""" - if not message: - return False - normalized = " ".join(str(message).strip().split()).lower() - return normalized in _CONTROL_INTERRUPT_MESSAGES - - def _check_unavailable_skill(command_name: str) -> str | None: """Check if a command matches a known-but-inactive skill. @@ -658,38 +427,22 @@ def _platform_config_key(platform: "Platform") -> str: def _load_gateway_config() -> dict: - """Load and parse ~/.hermes/config.yaml, returning {} on any error. - - Uses the module-level ``_hermes_home`` (so tests that monkeypatch it - still see their fixture) and shares the mtime-keyed raw-yaml cache - from ``hermes_cli.config.read_raw_config`` when the paths match. - """ - config_path = _hermes_home / 'config.yaml' - try: - from hermes_cli.config import get_config_path, read_raw_config - # Fast path: if _hermes_home agrees with the canonical config - # location, reuse the shared cache. Otherwise fall through to a - # direct read (keeps test fixtures with a monkeypatched - # _hermes_home working). - if config_path == get_config_path(): - return read_raw_config() - except Exception: - pass - + """Load and parse ~/.hermes/config.yaml, returning {} on any error.""" try: + config_path = _hermes_home / 'config.yaml' if config_path.exists(): import yaml with open(config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) or {} except Exception: - logger.debug("Could not load gateway config from %s", config_path) + logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml') return {} def _resolve_gateway_model(config: dict | None = None) -> str: """Read model from config.yaml — single source of truth. - Without this, temporary AIAgent instances (e.g. /compress) fall + Without this, temporary AIAgent instances (memory flush, /compress) fall back to the hardcoded default which fails when the active provider is openai-codex. """ @@ -729,47 +482,21 @@ def _resolve_hermes_bin() -> Optional[list[str]]: return None -def _parse_session_key(session_key: str) -> "dict | None": - """Parse a session key into its component parts. - - Session keys follow the format - ``agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]``. - Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and - optionally ``thread_id`` keys, or None if the key doesn't match. - - The 6th element is only returned as ``thread_id`` for chat types where - it is unambiguous (``dm`` and ``thread``). For group/channel sessions - the suffix may be a user_id (per-user isolation) rather than a - thread_id, so we leave ``thread_id`` out to avoid mis-routing. - """ - parts = session_key.split(":") - if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": - result = { - "platform": parts[2], - "chat_type": parts[3], - "chat_id": parts[4], - } - if len(parts) > 5 and parts[3] in ("dm", "thread"): - result["thread_id"] = parts[5] - return result - return None - - def _format_gateway_process_notification(evt: dict) -> "str | None": - """Format a watch pattern event from completion_queue into a [IMPORTANT:] message.""" + """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" evt_type = evt.get("type", "completion") _sid = evt.get("session_id", "unknown") _cmd = evt.get("command", "unknown") if evt_type == "watch_disabled": - return f"[IMPORTANT: {evt.get('message', '')}]" + return f"[SYSTEM: {evt.get('message', '')}]" if evt_type == "watch_match": _pat = evt.get("pattern", "?") _out = evt.get("output", "") _sup = evt.get("suppressed", 0) text = ( - f"[IMPORTANT: Background process {_sid} matched " + f"[SYSTEM: Background process {_sid} matched " f"watch pattern \"{_pat}\".\n" f"Command: {_cmd}\n" f"Matched output:\n{_out}" @@ -803,12 +530,10 @@ class GatewayRunner: _restart_via_service: bool = False _stop_task: Optional[asyncio.Task] = None _session_model_overrides: Dict[str, Dict[str, str]] = {} - _session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} def __init__(self, config: Optional[GatewayConfig] = None): self.config = config or load_gateway_config() self.adapters: Dict[Platform, BasePlatformAdapter] = {} - self._warn_if_docker_media_delivery_is_risky() # Load ephemeral config from config.yaml / env vars. # Both are injected at API-call time only and never persisted. @@ -821,6 +546,7 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._restart_drain_timeout = self._load_restart_drain_timeout() self._provider_routing = self._load_provider_routing() self._fallback_model = self._load_fallback_model() + self._smart_model_routing = self._load_smart_model_routing() # Wire process registry into session store for reset protection from tools.process_registry import process_registry @@ -847,39 +573,20 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._running_agents: Dict[str, Any] = {} self._running_agents_ts: Dict[str, float] = {} # start timestamp per session self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt - # Overflow buffer for explicit /queue commands. The adapter-level - # _pending_messages dict is a single slot per session (designed for - # "next-turn" follow-ups where repeated sends collapse into one - # event). /queue has different semantics: each invocation must - # produce its own full agent turn, in FIFO order, with no merging. - # When the slot is occupied, additional /queue items land here and - # are promoted one-at-a-time after each run's drain. Cleared on - # /new and /reset. /model and other mid-session operations - # preserve the queue. - self._queued_events: Dict[str, List[MessageEvent]] = {} self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce) - self._session_run_generation: Dict[str, int] = {} # Cache AIAgent instances per session to preserve prompt caching. # Without this, a new AIAgent is created per message, rebuilding the # system prompt (including memory) every turn — breaking prefix cache # and costing ~10x more on providers with prompt caching (Anthropic). # Key: session_key, Value: (AIAgent, config_signature_str) - # - # OrderedDict so _enforce_agent_cache_cap() can pop the least-recently- - # used entry (move_to_end() on cache hits, popitem(last=False) for - # eviction). Hard cap via _AGENT_CACHE_MAX_SIZE, idle TTL enforced - # from _session_expiry_watcher(). import threading as _threading - self._agent_cache: "OrderedDict[str, tuple]" = OrderedDict() + self._agent_cache: Dict[str, tuple] = {} self._agent_cache_lock = _threading.Lock() # Per-session model overrides from /model command. # Key: session_key, Value: dict with model/provider/api_key/base_url/api_mode self._session_model_overrides: Dict[str, Dict[str, str]] = {} - # Per-session reasoning effort overrides from /reasoning. - # Key: session_key, Value: parsed reasoning config dict. - self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} # Track pending exec approvals per session # Key: session_key, Value: {"command": str, "pattern_key": str, ...} self._pending_approvals: Dict[str, Dict[str, Any]] = {} @@ -912,43 +619,7 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._session_db = SessionDB() except Exception as e: logger.debug("SQLite session store not available: %s", e) - - # Opportunistic state.db maintenance: prune ended sessions older - # than sessions.retention_days + optional VACUUM. Tracks last-run - # in state_meta so it only actually executes once per - # sessions.min_interval_hours. Gateway is long-lived so blocking - # a few seconds once per day is acceptable; failures are logged - # but never raised. - if self._session_db is not None: - try: - from hermes_cli.config import load_config as _load_full_config - _sess_cfg = (_load_full_config().get("sessions") or {}) - if _sess_cfg.get("auto_prune", False): - self._session_db.maybe_auto_prune_and_vacuum( - retention_days=int(_sess_cfg.get("retention_days", 90)), - min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)), - vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)), - sessions_dir=self.config.sessions_dir, - ) - except Exception as exc: - logger.debug("state.db auto-maintenance skipped: %s", exc) - - # Opportunistic shadow-repo cleanup — deletes orphan/stale - # checkpoint repos under ~/.hermes/checkpoints/. Opt-in via - # checkpoints.auto_prune, idempotent via .last_prune marker. - try: - from hermes_cli.config import load_config as _load_full_config - _ckpt_cfg = (_load_full_config().get("checkpoints") or {}) - if _ckpt_cfg.get("auto_prune", False): - from tools.checkpoint_manager import maybe_auto_prune_checkpoints - maybe_auto_prune_checkpoints( - retention_days=int(_ckpt_cfg.get("retention_days", 7)), - min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)), - delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)), - ) - except Exception as exc: - logger.debug("checkpoint auto-maintenance skipped: %s", exc) - + # DM pairing store for code-based user authorization from gateway.pairing import PairingStore self.pairing_store = PairingStore() @@ -964,53 +635,6 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._background_tasks: set = set() - def _warn_if_docker_media_delivery_is_risky(self) -> None: - """Warn when Docker-backed gateways lack an explicit export mount. - - MEDIA delivery happens in the gateway process, so paths emitted by the model - must be readable from the host. A plain container-local path like - `/workspace/report.txt` or `/output/report.txt` often exists only inside - Docker, so users commonly need a dedicated export mount such as - `host-dir:/output`. - """ - if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker": - return - - connected = self.config.get_connected_platforms() - messaging_platforms = [p for p in connected if p not in {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK}] - if not messaging_platforms: - return - - raw_volumes = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip() - volumes: List[str] = [] - if raw_volumes: - try: - parsed = json.loads(raw_volumes) - if isinstance(parsed, list): - volumes = [str(v) for v in parsed if isinstance(v, str)] - except Exception: - logger.debug("Could not parse TERMINAL_DOCKER_VOLUMES for gateway media warning", exc_info=True) - - has_explicit_output_mount = False - for spec in volumes: - match = _DOCKER_VOLUME_SPEC_RE.match(spec) - if not match: - continue - container_path = match.group("container") - if container_path in _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS: - has_explicit_output_mount = True - break - - if has_explicit_output_mount: - return - - logger.warning( - "Docker backend is enabled for the messaging gateway but no explicit host-visible " - "output mount (for example '/home/user/.hermes/cache/documents:/output') is configured. " - "This is fine if the model already emits host-visible paths, but MEDIA file delivery can fail " - "for container-local paths like '/workspace/...' or '/output/...'." - ) - # -- Setup skill availability ---------------------------------------- @@ -1027,10 +651,6 @@ def _has_setup_skill(self) -> bool: _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json" - def _voice_key(self, platform: Platform, chat_id: str) -> str: - """Return a platform-namespaced key for voice mode state.""" - return f"{platform.value}:{chat_id}" - def _load_voice_modes(self) -> Dict[str, str]: try: data = json.loads(self._VOICE_MODE_PATH.read_text()) @@ -1041,21 +661,11 @@ def _load_voice_modes(self) -> Dict[str, str]: return {} valid_modes = {"off", "voice_only", "all"} - result = {} - for chat_id, mode in data.items(): - if mode not in valid_modes: - continue - key = str(chat_id) - # Skip legacy unprefixed keys (warn and skip) - if ":" not in key: - logger.warning( - "Skipping legacy unprefixed voice mode key %r during migration. " - "Re-enable voice mode on that chat to rebuild the prefixed key.", - key, - ) - continue - result[key] = mode - return result + return { + str(chat_id): mode + for chat_id, mode in data.items() + if mode in valid_modes + } def _save_voice_modes(self) -> None: try: @@ -1073,121 +683,138 @@ def _set_adapter_auto_tts_disabled(self, adapter, chat_id: str, disabled: bool) return if disabled: disabled_chats.add(chat_id) - # ``/voice off`` also clears any explicit enable — it's a hard override. - enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) - if isinstance(enabled_chats, set): - enabled_chats.discard(chat_id) else: disabled_chats.discard(chat_id) - def _set_adapter_auto_tts_enabled(self, adapter, chat_id: str, enabled: bool) -> None: - """Update an adapter's per-chat auto-TTS opt-in set if present. - - Used for ``/voice on``/``/voice tts`` where the user explicitly wants - auto-TTS even when ``voice.auto_tts`` is False globally. - """ - enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) - if not isinstance(enabled_chats, set): + def _sync_voice_mode_state_to_adapter(self, adapter) -> None: + """Restore persisted /voice off state into a live platform adapter.""" + disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) + if not isinstance(disabled_chats, set): return - if enabled: - enabled_chats.add(chat_id) - # An explicit opt-in clears any stale /voice off for this chat. - disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) - if isinstance(disabled_chats, set): - disabled_chats.discard(chat_id) - else: - enabled_chats.discard(chat_id) + disabled_chats.clear() + disabled_chats.update( + chat_id for chat_id, mode in self._voice_mode.items() if mode == "off" + ) - def _sync_voice_mode_state_to_adapter(self, adapter) -> None: - """Restore persisted /voice state into a live platform adapter. + # ----------------------------------------------------------------- - Populates three fields from config + ``self._voice_mode``: - - ``_auto_tts_default``: global default from ``voice.auto_tts`` - - ``_auto_tts_enabled_chats``: chats with mode ``voice_only``/``all`` - - ``_auto_tts_disabled_chats``: chats with mode ``off`` - """ - platform = getattr(adapter, "platform", None) - if not isinstance(platform, Platform): - return + def _flush_memories_for_session( + self, + old_session_id: str, + session_key: Optional[str] = None, + ): + """Prompt the agent to save memories/skills before context is lost. - disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) - enabled_chats = getattr(adapter, "_auto_tts_enabled_chats", None) - if not isinstance(disabled_chats, set) and not isinstance(enabled_chats, set): + Synchronous worker — meant to be called via run_in_executor from + an async context so it doesn't block the event loop. + """ + # Skip cron sessions — they run headless with no meaningful user + # conversation to extract memories from. + if old_session_id and old_session_id.startswith("cron_"): + logger.debug("Skipping memory flush for cron session: %s", old_session_id) return - # Push the global voice.auto_tts default (config.yaml) onto the adapter. - # Lazy import to avoid adding a module-level dep from gateway → hermes_cli. try: - from hermes_cli.config import load_config as _load_full_config - _full_cfg = _load_full_config() - _auto_tts_default = bool( - (_full_cfg.get("voice") or {}).get("auto_tts", False) - ) - except Exception: - _auto_tts_default = False - if hasattr(adapter, "_auto_tts_default"): - adapter._auto_tts_default = _auto_tts_default - - prefix = f"{platform.value}:" - if isinstance(disabled_chats, set): - disabled_chats.clear() - disabled_chats.update( - key[len(prefix):] for key, mode in self._voice_mode.items() - if mode == "off" and key.startswith(prefix) - ) - if isinstance(enabled_chats, set): - enabled_chats.clear() - enabled_chats.update( - key[len(prefix):] for key, mode in self._voice_mode.items() - if mode in ("voice_only", "all") and key.startswith(prefix) + history = self.session_store.load_transcript(old_session_id) + if not history or len(history) < 4: + return + + from run_agent import AIAgent + model, runtime_kwargs = self._resolve_session_agent_runtime( + session_key=session_key, ) + if not runtime_kwargs.get("api_key"): + return - async def _safe_adapter_disconnect(self, adapter, platform) -> None: - """Call adapter.disconnect() defensively, swallowing any error. + tmp_agent = AIAgent( + **runtime_kwargs, + model=model, + max_iterations=8, + quiet_mode=True, + skip_memory=True, # Flush agent — no memory provider + enabled_toolsets=["memory", "skills"], + session_id=old_session_id, + ) + # Fully silence the flush agent — quiet_mode only suppresses init + # messages; tool call output still leaks to the terminal through + # _safe_print → _print_fn. Set a no-op to prevent that. + tmp_agent._print_fn = lambda *a, **kw: None - Used when adapter.connect() failed or raised — the adapter may - have allocated partial resources (aiohttp.ClientSession, poll - tasks, child subprocesses) that would otherwise leak and surface - as "Unclosed client session" warnings at process exit. + # Build conversation history from transcript + msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in ("user", "assistant") and m.get("content") + ] - Must tolerate partial-init state and never raise, since callers - use it inside error-handling blocks. - """ - try: - await adapter.disconnect() - except Exception as e: - logger.debug( - "Defensive %s disconnect after failed connect raised: %s", - platform.value if platform is not None else "adapter", - e, + # Read live memory state from disk so the flush agent can see + # what's already saved and avoid overwriting newer entries. + _current_memory = "" + try: + from tools.memory_tool import get_memory_dir + _mem_dir = get_memory_dir() + for fname, label in [ + ("MEMORY.md", "MEMORY (your personal notes)"), + ("USER.md", "USER PROFILE (who the user is)"), + ]: + fpath = _mem_dir / fname + if fpath.exists(): + content = fpath.read_text(encoding="utf-8").strip() + if content: + _current_memory += f"\n\n## Current {label}:\n{content}" + except Exception: + pass # Non-fatal — flush still works, just without the guard + + # Give the agent a real turn to think about what to save + flush_prompt = ( + "[System: This session is about to be automatically reset due to " + "inactivity or a scheduled daily reset. The conversation context " + "will be cleared after this turn.\n\n" + "Review the conversation above and:\n" + "1. Save any important facts, preferences, or decisions to memory " + "(user profile or your notes) that would be useful in future sessions.\n" + "2. If you discovered a reusable workflow or solved a non-trivial " + "problem, consider saving it as a skill.\n" + "3. If nothing is worth saving, that's fine — just skip.\n\n" ) - def _platform_connect_timeout_secs(self) -> float: - """Return the per-platform connect timeout used during startup/retry.""" - raw = os.getenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "").strip() - if raw: - try: - timeout = float(raw) - except ValueError: - logger.warning( - "Ignoring invalid HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT=%r", - raw, + if _current_memory: + flush_prompt += ( + "IMPORTANT — here is the current live state of memory. Other " + "sessions, cron jobs, or the user may have updated it since this " + "conversation ended. Do NOT overwrite or remove entries unless " + "the conversation above reveals something that genuinely " + "supersedes them. Only add new information that is not already " + "captured below." + f"{_current_memory}\n\n" ) - else: - return max(0.0, timeout) - return _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT - async def _connect_adapter_with_timeout(self, adapter, platform) -> bool: - """Connect an adapter without allowing one platform to block others.""" - timeout = self._platform_connect_timeout_secs() - if timeout <= 0: - return await adapter.connect() - try: - return await asyncio.wait_for(adapter.connect(), timeout=timeout) - except asyncio.TimeoutError as exc: - raise TimeoutError( - f"{platform.value} connect timed out after {timeout:g}s" - ) from exc + flush_prompt += ( + "Do NOT respond to the user. Just use the memory and skill_manage " + "tools if needed, then stop.]" + ) + + tmp_agent.run_conversation( + user_message=flush_prompt, + conversation_history=msgs, + ) + logger.info("Pre-reset memory flush completed for session %s", old_session_id) + except Exception as e: + logger.debug("Pre-reset memory flush failed for session %s: %s", old_session_id, e) + + async def _async_flush_memories( + self, + old_session_id: str, + session_key: Optional[str] = None, + ): + """Run the sync memory flush in a thread pool so it won't block the event loop.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + self._flush_memories_for_session, + old_session_id, + session_key, + ) @property def should_exit_cleanly(self) -> bool: @@ -1254,7 +881,7 @@ def _resolve_session_agent_runtime( if override_runtime.get("api_key"): logger.debug( "Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s", - resolved_session_key or "", model, override_model, + (resolved_session_key or "")[:30], model, override_model, override_runtime.get("provider"), ) return override_model, override_runtime @@ -1262,12 +889,12 @@ def _resolve_session_agent_runtime( # resolution and apply model/provider from the override on top. logger.debug( "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s", - resolved_session_key or "", model, override_model, + (resolved_session_key or "")[:30], model, override_model, ) else: logger.debug( "No session model override: session=%s config_model=%s override_keys=%s", - resolved_session_key or "", model, + (resolved_session_key or "")[:30], model, list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]", ) @@ -1296,16 +923,11 @@ def _resolve_session_agent_runtime( return model, runtime_kwargs def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: - """Build the effective model/runtime config for a single turn. - - Always uses the session's primary model/provider. If `/fast` is - enabled and the model supports Priority Processing / Anthropic fast - mode, attach `request_overrides` so the API call is marked - accordingly. - """ + from agent.smart_model_routing import resolve_turn_route from hermes_cli.models import resolve_fast_mode_overrides - runtime = { + primary = { + "model": model, "api_key": runtime_kwargs.get("api_key"), "base_url": runtime_kwargs.get("base_url"), "provider": runtime_kwargs.get("provider"), @@ -1314,29 +936,18 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar "args": list(runtime_kwargs.get("args") or []), "credential_pool": runtime_kwargs.get("credential_pool"), } - route = { - "model": model, - "runtime": runtime, - "signature": ( - model, - runtime["provider"], - runtime["base_url"], - runtime["api_mode"], - runtime["command"], - tuple(runtime["args"]), - ), - } + route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) service_tier = getattr(self, "_service_tier", None) if not service_tier: - route["request_overrides"] = {} + route["request_overrides"] = None return route try: - overrides = resolve_fast_mode_overrides(route["model"]) + overrides = resolve_fast_mode_overrides(route.get("model")) except Exception: overrides = None - route["request_overrides"] = overrides or {} + route["request_overrides"] = overrides return route async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None: @@ -1421,80 +1032,7 @@ def _status_action_gerund(self) -> str: return "restarting" if self._restart_requested else "shutting down" def _queue_during_drain_enabled(self) -> bool: - # Both "queue" and "steer" modes imply the user doesn't want messages - # to be lost during restart — queue them for the newly-spawned gateway - # process to pick up. "interrupt" mode drops them (current behaviour). - return self._restart_requested and self._busy_input_mode in ("queue", "steer") - - # -------- /queue FIFO helpers -------------------------------------- - # /queue must produce one full agent turn per invocation, in FIFO - # order, with no merging. The adapter's _pending_messages dict is a - # single "next-up" slot (shared with photo-burst follow-ups), so we - # use it for the head of the queue and an overflow list for the - # tail. Enqueue puts new items in the slot when free, otherwise in - # the overflow. Promotion (called after each run's drain) moves the - # next overflow item into the slot so the following recursion picks - # it up. Clearing happens on /new and /reset via - # _handle_reset_command. - - def _enqueue_fifo(self, session_key: str, queued_event: "MessageEvent", adapter: Any) -> None: - """Append a /queue event to the FIFO chain for a session.""" - if adapter is None: - return - pending_slot = getattr(adapter, "_pending_messages", None) - if pending_slot is None: - return - queued_events = getattr(self, "_queued_events", None) - if queued_events is None: - queued_events = {} - self._queued_events = queued_events - if session_key in pending_slot: - queued_events.setdefault(session_key, []).append(queued_event) - else: - pending_slot[session_key] = queued_event - - def _promote_queued_event( - self, - session_key: str, - adapter: Any, - pending_event: Optional["MessageEvent"], - ) -> Optional["MessageEvent"]: - """Promote the next overflow item after the slot was drained. - - Called at the drain site after _dequeue_pending_event consumed - (or failed to consume) the slot. If there's an overflow item: - - When pending_event is None (slot was empty), return the - overflow head as the new pending_event. - - When pending_event already exists (slot was populated by an - interrupt follow-up or similar), stage the overflow head in - the slot so the NEXT recursion picks it up. - Returns the (possibly updated) pending_event for drain to use. - """ - queued_events = getattr(self, "_queued_events", None) - if not queued_events: - return pending_event - overflow = queued_events.get(session_key) - if not overflow: - return pending_event - next_queued = overflow.pop(0) - if not overflow: - queued_events.pop(session_key, None) - if pending_event is None: - return next_queued - if adapter is not None and hasattr(adapter, "_pending_messages"): - adapter._pending_messages[session_key] = next_queued - else: - # No adapter — push back so we don't silently drop the item. - queued_events.setdefault(session_key, []).insert(0, next_queued) - return pending_event - - def _queue_depth(self, session_key: str, *, adapter: Any = None) -> int: - """Total pending /queue items for a session — slot + overflow.""" - queued_events = getattr(self, "_queued_events", None) or {} - depth = len(queued_events.get(session_key, [])) - if adapter is not None and session_key in getattr(adapter, "_pending_messages", {}): - depth += 1 - return depth + return self._restart_requested and self._busy_input_mode == "queue" def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None: try: @@ -1535,6 +1073,7 @@ def _load_prefill_messages() -> List[Dict[str, Any]]: the prefill_messages_file key in ~/.hermes/config.yaml. Relative paths are resolved from ~/.hermes/. """ + import json as _json file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") if not file_path: try: @@ -1556,7 +1095,7 @@ def _load_prefill_messages() -> List[Dict[str, Any]]: return [] try: with open(path, "r", encoding="utf-8") as f: - data = json.load(f) + data = _json.load(f) if not isinstance(data, list): logger.warning("Prefill messages file must contain a JSON array: %s", path) return [] @@ -1581,7 +1120,7 @@ def _load_ephemeral_system_prompt() -> str: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - return (cfg_get(cfg, "agent", "system_prompt", default="") or "").strip() + return (cfg.get("agent", {}).get("system_prompt", "") or "").strip() except Exception: pass return "" @@ -1602,7 +1141,7 @@ def _load_reasoning_config() -> dict | None: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip() + effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip() except Exception: pass result = parse_reasoning_effort(effort) @@ -1610,66 +1149,6 @@ def _load_reasoning_config() -> dict | None: logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) return result - @staticmethod - def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]: - """Parse `/reasoning` args into `(value, persist_global)`. - - `/reasoning ` is session-scoped by default. `--global` may be - supplied in any position to persist the change to config.yaml. - """ - import shlex - - text = str(raw_args or "").strip().replace("—", "--") - if not text: - return "", False - try: - tokens = shlex.split(text) - except ValueError: - tokens = text.split() - - persist_global = False - value_tokens = [] - for token in tokens: - if token == "--global": - persist_global = True - else: - value_tokens.append(token) - return " ".join(value_tokens).strip().lower(), persist_global - - def _resolve_session_reasoning_config( - self, - *, - source: Optional[SessionSource] = None, - session_key: Optional[str] = None, - ) -> dict | None: - """Resolve reasoning effort for a session, honoring session overrides.""" - resolved_session_key = session_key - if not resolved_session_key and source is not None: - try: - resolved_session_key = self._session_key_for_source(source) - except Exception: - resolved_session_key = None - - overrides = getattr(self, "_session_reasoning_overrides", {}) or {} - if resolved_session_key and resolved_session_key in overrides: - return overrides[resolved_session_key] - return self._load_reasoning_config() - - def _set_session_reasoning_override( - self, - session_key: str, - reasoning_config: Optional[dict], - ) -> None: - """Set or clear the session-scoped reasoning override.""" - if not session_key: - return - if not hasattr(self, "_session_reasoning_overrides"): - self._session_reasoning_overrides = {} - if reasoning_config is None: - self._session_reasoning_overrides.pop(session_key, None) - else: - self._session_reasoning_overrides[session_key] = dict(reasoning_config) - @staticmethod def _load_service_tier() -> str | None: """Load Priority Processing setting from config.yaml. @@ -1685,7 +1164,7 @@ def _load_service_tier() -> str | None: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - raw = str(cfg_get(cfg, "agent", "service_tier", default="") or "").strip() + raw = str(cfg.get("agent", {}).get("service_tier", "") or "").strip() except Exception: pass @@ -1706,7 +1185,7 @@ def _load_show_reasoning() -> bool: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - return bool(cfg_get(cfg, "display", "show_reasoning", default=False)) + return bool(cfg.get("display", {}).get("show_reasoning", False)) except Exception: pass return False @@ -1722,14 +1201,10 @@ def _load_busy_input_mode() -> str: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - mode = str(cfg_get(cfg, "display", "busy_input_mode", default="") or "").strip().lower() + mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower() except Exception: pass - if mode == "queue": - return "queue" - if mode == "steer": - return "steer" - return "interrupt" + return "queue" if mode == "queue" else "interrupt" @staticmethod def _load_restart_drain_timeout() -> float: @@ -1742,7 +1217,7 @@ def _load_restart_drain_timeout() -> float: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - raw = str(cfg_get(cfg, "agent", "restart_drain_timeout", default="") or "").strip() + raw = str(cfg.get("agent", {}).get("restart_drain_timeout", "") or "").strip() except Exception: pass value = parse_restart_drain_timeout(raw) @@ -1775,7 +1250,7 @@ def _load_background_notifications_mode() -> str: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - raw = cfg_get(cfg, "display", "background_process_notifications") + raw = cfg.get("display", {}).get("background_process_notifications") if raw is False: mode = "off" elif raw not in (None, ""): @@ -1827,6 +1302,20 @@ def _load_fallback_model() -> list | dict | None: pass return None + @staticmethod + def _load_smart_model_routing() -> dict: + """Load optional smart cheap-vs-strong model routing config.""" + try: + import yaml as _y + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as _f: + cfg = _y.safe_load(_f) or {} + return cfg.get("smart_model_routing", {}) or {} + except Exception: + pass + return {} + def _snapshot_running_agents(self) -> Dict[str, Any]: return { session_key: agent @@ -1862,51 +1351,27 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session ) return True - # Normal busy case (agent actively running a task) + # --- Normal busy case (agent actively running a task) --- + # The user sent a message while the agent is working. Interrupt the + # agent immediately so it stops the current tool-calling loop and + # processes the new message. The pending message is stored in the + # adapter so the base adapter picks it up once the interrupted run + # returns. A brief ack tells the user what's happening (debounced + # to avoid spam when they fire multiple messages quickly). + adapter = self.adapters.get(event.source.platform) if not adapter: return False # let default path handle it - running_agent = self._running_agents.get(session_key) - - # Steer mode: inject mid-run via running_agent.steer() instead of - # queueing + interrupting. If the agent isn't running yet - # (sentinel) or lacks steer(), or the payload is empty, fall back - # to queue semantics so nothing is lost. - effective_mode = self._busy_input_mode - steered = False - if effective_mode == "steer": - steer_text = (event.text or "").strip() - can_steer = ( - steer_text - and running_agent is not None - and running_agent is not _AGENT_PENDING_SENTINEL - and hasattr(running_agent, "steer") - ) - if can_steer: - try: - steered = bool(running_agent.steer(steer_text)) - except Exception as exc: - logger.warning("Gateway steer failed for session %s: %s", session_key, exc) - steered = False - if not steered: - # Fall back to queue (merge into pending messages, no interrupt) - effective_mode = "queue" - # Store the message so it's processed as the next turn after the - # current run finishes (or is interrupted). Skip this for a - # successful steer — the text already landed inside the run and - # must NOT also be replayed as a next-turn user message. - if not steered: - merge_pending_message_event(adapter._pending_messages, session_key, event) - - is_queue_mode = effective_mode == "queue" - is_steer_mode = effective_mode == "steer" - - # If not in queue/steer mode, interrupt the running agent immediately. - # This aborts in-flight tool calls and causes the agent loop to exit - # at the next check point. - if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + # interrupt causes the current run to exit. + from gateway.platforms.base import merge_pending_message_event + merge_pending_message_event(adapter._pending_messages, session_key, event) + + # Interrupt the running agent — this aborts in-flight tool calls and + # causes the agent loop to exit at the next check point. + running_agent = self._running_agents.get(session_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: try: running_agent.interrupt(event.text) except Exception: @@ -1918,7 +1383,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session now = time.time() last_ack = self._busy_ack_ts.get(session_key, 0) if now - last_ack < _BUSY_ACK_COOLDOWN: - return True # interrupt sent (if not queue), ack already delivered recently + return True # interrupt sent, ack already delivered recently self._busy_ack_ts[session_key] = now @@ -1943,48 +1408,10 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session pass status_detail = f" ({', '.join(status_parts)})" if status_parts else "" - if is_steer_mode: - message = ( - f"⏩ Steered into current run{status_detail}. " - f"Your message arrives after the next tool call." - ) - elif is_queue_mode: - message = ( - f"⏳ Queued for the next turn{status_detail}. " - f"I'll respond once the current task finishes." - ) - else: - message = ( - f"⚡ Interrupting current task{status_detail}. " - f"I'll respond to your message shortly." - ) - - # First-touch onboarding: the very first time a user sends a message - # while the agent is busy, append a one-time hint explaining the - # queue/interrupt knob. Flag is persisted to config.yaml so it never - # fires again on this install. - try: - from agent.onboarding import ( - BUSY_INPUT_FLAG, - busy_input_hint_gateway, - is_seen, - mark_seen, - ) - _user_cfg = _load_gateway_config() - if not is_seen(_user_cfg, BUSY_INPUT_FLAG): - if is_steer_mode: - _hint_mode = "steer" - elif is_queue_mode: - _hint_mode = "queue" - else: - _hint_mode = "interrupt" - message = ( - f"{message}\n\n" - f"{busy_input_hint_gateway(_hint_mode)}" - ) - mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) - except Exception as _onb_err: - logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err) + message = ( + f"⚡ Interrupting current task{status_detail}. " + f"I'll respond to your message shortly." + ) thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None try: @@ -2035,7 +1462,7 @@ def _interrupt_running_agents(self, reason: str) -> None: continue try: agent.interrupt(reason) - logger.debug("Interrupted running agent for session %s during shutdown", session_key) + logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20]) except Exception as e: logger.debug("Failed interrupting agent during shutdown: %s", e) @@ -2053,7 +1480,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None: action = "restarting" if self._restart_requested else "shutting down" hint = ( "Your current task will be interrupted. " - "Send any message after restart and I'll try to resume where you left off." + "Send any message after restart to resume where it left off." if self._restart_requested else "Your current task will be interrupted." ) @@ -2061,32 +1488,13 @@ async def _notify_active_sessions_of_shutdown(self) -> None: notified: set = set() for session_key in active: - source = None - try: - if getattr(self, "session_store", None) is not None: - self.session_store._ensure_loaded() - entry = self.session_store._entries.get(session_key) - source = getattr(entry, "origin", None) if entry else None - except Exception as e: - logger.debug( - "Failed to load session origin for shutdown notification %s: %s", - session_key, - e, - ) - - if source is not None: - platform_str = source.platform.value - chat_id = source.chat_id - thread_id = source.thread_id - else: - # Fall back to parsing the session key when no persisted - # origin is available (legacy sessions/tests). - _parsed = _parse_session_key(session_key) - if not _parsed: - continue - platform_str = _parsed["platform"] - chat_id = _parsed["chat_id"] - thread_id = _parsed.get("thread_id") + # Parse platform + chat_id from the session key. + # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...] + parts = session_key.split(":") + if len(parts) < 5: + continue + platform_str = parts[2] + chat_id = parts[4] # Deduplicate: one notification per chat, even if multiple # sessions (different users/threads) share the same chat. @@ -2102,6 +1510,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None: # Include thread_id if present so the message lands in the # correct forum topic / thread. + thread_id = parts[5] if len(parts) > 5 else None metadata = {"thread_id": thread_id} if thread_id else None await adapter.send(chat_id, msg, metadata=metadata) @@ -2127,48 +1536,19 @@ def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None: ) except Exception: pass - self._cleanup_agent_resources(agent) - - def _cleanup_agent_resources(self, agent: Any) -> None: - """Best-effort cleanup for temporary or cached agent instances.""" - if agent is None: - return - try: - if hasattr(agent, "shutdown_memory_provider"): - # Pass the agent's own conversation transcript so memory - # providers' ``on_session_end`` hooks see the real messages - # instead of the empty default (#15165). ``_session_messages`` - # is set on ``AIAgent`` (run_agent.py:1518) and refreshed at - # the end of every ``run_conversation`` turn via - # ``_persist_session``; on an agent built through - # ``object.__new__`` (test stubs) the attribute may be - # absent, so ``getattr`` with a ``None`` default keeps the - # call signature-compatible with the pre-fix behaviour - # (``shutdown_memory_provider(messages=None)``). - session_messages = getattr(agent, "_session_messages", None) - if isinstance(session_messages, list): - agent.shutdown_memory_provider(session_messages) - else: + try: + if hasattr(agent, "shutdown_memory_provider"): agent.shutdown_memory_provider() - except Exception: - pass - # Close tool resources (terminal sandboxes, browser daemons, - # background processes, httpx clients) to prevent zombie - # process accumulation. - try: - if hasattr(agent, "close"): - agent.close() - except Exception: - pass - # Auxiliary async clients (session_search/web/vision/etc.) live in a - # process-global cache and are created inside worker threads. Clean up - # any entries whose event loop is now dead so their httpx transports do - # not accumulate across gateway turns. - try: - from agent.auxiliary_client import cleanup_stale_async_clients - cleanup_stale_async_clients() - except Exception: - pass + except Exception: + pass + # Close tool resources (terminal sandboxes, browser daemons, + # background processes, httpx clients) to prevent zombie + # process accumulation. + try: + if hasattr(agent, 'close'): + agent.close() + except Exception: + pass _STUCK_LOOP_THRESHOLD = 3 # restarts while active before auto-suspend _STUCK_LOOP_FILE = ".restart_failure_counts" @@ -2230,7 +1610,7 @@ def _suspend_stuck_loop_sessions(self) -> int: logger.warning( "Auto-suspended stuck session %s (active across %d " "consecutive restarts — likely a stuck loop)", - session_key, counts[session_key], + session_key[:30], counts[session_key], ) except Exception: pass @@ -2354,7 +1734,6 @@ async def start(self) -> bool: "WEIXIN_ALLOWED_USERS", "BLUEBUBBLES_ALLOWED_USERS", "QQ_ALLOWED_USERS", - "YUANBAO_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( @@ -2369,8 +1748,7 @@ async def start(self) -> bool: "WECOM_CALLBACK_ALLOW_ALL_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_ALLOW_ALL_USERS", - "QQ_ALLOW_ALL_USERS", - "YUANBAO_ALLOW_ALL_USERS") + "QQ_ALLOW_ALL_USERS") ) if not _any_allowlist and not _allow_all: logger.warning( @@ -2379,42 +1757,8 @@ async def start(self) -> bool: "or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)." ) - # Discover Python plugins before shell hooks so plugin block - # decisions take precedence in tie cases. The CLI startup path - # does this via an explicit call in hermes_cli/main.py; the - # gateway lazily imports run_agent inside per-request handlers, - # so the discover_plugins() side-effect in model_tools.py is NOT - # guaranteed to have run by the time we reach this point. - try: - from hermes_cli.plugins import discover_plugins - discover_plugins() - except Exception: - logger.debug( - "plugin discovery failed at gateway startup", exc_info=True, - ) - - # Register declarative shell hooks from cli-config.yaml. Gateway - # has no TTY, so consent has to come from one of the three opt-in - # channels (--accept-hooks on launch, HERMES_ACCEPT_HOOKS env var, - # or hooks_auto_accept: true in config.yaml). We pass - # accept_hooks=False here and let register_from_config resolve - # the effective value from env + config itself — the CLI-side - # registration already honored --accept-hooks, and re-reading - # hooks_auto_accept here would just duplicate that lookup. - # Failures are logged but must never block gateway startup. - try: - from hermes_cli.config import load_config - from agent.shell_hooks import register_from_config - register_from_config(load_config(), accept_hooks=False) - except Exception: - logger.debug( - "shell-hook registration failed at gateway startup", - exc_info=True, - ) - # Discover and load event hooks self.hooks.discover_and_load() - # Recover background processes from checkpoint (crash recovery) try: @@ -2491,7 +1835,7 @@ async def start(self) -> bool: error_message=None, ) try: - success = await self._connect_adapter_with_timeout(adapter, platform) + success = await adapter.connect() if success: self.adapters[platform] = adapter self._sync_voice_mode_state_to_adapter(adapter) @@ -2505,15 +1849,6 @@ async def start(self) -> bool: logger.info("✓ %s connected", platform.value) else: logger.warning("✗ %s failed to connect", platform.value) - # Defensive cleanup: a failed connect() may have - # allocated resources (aiohttp.ClientSession, poll - # tasks, bridge subprocesses) before giving up. - # Without this call, those resources are orphaned - # and Python logs "Unclosed client session" at - # process exit. Adapter disconnect() implementations - # are expected to be idempotent and tolerate - # partial-init state. - await self._safe_adapter_disconnect(adapter, platform) if adapter.has_fatal_error: self._update_platform_runtime_status( platform.value, @@ -2554,10 +1889,6 @@ async def start(self) -> bool: } except Exception as e: logger.error("✗ %s error: %s", platform.value, e) - # Same defensive cleanup path for exceptions — an adapter - # that raised mid-connect may still have a live - # aiohttp.ClientSession or child subprocess. - await self._safe_adapter_disconnect(adapter, platform) self._update_platform_runtime_status( platform.value, platform_state="retrying", @@ -2646,7 +1977,7 @@ async def start(self) -> bool: except Exception as e: logger.error("Recovered watcher setup error: %s", e) - # Start background session expiry watcher to finalize expired sessions + # Start background session expiry watcher for proactive memory flushing asyncio.create_task(self._session_expiry_watcher()) # Start background reconnection watcher for platforms that failed at startup @@ -2663,24 +1994,25 @@ async def start(self) -> bool: return True async def _session_expiry_watcher(self, interval: int = 300): - """Background task that finalizes expired sessions. + """Background task that proactively flushes memories for expired sessions. + + Runs every `interval` seconds (default 5 min). For each session that + has expired according to its reset policy, flushes memories in a thread + pool and marks the session so it won't be flushed again. - Runs every ``interval`` seconds (default 5 min). For each session - whose reset policy has expired, invokes ``on_session_finalize`` - hooks, cleans up the cached AIAgent's tool resources, evicts the - cache entry so it can be garbage-collected, and marks the session - so it won't be finalized again. + This means memories are already saved by the time the user sends their + next message, so there's no blocking delay. """ await asyncio.sleep(60) # initial delay — let the gateway fully start - _finalize_failures: dict[str, int] = {} # session_id -> consecutive failure count - _MAX_FINALIZE_RETRIES = 3 + _flush_failures: dict[str, int] = {} # session_id -> consecutive failure count + _MAX_FLUSH_RETRIES = 3 while self._running: try: self.session_store._ensure_loaded() # Collect expired sessions first, then log a single summary. _expired_entries = [] for key, entry in list(self.session_store._entries.items()): - if entry.expiry_finalized: + if entry.memory_flushed: continue if not self.session_store._is_session_expired(entry): continue @@ -2698,23 +2030,13 @@ async def _session_expiry_watcher(self, interval: int = 300): f"{p}:{c}" for p, c in sorted(_platforms.items()) ) logger.info( - "Session expiry: %d sessions to finalize (%s)", + "Session expiry: %d sessions to flush (%s)", len(_expired_entries), _plat_summary, ) for key, entry in _expired_entries: try: - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _parts = key.split(":") - _platform = _parts[2] if len(_parts) > 2 else "" - _invoke_hook( - "on_session_finalize", - session_id=entry.session_id, - platform=_platform, - ) - except Exception: - pass + await self._async_flush_memories(entry.session_id, key) # Shut down memory provider and close tool resources # on the cached agent. Idle agents live in # _agent_cache (not _running_agents), so look there. @@ -2729,93 +2051,59 @@ async def _session_expiry_watcher(self, interval: int = 300): if _cached_agent is None: _cached_agent = self._running_agents.get(key) if _cached_agent and _cached_agent is not _AGENT_PENDING_SENTINEL: - self._cleanup_agent_resources(_cached_agent) - # Drop the cache entry so the AIAgent (and its LLM - # clients, tool schemas, memory provider refs) can - # be garbage-collected. Otherwise the cache grows - # unbounded across the gateway's lifetime. - self._evict_cached_agent(key) - # Mark as finalized and persist to disk so the flag + try: + if hasattr(_cached_agent, 'shutdown_memory_provider'): + _cached_agent.shutdown_memory_provider() + except Exception: + pass + try: + if hasattr(_cached_agent, 'close'): + _cached_agent.close() + except Exception: + pass + # Mark as flushed and persist to disk so the flag # survives gateway restarts. with self.session_store._lock: - entry.expiry_finalized = True + entry.memory_flushed = True self.session_store._save() logger.debug( - "Session expiry finalized for %s", + "Memory flush completed for session %s", entry.session_id, ) - _finalize_failures.pop(entry.session_id, None) + _flush_failures.pop(entry.session_id, None) except Exception as e: - failures = _finalize_failures.get(entry.session_id, 0) + 1 - _finalize_failures[entry.session_id] = failures - if failures >= _MAX_FINALIZE_RETRIES: + failures = _flush_failures.get(entry.session_id, 0) + 1 + _flush_failures[entry.session_id] = failures + if failures >= _MAX_FLUSH_RETRIES: logger.warning( - "Session finalize gave up after %d attempts for %s: %s. " - "Marking as finalized to prevent infinite retry loop.", + "Memory flush gave up after %d attempts for %s: %s. " + "Marking as flushed to prevent infinite retry loop.", failures, entry.session_id, e, ) with self.session_store._lock: - entry.expiry_finalized = True + entry.memory_flushed = True self.session_store._save() - _finalize_failures.pop(entry.session_id, None) + _flush_failures.pop(entry.session_id, None) else: logger.debug( - "Session finalize failed (%d/%d) for %s: %s", - failures, _MAX_FINALIZE_RETRIES, entry.session_id, e, + "Memory flush failed (%d/%d) for %s: %s", + failures, _MAX_FLUSH_RETRIES, entry.session_id, e, ) if _expired_entries: - _done = sum( - 1 for _, e in _expired_entries if e.expiry_finalized + _flushed = sum( + 1 for _, e in _expired_entries if e.memory_flushed ) - _failed = len(_expired_entries) - _done + _failed = len(_expired_entries) - _flushed if _failed: logger.info( - "Session expiry done: %d finalized, %d pending retry", - _done, _failed, + "Session expiry done: %d flushed, %d pending retry", + _flushed, _failed, ) else: logger.info( - "Session expiry done: %d finalized", _done, + "Session expiry done: %d flushed", _flushed, ) - - # Sweep agents that have been idle beyond the TTL regardless - # of session reset policy. This catches sessions with very - # long / "never" reset windows, whose cached AIAgents would - # otherwise pin memory for the gateway's entire lifetime. - try: - _idle_evicted = self._sweep_idle_cached_agents() - if _idle_evicted: - logger.info( - "Agent cache idle sweep: evicted %d agent(s)", - _idle_evicted, - ) - except Exception as _e: - logger.debug("Idle agent sweep failed: %s", _e) - - # Periodically prune stale SessionStore entries. The - # in-memory dict (and sessions.json) would otherwise grow - # unbounded in gateways serving many rotating chats / - # threads / users over long time windows. Pruning is - # invisible to users — a resumed session just gets a - # fresh session_id, exactly as if the reset policy fired. - _last_prune_ts = getattr(self, "_last_session_store_prune_ts", 0.0) - _prune_interval = 3600.0 # once per hour - if time.time() - _last_prune_ts > _prune_interval: - try: - _max_age = int( - getattr(self.config, "session_store_max_age_days", 0) or 0 - ) - if _max_age > 0: - _pruned = self.session_store.prune_old_entries(_max_age) - if _pruned: - logger.info( - "SessionStore prune: dropped %d stale entries", - _pruned, - ) - except Exception as _e: - logger.debug("SessionStore prune failed: %s", _e) - self._last_session_store_prune_ts = time.time() except Exception as e: logger.debug("Session expiry watcher error: %s", e) # Sleep in small increments so we can stop quickly @@ -2882,7 +2170,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) - success = await self._connect_adapter_with_timeout(adapter, platform) + success = await adapter.connect() if success: self.adapters[platform] = adapter self._sync_voice_mode_state_to_adapter(adapter) @@ -2899,7 +2187,7 @@ async def _platform_reconnect_watcher(self) -> None: # Rebuild channel directory with the new adapter try: from gateway.channel_directory import build_channel_directory - await build_channel_directory(self.adapters) + directory = await build_channel_directory(self.adapters) except Exception: pass else: @@ -2968,40 +2256,6 @@ async def stop( return async def _stop_impl() -> None: - def _kill_tool_subprocesses(phase: str) -> None: - """Kill tool subprocesses + tear down terminal envs + browsers. - - Called twice in the shutdown path: once eagerly after a - drain timeout forces agent interrupt (so we reclaim bash/ - sleep children before systemd TimeoutStopSec escalates to - SIGKILL on the cgroup — #8202), and once as a final - catch-all at the end of _stop_impl() for the graceful - path or anything respawned mid-teardown. - - All steps are best-effort; exceptions are swallowed so - one subsystem's failure doesn't block the rest. - """ - try: - from tools.process_registry import process_registry - _killed = process_registry.kill_all() - if _killed: - logger.info( - "Shutdown (%s): killed %d tool subprocess(es)", - phase, _killed, - ) - except Exception as _e: - logger.debug("process_registry.kill_all (%s) error: %s", phase, _e) - try: - from tools.terminal_tool import cleanup_all_environments - cleanup_all_environments() - except Exception as _e: - logger.debug("cleanup_all_environments (%s) error: %s", phase, _e) - try: - from tools.browser_tool import cleanup_all_browsers - cleanup_all_browsers() - except Exception as _e: - logger.debug("cleanup_all_browsers (%s) error: %s", phase, _e) - logger.info( "Stopping gateway%s...", " for restart" if self._restart_requested else "", @@ -3021,58 +2275,14 @@ def _kill_tool_subprocesses(phase: str) -> None: timeout, self._running_agent_count(), ) - # Mark forcibly-interrupted sessions as resume_pending BEFORE - # interrupting the agents. This preserves each session's - # session_id + transcript so the next message on the same - # session_key auto-resumes from the existing conversation - # instead of getting routed through suspend_recently_active() - # and converted into a fresh session. Terminal escalation - # for genuinely stuck sessions still flows through the - # existing ``.restart_failure_counts`` stuck-loop counter - # (incremented below, threshold 3), which sets - # ``suspended=True`` and overrides resume_pending. - # - # Iterate self._running_agents (current) rather than the - # drain-start ``active_agents`` snapshot — the snapshot - # may include sessions that finished gracefully during - # the drain window, and marking those falsely would give - # them a stray restart-interruption system note on their - # next turn even though their previous turn completed - # cleanly. Skip pending sentinels for the same reason - # _interrupt_running_agents() does: their agent hasn't - # started yet, there's nothing to interrupt, and the - # session shouldn't carry a misleading resume flag. - _resume_reason = ( - "restart_timeout" if self._restart_requested else "shutdown_timeout" - ) - for _sk, _agent in list(self._running_agents.items()): - if _agent is _AGENT_PENDING_SENTINEL: - continue - try: - self.session_store.mark_resume_pending(_sk, _resume_reason) - except Exception as _e: - logger.debug( - "mark_resume_pending failed for %s: %s", - _sk, _e, - ) self._interrupt_running_agents( - _INTERRUPT_REASON_GATEWAY_RESTART if self._restart_requested else _INTERRUPT_REASON_GATEWAY_SHUTDOWN + "Gateway restarting" if self._restart_requested else "Gateway shutting down" ) interrupt_deadline = asyncio.get_running_loop().time() + 5.0 while self._running_agents and asyncio.get_running_loop().time() < interrupt_deadline: self._update_runtime_status("draining") await asyncio.sleep(0.1) - # Kill lingering tool subprocesses NOW, before we spend more - # budget on adapter disconnect / session DB close. Under - # systemd (TimeoutStopSec bounded by drain_timeout+headroom), - # deferring this to the end of stop() risks systemd escalating - # to SIGKILL on the cgroup first — at which point bash/sleep - # children left behind by an interrupted terminal tool get - # killed by systemd instead of us (issue #8202). The final - # catch-all cleanup below still runs for the graceful path. - _kill_tool_subprocesses("post-interrupt") - if self._restart_requested and self._restart_detached: try: await self._launch_detached_restart_command() @@ -3081,23 +2291,6 @@ def _kill_tool_subprocesses(phase: str) -> None: self._finalize_shutdown_agents(active_agents) - # Also shut down memory providers on idle cached agents. - # _finalize_shutdown_agents only handles agents that were - # mid-turn at drain time; the _agent_cache may still hold - # idle agents whose MemoryProviders never received - # on_session_end(). - _cache_lock = getattr(self, "_agent_cache_lock", None) - _cache = getattr(self, "_agent_cache", None) - if _cache_lock is not None and _cache is not None: - with _cache_lock: - _idle_agents = list(_cache.values()) - _cache.clear() - for _entry in _idle_agents: - _agent = ( - _entry[0] if isinstance(_entry, tuple) else _entry - ) - self._cleanup_agent_resources(_agent) - for platform, adapter in list(self.adapters.items()): try: await adapter.cancel_background_tasks() @@ -3117,7 +2310,6 @@ def _kill_tool_subprocesses(phase: str) -> None: self.adapters.clear() self._running_agents.clear() - self._running_agents_ts.clear() self._pending_messages.clear() self._pending_approvals.clear() if hasattr(self, '_busy_ack_ts'): @@ -3125,44 +2317,25 @@ def _kill_tool_subprocesses(phase: str) -> None: self._shutdown_event.set() # Global cleanup: kill any remaining tool subprocesses not tied - # to a specific agent (catch-all for zombie prevention). On the - # drain-timeout path we already did this earlier after agent - # interrupt — this second call catches (a) the graceful path - # where drain succeeded without interrupt, and (b) anything - # that got respawned between the earlier call and adapter - # disconnect (defense in depth; safe to call repeatedly). - _kill_tool_subprocesses("final-cleanup") - - # Reap the process-global auxiliary-client cache once at the very - # end of teardown. Per-turn cleanup runs in _cleanup_agent_resources - # for each active agent, but clients bound to worker-thread loops - # that died with their ThreadPoolExecutor (notably cron ticks) only - # get swept here. Without this, long-running gateways accumulate - # async httpx transports until they hit EMFILE on macOS's default - # RLIMIT_NOFILE=256. See #14210. + # to a specific agent (catch-all for zombie prevention). try: - from agent.auxiliary_client import shutdown_cached_clients - shutdown_cached_clients() - except Exception as _e: - logger.debug("shutdown_cached_clients error: %s", _e) - - # Close SQLite session DBs so the WAL write lock is released. - # Without this, --replace and similar restart flows leave the - # old gateway's connection holding the WAL lock until Python - # actually exits — causing 'database is locked' errors when - # the new gateway tries to open the same file. - for _db_holder in (self, getattr(self, "session_store", None)): - _db = getattr(_db_holder, "_db", None) if _db_holder else None - if _db is None or not hasattr(_db, "close"): - continue - try: - _db.close() - except Exception as _e: - logger.debug("SessionDB close error: %s", _e) + from tools.process_registry import process_registry + process_registry.kill_all() + except Exception: + pass + try: + from tools.terminal_tool import cleanup_all_environments + cleanup_all_environments() + except Exception: + pass + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass - from gateway.status import remove_pid_file, release_gateway_runtime_lock + from gateway.status import remove_pid_file remove_pid_file() - release_gateway_runtime_lock() # Write a clean-shutdown marker so the next startup knows this # wasn't a crash. suspend_recently_active() only needs to run @@ -3361,14 +2534,8 @@ def _create_adapter( return None return QQAdapter(config) - elif platform == Platform.YUANBAO: - from gateway.platforms.yuanbao import YuanbaoAdapter, WEBSOCKETS_AVAILABLE - if not WEBSOCKETS_AVAILABLE: - logger.warning("Yuanbao: websockets not installed. Run: pip install websockets") - return None - return YuanbaoAdapter(config) - return None + def _is_user_authorized(self, source: SessionSource) -> bool: """ Check if a user is authorized to use the bot. @@ -3409,11 +2576,6 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", Platform.QQBOT: "QQ_ALLOWED_USERS", - Platform.YUANBAO: "YUANBAO_ALLOWED_USERS", - } - platform_group_env_map = { - Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS", - Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -3432,7 +2594,6 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", Platform.QQBOT: "QQ_ALLOW_ALL_USERS", - Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) @@ -3440,28 +2601,6 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"): return True - # Discord bot senders that passed the DISCORD_ALLOW_BOTS platform - # filter are already authorized at the platform level — skip the - # user allowlist. Without this, bot messages allowed by - # DISCORD_ALLOW_BOTS=mentions/all would be rejected here with - # "Unauthorized user" (fixes #4466). - if source.platform == Platform.DISCORD and getattr(source, "is_bot", False): - allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() - if allow_bots in ("mentions", "all"): - return True - - # Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's - # on_message pre-filter already verified role membership — if the - # message reached here, the user passed that check. Authorize - # directly to avoid the "no allowlists configured" branch below - # rejecting role-only setups where DISCORD_ALLOWED_USERS is empty - # (issue #7871). - if ( - source.platform == Platform.DISCORD - and os.getenv("DISCORD_ALLOWED_ROLES", "").strip() - ): - return True - # Check pairing store (always checked, regardless of allowlists) platform_name = source.platform.value if source.platform else "" if self.pairing_store.is_approved(platform_name, user_id): @@ -3469,23 +2608,12 @@ def _is_user_authorized(self, source: SessionSource) -> bool: # Check platform-specific and global allowlists platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip() - group_allowlist = "" - if source.chat_type in {"group", "forum"}: - group_allowlist = os.getenv(platform_group_env_map.get(source.platform, ""), "").strip() global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() - if not platform_allowlist and not group_allowlist and not global_allowlist: + if not platform_allowlist and not global_allowlist: # No allowlists configured -- check global allow-all flag return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") - # Some platforms authorize group traffic by chat ID rather than sender ID. - if group_allowlist and source.chat_type in {"group", "forum"} and source.chat_id: - allowed_group_ids = { - chat_id.strip() for chat_id in group_allowlist.split(",") if chat_id.strip() - } - if "*" in allowed_group_ids or source.chat_id in allowed_group_ids: - return True - # Check if user is in any allowlist allowed_ids = set() if platform_allowlist: @@ -3518,59 +2646,10 @@ def _is_user_authorized(self, source: SessionSource) -> bool: return bool(check_ids & allowed_ids) def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: - """Return how unauthorized DMs should be handled for a platform. - - Resolution order: - 1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins. - 2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform. - 3. When an allowlist (``PLATFORM_ALLOWED_USERS`` or ``GATEWAY_ALLOWED_USERS``) is - configured, default to ``"ignore"`` — the allowlist signals that the owner has - deliberately restricted access; spamming unknown contacts with pairing codes - is both noisy and a potential info-leak. (#9337) - 4. No allowlist and no explicit config → ``"pair"`` (open-gateway default). - """ + """Return how unauthorized DMs should be handled for a platform.""" config = getattr(self, "config", None) - - # Check for an explicit per-platform override first. - if config and hasattr(config, "get_unauthorized_dm_behavior") and platform: - platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None - if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}): - # Operator explicitly configured behavior for this platform — respect it. - return config.get_unauthorized_dm_behavior(platform) - - # Check for an explicit global config override. - if config and hasattr(config, "unauthorized_dm_behavior"): - if config.unauthorized_dm_behavior != "pair": # non-default → explicit override - return config.unauthorized_dm_behavior - - # No explicit override. Fall back to allowlist-aware default: - # if any allowlist is configured for this platform, silently drop - # unauthorized messages instead of sending pairing codes. - if platform: - platform_env_map = { - Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", - Platform.DISCORD: "DISCORD_ALLOWED_USERS", - Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", - Platform.SLACK: "SLACK_ALLOWED_USERS", - Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", - Platform.EMAIL: "EMAIL_ALLOWED_USERS", - Platform.SMS: "SMS_ALLOWED_USERS", - Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", - Platform.MATRIX: "MATRIX_ALLOWED_USERS", - Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", - Platform.FEISHU: "FEISHU_ALLOWED_USERS", - Platform.WECOM: "WECOM_ALLOWED_USERS", - Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", - Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", - Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", - Platform.QQBOT: "QQ_ALLOWED_USERS", - } - if os.getenv(platform_env_map.get(platform, ""), "").strip(): - return "ignore" - - if os.getenv("GATEWAY_ALLOWED_USERS", "").strip(): - return "ignore" - + if config and hasattr(config, "get_unauthorized_dm_behavior"): + return config.get_unauthorized_dm_behavior(platform) return "pair" async def _handle_message(self, event: MessageEvent) -> Optional[str]: @@ -3590,50 +2669,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Internal events (e.g. background-process completion notifications) # are system-generated and must skip user authorization. - is_internal = bool(getattr(event, "internal", False)) - - # Fire pre_gateway_dispatch plugin hook for user-originated messages. - # Plugins receive the MessageEvent and may return a dict influencing flow: - # {"action": "skip", "reason": ...} -> drop (no reply, plugin handled) - # {"action": "rewrite", "text": ...} -> replace event.text, continue - # {"action": "allow"} / None -> normal dispatch - # Hook runs BEFORE auth so plugins can handle unauthorized senders - # (e.g. customer handover ingest) without triggering the pairing flow. - if not is_internal: - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _hook_results = _invoke_hook( - "pre_gateway_dispatch", - event=event, - gateway=self, - session_store=self.session_store, - ) - except Exception as _hook_exc: - logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc) - _hook_results = [] - - for _result in _hook_results: - if not isinstance(_result, dict): - continue - _action = _result.get("action") - if _action == "skip": - logger.info( - "pre_gateway_dispatch skip: reason=%s platform=%s chat=%s", - _result.get("reason"), - source.platform.value if source.platform else "unknown", - source.chat_id or "unknown", - ) - return None - if _action == "rewrite": - _new_text = _result.get("text") - if isinstance(_new_text, str): - event = dataclasses.replace(event, text=_new_text) - source = event.source - break - if _action == "allow": - break - - if is_internal: + if getattr(event, "internal", False): pass elif source.user_id is None: # Messages with no user identity (Telegram service messages, @@ -3681,10 +2717,6 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # The update process (detached) wrote .update_prompt.json; the watcher # forwarded it to the user; now the user's reply goes back via # .update_response so the update process can continue. - # - # IMPORTANT: recognized slash commands must bypass this interception. - # Otherwise control/session commands like /new or /help get silently - # consumed as update answers instead of being dispatched normally. _quick_key = self._session_key_for_source(source) _update_prompts = getattr(self, "_update_prompt_pending", {}) if _update_prompts.get(_quick_key): @@ -3696,22 +2728,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: elif cmd in ("deny", "no"): response_text = "n" else: - _recognized_cmd = None - if cmd: - try: - from hermes_cli.commands import resolve_command as _resolve_update_cmd - except Exception: - _resolve_update_cmd = None - if _resolve_update_cmd is not None: - try: - _cmd_def = _resolve_update_cmd(cmd) - _recognized_cmd = _cmd_def.name if _cmd_def else None - except Exception: - _recognized_cmd = None - if _recognized_cmd: - response_text = "" - else: - response_text = raw + response_text = raw if response_text: response_path = _hermes_home / ".update_response" try: @@ -3719,35 +2736,11 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: tmp.write_text(response_text) tmp.replace(response_path) except OSError as e: - logger.warning("Failed to write update response: %s", e) - return f"✗ Failed to send response to update process: {e}" - _update_prompts.pop(_quick_key, None) - label = response_text if len(response_text) <= 20 else response_text[:20] + "…" - return f"✓ Sent `{label}` to the update process." - # Recognized slash command during a pending update prompt: - # unblock the detached update subprocess by writing a blank - # response so ``_gateway_prompt`` returns the prompt's default - # (typically a safe "n" / skip) and exits cleanly instead of - # blocking on stdin until the 30-minute watcher timeout. - # The slash command then falls through to normal dispatch. - if _recognized_cmd: - response_path = _hermes_home / ".update_response" - try: - tmp = response_path.with_suffix(".tmp") - tmp.write_text("") - tmp.replace(response_path) - logger.info( - "Recognized /%s during pending update prompt for %s; " - "cancelled prompt with default and dispatching command", - _recognized_cmd, - _quick_key, - ) - except OSError as e: - logger.warning( - "Failed to write cancel response for pending update prompt: %s", - e, - ) + logger.warning("Failed to write update response: %s", e) + return f"✗ Failed to send response to update process: {e}" _update_prompts.pop(_quick_key, None) + label = response_text if len(response_text) <= 20 else response_text[:20] + "…" + return f"✓ Sent `{label}` to the update process." # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages @@ -3800,24 +2793,19 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: logger.warning( "Evicting stale _running_agents entry for %s " "(age: %.0fs, idle: %.0fs, timeout: %.0fs)%s", - _quick_key, _stale_age, _stale_idle, + _quick_key[:30], _stale_age, _stale_idle, _raw_stale_timeout, _stale_detail, ) - self._invalidate_session_run_generation( - _quick_key, - reason="stale_running_agent_eviction", - ) - self._release_running_agent_state(_quick_key) + del self._running_agents[_quick_key] + self._running_agents_ts.pop(_quick_key, None) + self._busy_ack_ts.pop(_quick_key, None) if _quick_key in self._running_agents: if event.get_command() == "status": return await self._handle_status_command(event) # Resolve the command once for all early-intercept checks below. - from hermes_cli.commands import ( - ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS, - resolve_command as _resolve_cmd_inner, - ) + from hermes_cli.commands import resolve_command as _resolve_cmd_inner _evt_cmd = event.get_command() _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None @@ -3830,13 +2818,17 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # _interrupt_requested. Force-clean _running_agents so the session # is unlocked and subsequent messages are processed normally. if _cmd_def_inner and _cmd_def_inner.name == "stop": - await self._interrupt_and_clear_session( - _quick_key, - source, - interrupt_reason=_INTERRUPT_REASON_STOP, - invalidation_reason="stop_command", - ) - logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key) + running_agent = self._running_agents.get(_quick_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + running_agent.interrupt("Stop requested") + # Force-clean: remove the session lock regardless of agent state + adapter = self.adapters.get(source.platform) + if adapter and hasattr(adapter, 'get_pending_message'): + adapter.get_pending_message(_quick_key) # consume and discard + self._pending_messages.pop(_quick_key, None) + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] + logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20]) return "⚡ Stopped. You can continue this session." # /reset and /new must bypass the running-agent guard so they @@ -3847,85 +2839,36 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # doesn't get re-processed as a user message after the # interrupt completes. if _cmd_def_inner and _cmd_def_inner.name == "new": + running_agent = self._running_agents.get(_quick_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + running_agent.interrupt("Session reset requested") # Clear any pending messages so the old text doesn't replay - await self._interrupt_and_clear_session( - _quick_key, - source, - interrupt_reason=_INTERRUPT_REASON_RESET, - invalidation_reason="new_command", - ) + adapter = self.adapters.get(source.platform) + if adapter and hasattr(adapter, 'get_pending_message'): + adapter.get_pending_message(_quick_key) # consume and discard + self._pending_messages.pop(_quick_key, None) # Clean up the running agent entry so the reset handler # doesn't think an agent is still active. + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] return await self._handle_reset_command(event) - # /queue — queue without interrupting. - # Semantics: each /queue invocation produces its own full agent - # turn, processed in FIFO order after the current run (and any - # earlier /queue items) finishes. Messages are NOT merged. + # /queue — queue without interrupting if event.get_command() in ("queue", "q"): queued_text = event.get_command_args().strip() if not queued_text: return "Usage: /queue " adapter = self.adapters.get(source.platform) if adapter: - queued_event = MessageEvent( + from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT + queued_event = _ME( text=queued_text, - message_type=MessageType.TEXT, - source=event.source, - message_id=event.message_id, - channel_prompt=event.channel_prompt, - ) - self._enqueue_fifo(_quick_key, queued_event, adapter) - depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform)) - if depth <= 1: - return "Queued for the next turn." - return f"Queued for the next turn. ({depth} queued)" - - # /steer — inject mid-run after the next tool call. - # Unlike /queue (turn boundary), /steer lands BETWEEN tool-call - # iterations inside the same agent run, by appending to the - # last tool result's content. No interrupt, no new user turn, - # no role-alternation violation. - if _cmd_def_inner and _cmd_def_inner.name == "steer": - steer_text = event.get_command_args().strip() - if not steer_text: - return "Usage: /steer " - running_agent = self._running_agents.get(_quick_key) - if running_agent is _AGENT_PENDING_SENTINEL: - # Agent hasn't started yet — queue as turn-boundary fallback. - adapter = self.adapters.get(source.platform) - if adapter: - queued_event = MessageEvent( - text=steer_text, - message_type=MessageType.TEXT, - source=event.source, - message_id=event.message_id, - channel_prompt=event.channel_prompt, - ) - adapter._pending_messages[_quick_key] = queued_event - return "Agent still starting — /steer queued for the next turn." - if running_agent and hasattr(running_agent, "steer"): - try: - accepted = running_agent.steer(steer_text) - except Exception as exc: - logger.warning("Steer failed for session %s: %s", _quick_key, exc) - return f"⚠️ Steer failed: {exc}" - if accepted: - preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "") - return f"⏩ Steer queued — arrives after the next tool call: '{preview}'" - return "Steer rejected (empty payload)." - # Running agent is missing or lacks steer() — fall back to queue. - adapter = self.adapters.get(source.platform) - if adapter: - queued_event = MessageEvent( - text=steer_text, - message_type=MessageType.TEXT, + message_type=_MT.TEXT, source=event.source, message_id=event.message_id, - channel_prompt=event.channel_prompt, ) adapter._pending_messages[_quick_key] = queued_event - return "No active agent — /steer queued for the next turn." + return "Queued for the next turn." # /model must not be used while the agent is running. if _cmd_def_inner and _cmd_def_inner.name == "model": @@ -3940,111 +2883,32 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return await self._handle_approve_command(event) return await self._handle_deny_command(event) - # /agents (/tasks alias) should be query-only and never interrupt. - if _cmd_def_inner and _cmd_def_inner.name == "agents": - return await self._handle_agents_command(event) - # /background must bypass the running-agent guard — it starts a # parallel task and must never interrupt the active conversation. - # /btw is an alias of /background and resolves to the same canonical - # name, so this branch handles both commands. if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) - # Session-level toggles that are safe to run mid-agent — - # /yolo can unblock a pending approval prompt, /verbose cycles - # the tool-progress display mode for the ongoing stream. - # Both modify session state without needing agent interaction - # and must not be queued (the safety net would discard them). - # /fast and /reasoning are config-only and take effect next - # message, so they fall through to the catch-all busy response - # below — users should wait and set them between turns. - if _cmd_def_inner and _cmd_def_inner.name in ("yolo", "verbose"): - if _cmd_def_inner.name == "yolo": - return await self._handle_yolo_command(event) - if _cmd_def_inner.name == "verbose": - return await self._handle_verbose_command(event) - if _cmd_def_inner.name == "footer": - return await self._handle_footer_command(event) - - # Gateway-handled info/control commands with dedicated - # running-agent handlers. - if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS: - if _cmd_def_inner.name == "help": - return await self._handle_help_command(event) - if _cmd_def_inner.name == "commands": - return await self._handle_commands_command(event) - if _cmd_def_inner.name == "profile": - return await self._handle_profile_command(event) - if _cmd_def_inner.name == "update": - return await self._handle_update_command(event) - - # Catch-all: any other recognized slash command reached the - # running-agent guard. Reject gracefully rather than falling - # through to interrupt + discard. Without this, commands - # like /model, /reasoning, /voice, /insights, /title, - # /resume, /retry, /undo, /compress, /usage, - # /reload-mcp, /sethome, /reset (all registered as Discord - # slash commands) would interrupt the agent AND get - # silently discarded by the slash-command safety net, - # producing a zero-char response. See #5057, #6252, #10370. - if _cmd_def_inner: - return ( - f"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run " - f"mid-turn. Wait for the current response or `/stop` first." - ) - if event.message_type == MessageType.PHOTO: - logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key) + logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) if adapter: merge_pending_message_event(adapter._pending_messages, _quick_key, event) return None - _telegram_followup_grace = float( - os.getenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0") - ) - _started_at = self._running_agents_ts.get(_quick_key, 0) - if ( - source.platform == Platform.TELEGRAM - and event.message_type == MessageType.TEXT - and _telegram_followup_grace > 0 - and _started_at - and (time.time() - _started_at) <= _telegram_followup_grace - ): - logger.debug( - "Telegram follow-up arrived %.2fs after run start for %s — queueing without interrupt", - time.time() - _started_at, - _quick_key, - ) - adapter = self.adapters.get(source.platform) - if adapter: - merge_pending_message_event( - adapter._pending_messages, - _quick_key, - event, - merge_text=True, - ) - return None - running_agent = self._running_agents.get(_quick_key) if running_agent is _AGENT_PENDING_SENTINEL: # Agent is being set up but not ready yet. if event.get_command() == "stop": # Force-clean the sentinel so the session is unlocked. - self._release_running_agent_state(_quick_key) - logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key) + if _quick_key in self._running_agents: + del self._running_agents[_quick_key] + logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20]) return "⚡ Force-stopped. The agent was still starting — session unlocked." # Queue the message so it will be picked up after the # agent starts. adapter = self.adapters.get(source.platform) if adapter: - merge_pending_message_event( - adapter._pending_messages, - _quick_key, - event, - merge_text=True, - ) + adapter._pending_messages[_quick_key] = event return None if self._draining: if self._queue_during_drain_enabled(): @@ -4054,29 +2918,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if self._queue_during_drain_enabled() else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." ) - if self._busy_input_mode == "queue": - logger.debug("PRIORITY queue follow-up for session %s", _quick_key) - self._queue_or_replace_pending_event(_quick_key, event) - return None - if self._busy_input_mode == "steer": - # Steer mode: inject text into the running agent mid-run via - # agent.steer(). Falls back to queue semantics if the payload - # is empty, the agent lacks steer(), or steer() rejects. - steer_text = (event.text or "").strip() - steered = False - if steer_text and hasattr(running_agent, "steer"): - try: - steered = bool(running_agent.steer(steer_text)) - except Exception as exc: - logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc) - steered = False - if steered: - logger.debug("PRIORITY steer for session %s", _quick_key) - return None - logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key) - self._queue_or_replace_pending_event(_quick_key, event) - return None - logger.debug("PRIORITY interrupt for session %s", _quick_key) + logger.debug("PRIORITY interrupt for session %s", _quick_key[:20]) running_agent.interrupt(event.text) if _quick_key in self._pending_messages: self._pending_messages[_quick_key] += "\n" + event.text @@ -4086,72 +2928,22 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Check for commands command = event.get_command() - - from hermes_cli.commands import ( - GATEWAY_KNOWN_COMMANDS, - is_gateway_known_command, - resolve_command as _resolve_cmd, - ) - - # Resolve aliases to canonical name so dispatch and hook names - # don't depend on the exact alias the user typed. - _cmd_def = _resolve_cmd(command) if command else None - canonical = _cmd_def.name if _cmd_def else command - - # Fire the ``command:`` hook for any recognized slash - # command — built-in OR plugin-registered. Handlers can return a - # dict with ``{"decision": "deny" | "handled" | "rewrite", ...}`` - # to intercept dispatch before core handling runs. This replaces - # the previous fire-and-forget emit(): return values are now - # honored, but handlers that return nothing behave exactly as - # before (telemetry-style hooks keep working). - if command and is_gateway_known_command(canonical): - raw_args = event.get_command_args().strip() - hook_ctx = { + + # Emit command:* hook for any recognized slash command. + # GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY + # in hermes_cli/commands.py — no hardcoded set to maintain here. + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd + if command and command in GATEWAY_KNOWN_COMMANDS: + await self.hooks.emit(f"command:{command}", { "platform": source.platform.value if source.platform else "", "user_id": source.user_id, - "command": canonical, - "raw_command": command, - "args": raw_args, - "raw_args": raw_args, - } - try: - hook_results = await self.hooks.emit_collect( - f"command:{canonical}", hook_ctx - ) - except Exception as _hook_err: - logger.debug( - "command:%s hook dispatch failed (non-fatal): %s", - canonical, _hook_err, - ) - hook_results = [] + "command": command, + "args": event.get_command_args().strip(), + }) - for hook_result in hook_results: - if not isinstance(hook_result, dict): - continue - decision = str(hook_result.get("decision", "")).strip().lower() - if not decision or decision == "allow": - continue - if decision == "deny": - message = hook_result.get("message") - if isinstance(message, str) and message: - return message - return f"Command `/{command}` was blocked by a hook." - if decision == "handled": - message = hook_result.get("message") - return message if isinstance(message, str) and message else None - if decision == "rewrite": - new_command = str( - hook_result.get("command_name", "") - ).strip().lstrip("/") - if not new_command: - continue - new_args = str(hook_result.get("raw_args", "")).strip() - event.text = f"/{new_command} {new_args}".strip() - command = event.get_command() - _cmd_def = _resolve_cmd(command) if command else None - canonical = _cmd_def.name if _cmd_def else command - break + # Resolve aliases to canonical name so dispatch only checks canonicals. + _cmd_def = _resolve_cmd(command) if command else None + canonical = _cmd_def.name if _cmd_def else command if canonical == "new": return await self._handle_reset_command(event) @@ -4168,9 +2960,6 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "status": return await self._handle_status_command(event) - if canonical == "agents": - return await self._handle_agents_command(event) - if canonical == "restart": return await self._handle_restart_command(event) @@ -4186,18 +2975,40 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "verbose": return await self._handle_verbose_command(event) - if canonical == "footer": - return await self._handle_footer_command(event) - if canonical == "yolo": return await self._handle_yolo_command(event) if canonical == "model": return await self._handle_model_command(event) + if canonical == "provider": + return await self._handle_provider_command(event) + if canonical == "personality": return await self._handle_personality_command(event) + if canonical == "plan": + try: + from agent.skill_commands import build_plan_path, build_skill_invocation_message + + user_instruction = event.get_command_args().strip() + plan_path = build_plan_path(user_instruction) + event.text = build_skill_invocation_message( + "/plan", + user_instruction, + task_id=_quick_key, + runtime_note=( + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" + ), + ) + if not event.text: + return "Failed to load the bundled /plan skill." + canonical = None + except Exception as e: + logger.exception("Failed to prepare /plan command") + return f"Failed to enter plan mode: {e}" + if canonical == "retry": return await self._handle_retry_command(event) @@ -4246,20 +3057,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "background": return await self._handle_background_command(event) - if canonical == "steer": - # No active agent — /steer has no tool call to inject into. - # Strip the prefix so downstream treats it as a normal user - # message. If the payload is empty, surface the usage hint. - steer_payload = event.get_command_args().strip() - if not steer_payload: - return "Usage: /steer (no agent is running; sending as a normal message)" - try: - event.text = steer_payload - except Exception: - pass - # Do NOT return — fall through to _handle_message_with_agent - # at the end of this function so the rewritten text is sent - # to the agent as a regular user turn. + if canonical == "btw": + return await self._handle_btw_command(event) if canonical == "voice": return await self._handle_voice_command(event) @@ -4319,8 +3118,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: plugin_handler = get_plugin_command_handler(command.replace("_", "-")) if plugin_handler: user_args = event.get_command_args().strip() + import asyncio as _aio result = plugin_handler(user_args) - if asyncio.iscoroutine(result): + if _aio.iscoroutine(result): result = await result return str(result) if result else None except Exception as e: @@ -4403,23 +3203,17 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # same session — corrupting the transcript. self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL self._running_agents_ts[_quick_key] = time.time() - _run_generation = self._begin_session_run_generation(_quick_key) try: - return await self._handle_message_with_agent(event, source, _quick_key, _run_generation) + return await self._handle_message_with_agent(event, source, _quick_key) finally: # If _run_agent replaced the sentinel with a real agent and # then cleaned it up, this is a no-op. If we exited early # (exception, command fallthrough, etc.) the sentinel must # not linger or the session would be permanently locked out. if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: - self._release_running_agent_state(_quick_key) - else: - # Agent path already cleaned _running_agents; make sure - # the paired metadata dicts are gone too. - self._running_agents_ts.pop(_quick_key, None) - if hasattr(self, "_busy_ack_ts"): - self._busy_ack_ts.pop(_quick_key, None) + del self._running_agents[_quick_key] + self._running_agents_ts.pop(_quick_key, None) async def _prepare_inbound_message_text( self, @@ -4433,25 +3227,16 @@ async def _prepare_inbound_message_text( Keep the normal inbound path and the queued follow-up path on the same preprocessing pipeline so sender attribution, image enrichment, STT, document notes, reply context, and @ references all behave the same. - - Side effect: writes ``self._pending_native_image_paths`` to a list of - local image paths when the active model supports native vision AND - the user has images attached. The caller consumes and clears this - attribute at the ``run_conversation`` site to build a multimodal user - turn. When the list is empty, the ``_enrich_message_with_vision`` - text path has already run and images are represented in-text. """ history = history or [] message_text = event.text or "" - # Reset per-call buffer; set only when native routing is chosen. - self._pending_native_image_paths = [] - _is_shared_multi_user = is_shared_multi_user_session( - source, - group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), - thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + _is_shared_thread = ( + source.chat_type != "dm" + and source.thread_id + and not getattr(self.config, "thread_sessions_per_user", False) ) - if _is_shared_multi_user and source.user_name: + if _is_shared_thread and source.user_name: message_text = f"[{source.user_name}] {message_text}" if event.media_urls: @@ -4465,25 +3250,10 @@ async def _prepare_inbound_message_text( audio_paths.append(path) if image_paths: - # Decide routing: native (attach pixels) vs text (vision_analyze - # pre-run + prepend description). See agent/image_routing.py. - _img_mode = self._decide_image_input_mode() - if _img_mode == "native": - # Defer attachment to the run_conversation call site. - self._pending_native_image_paths = list(image_paths) - logger.info( - "Image routing: native (model supports vision). %d image(s) will be attached inline.", - len(image_paths), - ) - else: - logger.info( - "Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.", - _img_mode, len(image_paths), - ) - message_text = await self._enrich_message_with_vision( - message_text, - image_paths, - ) + message_text = await self._enrich_message_with_vision( + message_text, + image_paths, + ) if audio_paths: message_text = await self._enrich_message_with_transcription( @@ -4526,7 +3296,9 @@ async def _prepare_inbound_message_text( for i, path in enumerate(event.media_urls): mtype = event.media_types[i] if i < len(event.media_types) else "" if mtype in ("", "application/octet-stream"): - _ext = os.path.splitext(path)[1].lower() + import os as _os2 + + _ext = _os2.path.splitext(path)[1].lower() if _ext in _TEXT_EXTENSIONS: mtype = "text/plain" else: @@ -4536,10 +3308,13 @@ async def _prepare_inbound_message_text( if not mtype.startswith(("application/", "text/")): continue - basename = os.path.basename(path) + import os as _os + import re as _re + + basename = _os.path.basename(path) parts = basename.split("_", 2) display_name = parts[2] if len(parts) >= 3 else basename - display_name = re.sub(r'[^\w.\- ]', '_', display_name) + display_name = _re.sub(r'[^\w.\- ]', '_', display_name) if mtype.startswith("text/"): context_note = ( @@ -4562,20 +3337,36 @@ async def _prepare_inbound_message_text( # is referencing. History can contain the same or similar text # multiple times, and without an explicit pointer the agent has to # guess (or answer for both subjects). Token overhead is minimal. - reply_snippet = event.reply_to_text[:500] - message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + full_reply_text = event.reply_to_text + _REPLY_TO_LIMIT = 3000 + if len(full_reply_text) > _REPLY_TO_LIMIT: + reply_snippet = full_reply_text[:_REPLY_TO_LIMIT] + "\n…[已截断,原文过长]" + else: + reply_snippet = full_reply_text + # _prepare_inbound_message_text is called before session_entry is bound, + # but we need the session_id for reply-context lookups. + # Pass it via the event object as a carrier (set by caller below). + _session_id = getattr(event, "_session_id", "") or "" + history_messages = self.session_store.load_transcript(_session_id) + found_in_history = any( + reply_snippet in (msg.get("content") or "") + for msg in reversed(history_messages) + ) + if not found_in_history: + message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + else: + marker_preview = reply_snippet[:80].replace("\n", " ") + message_text = f"[Replying to earlier message: {marker_preview}...]\n\n{message_text}" if "@" in message_text: try: from agent.context_references import preprocess_context_references_async from agent.model_metadata import get_model_context_length - _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) - _msg_runtime = _resolve_runtime_agent_kwargs() + _msg_cwd = os.environ.get("MESSAGING_CWD", os.path.expanduser("~")) _msg_ctx_len = get_model_context_length( self._model, - base_url=self._base_url or _msg_runtime.get("base_url") or "", - api_key=_msg_runtime.get("api_key") or "", + base_url=self._base_url or "", ) _ctx_result = await preprocess_context_references_async( message_text, @@ -4598,7 +3389,7 @@ async def _prepare_inbound_message_text( return message_text - async def _handle_message_with_agent(self, event, source, _quick_key: str, run_generation: int): + async def _handle_message_with_agent(self, event, source, _quick_key: str): """Inner handler that runs under the _running_agents sentinel guard.""" _msg_start_time = time.time() _platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) @@ -4612,15 +3403,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Get or create session session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key - if getattr(session_entry, "was_auto_reset", False): - # Treat auto-reset as a full conversation boundary — drop every - # session-scoped transient state so the fresh session does not - # inherit the previous conversation's model/reasoning overrides - # or a queued "/model switched" note. - self._session_model_overrides.pop(session_key, None) - self._set_session_reasoning_override(session_key, None) - if hasattr(self, "_pending_model_notes"): - self._pending_model_notes.pop(session_key, None) # Emit session:start for new or auto-reset sessions _is_new_session = ( @@ -4644,7 +3426,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Read privacy.redact_pii from config (re-read per message) _redact_pii = False try: - _pcfg = _load_gateway_config() + import yaml as _pii_yaml + with open(_config_path, encoding="utf-8") as _pf: + _pcfg = _pii_yaml.safe_load(_pf) or {} _redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False)) except Exception: pass @@ -4732,7 +3516,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if _loaded: _loaded_skill, _skill_dir, _display_name = _loaded _note = ( - f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. ' + f'[SYSTEM: The "{_display_name}" skill is auto-loaded. ' f"Follow its instructions for this session.]" ) _part = _build_skill_message(_loaded_skill, _skill_dir, _note) @@ -4787,15 +3571,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _hyg_model = "anthropic/claude-sonnet-4.6" _hyg_threshold_pct = 0.85 _hyg_compression_enabled = True - _hyg_hard_msg_limit = 400 _hyg_config_context_length = None _hyg_provider = None _hyg_base_url = None _hyg_api_key = None _hyg_data = {} try: - _hyg_data = _load_gateway_config() - if _hyg_data: + _hyg_cfg_path = _hermes_home / "config.yaml" + if _hyg_cfg_path.exists(): + import yaml as _hyg_yaml + with open(_hyg_cfg_path, encoding="utf-8") as _hyg_f: + _hyg_data = _hyg_yaml.safe_load(_hyg_f) or {} + # Resolve model name (same logic as run_sync) _model_cfg = _hyg_data.get("model", {}) if isinstance(_model_cfg, str): @@ -4822,14 +3609,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _hyg_compression_enabled = str( _comp_cfg.get("enabled", True) ).lower() in ("true", "1", "yes") - _raw_hard_limit = _comp_cfg.get("hygiene_hard_message_limit") - if _raw_hard_limit is not None: - try: - _parsed = int(_raw_hard_limit) - if _parsed > 0: - _hyg_hard_msg_limit = _parsed - except (TypeError, ValueError): - pass try: _hyg_model, _hyg_runtime = self._resolve_session_agent_runtime( @@ -4911,10 +3690,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # collection, which prevents compression, which causes more # disconnects. 400 messages is well above normal sessions # but catches runaway growth before it becomes unrecoverable. - # Threshold is configurable via - # compression.hygiene_hard_message_limit. # (#2153) - _HARD_MSG_LIMIT = _hyg_hard_msg_limit + _HARD_MSG_LIMIT = 400 _needs_compress = ( _approx_tokens >= _compress_token_threshold or _msg_count >= _HARD_MSG_LIMIT @@ -4954,109 +3731,53 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g model=_hyg_model, max_iterations=4, quiet_mode=True, - skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) - try: - _hyg_agent._print_fn = lambda *a, **kw: None - - loop = asyncio.get_running_loop() - _compressed, _ = await loop.run_in_executor( - None, - lambda: _hyg_agent._compress_context( - _hyg_msgs, "", - approx_tokens=_approx_tokens, - ), - ) + _hyg_agent._print_fn = lambda *a, **kw: None + + loop = asyncio.get_event_loop() + _compressed, _ = await loop.run_in_executor( + None, + lambda: _hyg_agent._compress_context( + _hyg_msgs, "", + approx_tokens=_approx_tokens, + ), + ) - # _compress_context ends the old session and creates - # a new session_id. Write compressed messages into - # the NEW session so the old transcript stays intact - # and searchable via session_search. - _hyg_new_sid = _hyg_agent.session_id - if _hyg_new_sid != session_entry.session_id: - session_entry.session_id = _hyg_new_sid - self.session_store._save() - - self.session_store.rewrite_transcript( - session_entry.session_id, _compressed - ) - # Reset stored token count — transcript was rewritten - session_entry.last_prompt_tokens = 0 - history = _compressed - _new_count = len(_compressed) - _new_tokens = estimate_messages_tokens_rough( - _compressed - ) + # _compress_context ends the old session and creates + # a new session_id. Write compressed messages into + # the NEW session so the old transcript stays intact + # and searchable via session_search. + _hyg_new_sid = _hyg_agent.session_id + if _hyg_new_sid != session_entry.session_id: + session_entry.session_id = _hyg_new_sid + self.session_store._save() + + self.session_store.rewrite_transcript( + session_entry.session_id, _compressed + ) + # Reset stored token count — transcript was rewritten + session_entry.last_prompt_tokens = 0 + history = _compressed + _new_count = len(_compressed) + _new_tokens = estimate_messages_tokens_rough( + _compressed + ) - logger.info( - "Session hygiene: compressed %s → %s msgs, " - "~%s → ~%s tokens", - _msg_count, _new_count, - f"{_approx_tokens:,}", f"{_new_tokens:,}", - ) + logger.info( + "Session hygiene: compressed %s → %s msgs, " + "~%s → ~%s tokens", + _msg_count, _new_count, + f"{_approx_tokens:,}", f"{_new_tokens:,}", + ) - if _new_tokens >= _warn_token_threshold: - logger.warning( - "Session hygiene: still ~%s tokens after " - "compression", - f"{_new_tokens:,}", - ) - - # If summary generation failed, the - # compressor inserted a static fallback - # placeholder and the dropped turns are - # gone for good. Surface a visible - # warning to the gateway user — agent.log - # alone is invisible on TG/Discord/etc. - _comp = getattr(_hyg_agent, "context_compressor", None) - if _comp is not None and getattr(_comp, "_last_summary_fallback_used", False): - _dropped = getattr(_comp, "_last_summary_dropped_count", 0) - _err = getattr(_comp, "_last_summary_error", None) or "unknown error" - _warn_msg = ( - "⚠️ Context compression summary failed " - f"({_err}). {_dropped} historical message(s) " - "were removed and replaced with a placeholder. " - "Earlier context is no longer recoverable. " - "Consider /reset for a clean session, or check " - "your auxiliary.compression model configuration." - ) - try: - _adapter = self.adapters.get(source.platform) - if _adapter and source.chat_id: - await _adapter.send(source.chat_id, _warn_msg, metadata=_hyg_meta) - except Exception as _werr: - logger.warning( - "Failed to deliver compression-failure warning to user: %s", - _werr, - ) - # Separately: if the user's CONFIGURED aux - # model failed and we recovered by falling - # back to the main model, tell them — a - # misconfigured auxiliary.compression.model - # is something only they can fix, and - # silent recovery would hide it. - elif _comp is not None and getattr(_comp, "_last_aux_model_failure_model", None): - _aux_model = getattr(_comp, "_last_aux_model_failure_model", "") - _aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or "unknown error" - _aux_msg = ( - f"ℹ️ Configured compression model `{_aux_model}` " - f"failed ({_aux_err}). Recovered using your main " - "model — context is intact — but you may want to " - "check `auxiliary.compression.model` in config.yaml." - ) - try: - _adapter = self.adapters.get(source.platform) - if _adapter and source.chat_id: - await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta) - except Exception as _werr: - logger.warning( - "Failed to deliver aux-model-fallback notice to user: %s", - _werr, - ) - finally: - self._cleanup_agent_resources(_hyg_agent) + if _new_tokens >= _warn_token_threshold: + logger.warning( + "Session hygiene: still ~%s tokens after " + "compression", + f"{_new_tokens:,}", + ) except Exception as e: logger.warning( @@ -5079,20 +3800,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if not os.getenv(env_key): adapter = self.adapters.get(source.platform) if adapter: - # Slack dispatches all Hermes commands through a single - # parent slash command `/hermes`; bare `/sethome` is not - # registered and would fail with "app did not respond". - sethome_cmd = ( - "/hermes sethome" - if source.platform == Platform.SLACK - else "/sethome" - ) await adapter.send( source.chat_id, f"📬 No home channel is set for {platform_name.title()}. " f"A home channel is where Hermes delivers cron job results " f"and cross-platform messages.\n\n" - f"Type {sethome_cmd} to make this chat your home channel, " + f"Type /sethome to make this chat your home channel, " f"or ignore to skip." ) @@ -5121,6 +3834,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # attachments (documents, audio, etc.) are not sent to the vision # tool even when they appear in the same message. # ----------------------------------------------------------------- + # Inject session_id into event for reply-context lookups in _prepare_inbound_message_text + event._session_id = session_entry.session_id message_text = await self._prepare_inbound_message_text( event=event, source=source, @@ -5129,15 +3844,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if message_text is None: return - # Bind this gateway run generation to the adapter's active-session - # event so deferred post-delivery callbacks can be released by the - # same run that registered them. - self._bind_adapter_run_generation( - self.adapters.get(source.platform), - session_key, - run_generation, - ) - try: # Emit agent:start hook hook_ctx = { @@ -5156,9 +3862,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g source=source, session_id=session_entry.session_id, session_key=session_key, - run_generation=run_generation, event_message_id=event.message_id, - channel_prompt=event.channel_prompt, ) # Stop persistent typing indicator now that the agent is done @@ -5169,35 +3873,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g except Exception: pass - if not self._is_session_run_current(_quick_key, run_generation): - logger.info( - "Discarding stale agent result for %s — generation %d is no longer current", - _quick_key or "?", - run_generation, - ) - _stale_adapter = self.adapters.get(source.platform) - if getattr(type(_stale_adapter), "pop_post_delivery_callback", None) is not None: - _stale_adapter.pop_post_delivery_callback( - _quick_key, - generation=run_generation, - ) - elif _stale_adapter and hasattr(_stale_adapter, "_post_delivery_callbacks"): - _stale_adapter._post_delivery_callbacks.pop(_quick_key, None) - return None - response = agent_result.get("final_response") or "" - - # Convert the agent's internal "(empty)" sentinel into a - # user-friendly message. "(empty)" means the model failed to - # produce visible content after exhausting all retries (nudge, - # prefill, empty-retry, fallback). Sending the raw sentinel - # looks like a bug; a short explanation is more helpful. - if response == "(empty)": - response = ( - "⚠️ The model returned no response after processing tool " - "results. This can happen with some models — try again or " - "rephrase your question." - ) agent_messages = agent_result.get("messages", []) _response_time = time.time() - _msg_start_time _api_calls = agent_result.get("api_calls", 0) @@ -5211,20 +3887,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Successful turn — clear any stuck-loop counter for this session. # This ensures the counter only accumulates across CONSECUTIVE # restarts where the session was active (never completed). - # - # Also clear the resume_pending flag (set by drain-timeout - # shutdown) — the turn ran to completion, so recovery - # succeeded and subsequent messages should no longer receive - # the restart-interruption system note. if session_key: self._clear_restart_failure_count(session_key) - try: - self.session_store.clear_resume_pending(session_key) - except Exception as _e: - logger.debug( - "clear_resume_pending failed for %s: %s", - session_key, _e, - ) # Surface error details when the agent failed silently (final_response=None) if not response and agent_result.get("failed"): @@ -5282,27 +3946,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g display_reasoning = last_reasoning.strip() response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}" - # Runtime-metadata footer — only on the FINAL message of the turn. - # Off by default (display.runtime_footer.enabled=false). When - # streaming already delivered the body, we can't mutate the sent - # text, so we fire a separate trailing send below. - _footer_line = "" - try: - from gateway.runtime_footer import build_footer_line as _bfl - _footer_line = _bfl( - user_config=_load_gateway_config(), - platform_key=_platform_config_key(source.platform), - model=agent_result.get("model"), - context_tokens=agent_result.get("last_prompt_tokens", 0) or 0, - context_length=agent_result.get("context_length") or None, - cwd=os.environ.get("TERMINAL_CWD", ""), - ) - except Exception as _footer_err: - logger.debug("runtime_footer build failed: %s", _footer_err) - _footer_line = "" - if _footer_line and response and not agent_result.get("already_sent"): - response = f"{response}\n\n{_footer_line}" - # Emit agent:end hook await self.hooks.emit("agent:end", { **hook_ctx, @@ -5335,7 +3978,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g synth_text = _format_gateway_process_notification(evt) if synth_text: try: - await self._inject_watch_notification(synth_text, evt) + await self._inject_watch_notification(synth_text, event) except Exception as e2: logger.error("Watch notification injection error: %s", e2) except Exception as e: @@ -5377,9 +4020,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g self.session_store.reset_session(session_key) self._evict_cached_agent(session_key) self._session_model_overrides.pop(session_key, None) - self._set_session_reasoning_override(session_key, None) - if hasattr(self, "_pending_model_notes"): - self._pending_model_notes.pop(session_key, None) response = (response or "") + ( "\n\n🔄 Session auto-reset — the conversation exceeded the " "maximum context size and could not be compressed further. " @@ -5473,17 +4113,6 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g await self._deliver_media_from_response( response, event, _media_adapter, ) - # Streaming already delivered the body text, but the footer was - # intentionally held back (see the `not already_sent` gate above). - # Send it now as a small trailing message so Telegram/Discord/etc. - # still surface the runtime metadata on the final reply. - if _footer_line: - try: - _foot_adapter = self.adapters.get(source.platform) - if _foot_adapter: - await _foot_adapter.send(source.chat_id, _footer_line) - except Exception as _e: - logger.debug("trailing footer send failed: %s", _e) return None return response @@ -5563,11 +4192,13 @@ def _format_session_info(self) -> str: provider = None base_url = None api_key = None - custom_provs = None try: - data = _load_gateway_config() - if data: + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + import yaml as _info_yaml + with open(cfg_path, encoding="utf-8") as f: + data = _info_yaml.safe_load(f) or {} model_cfg = data.get("model", {}) if isinstance(model_cfg, dict): raw_ctx = model_cfg.get("context_length") @@ -5578,11 +4209,6 @@ def _format_session_info(self) -> str: pass provider = model_cfg.get("provider") or None base_url = model_cfg.get("base_url") or None - try: - from hermes_cli.config import get_compatible_custom_providers - custom_provs = get_compatible_custom_providers(data) - except Exception: - custom_provs = data.get("custom_providers") except Exception: pass @@ -5601,7 +4227,6 @@ def _format_session_info(self) -> str: api_key=api_key or "", config_context_length=config_context_length, provider=provider or "", - custom_providers=custom_provs, ) # Format context source hint @@ -5638,12 +4263,19 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: # Get existing session key session_key = self._session_key_for_source(source) - self._invalidate_session_run_generation(session_key, reason="session_reset") - - # Snapshot the old entry so on_session_finalize can report the - # expiring session id before reset_session() rotates it. - old_entry = self.session_store._entries.get(session_key) - + + # Flush memories in the background (fire-and-forget) so the user + # gets the "Session reset!" response immediately. + try: + old_entry = self.session_store._entries.get(session_key) + if old_entry: + _flush_task = asyncio.create_task( + self._async_flush_memories(old_entry.session_id, session_key) + ) + self._background_tasks.add(_flush_task) + _flush_task.add_done_callback(self._background_tasks.discard) + except Exception as e: + logger.debug("Gateway memory flush on reset failed: %s", e) # Close tool resources on the old agent (terminal sandboxes, browser # daemons, background processes) before evicting from cache. # Guard with getattr because test fixtures may skip __init__. @@ -5653,16 +4285,18 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: _cached = self._agent_cache.get(session_key) _old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None if _old_agent is not None: - self._cleanup_agent_resources(_old_agent) + try: + if hasattr(_old_agent, "shutdown_memory_provider"): + _old_agent.shutdown_memory_provider() + except Exception: + pass + try: + if hasattr(_old_agent, "close"): + _old_agent.close() + except Exception: + pass self._evict_cached_agent(session_key) - # Discard any /queue overflow for this session — /new is a - # conversation-boundary operation, queued follow-ups from the - # previous conversation must not bleed into the new one. - _qe = getattr(self, "_queued_events", None) - if _qe is not None: - _qe.pop(session_key, None) - try: from tools.env_passthrough import clear_env_passthrough clear_env_passthrough() @@ -5678,17 +4312,9 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: # Reset the session new_entry = self.session_store.reset_session(session_key) - # Clear any session-scoped model/reasoning overrides so the next agent - # picks up configured defaults instead of previous session switches. + # Clear any session-scoped model override so the next agent picks up + # the configured default instead of the previously switched model. self._session_model_overrides.pop(session_key, None) - self._set_session_reasoning_override(session_key, None) - if hasattr(self, "_pending_model_notes"): - self._pending_model_notes.pop(session_key, None) - - # Clear session-scoped dangerous-command approvals and /yolo state. - # /new is a conversation-boundary operation — approval state from the - # previous conversation must not survive the reset. - self._clear_session_boundary_security_state(session_key) # Fire plugin on_session_finalize hook (session boundary) try: @@ -5748,16 +4374,31 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show active profile name and home directory.""" - from hermes_constants import display_hermes_home - from hermes_cli.profiles import get_active_profile_name + from hermes_constants import get_hermes_home, display_hermes_home + from pathlib import Path + home = get_hermes_home() display = display_hermes_home() - profile_name = get_active_profile_name() - lines = [ - f"👤 **Profile:** `{profile_name}`", - f"📂 **Home:** `{display}`", - ] + # Detect profile name from HERMES_HOME path + # Profile paths look like: ~/.hermes/profiles/ + profiles_parent = Path.home() / ".hermes" / "profiles" + try: + rel = home.relative_to(profiles_parent) + profile_name = str(rel).split("/")[0] + except ValueError: + profile_name = None + + if profile_name: + lines = [ + f"👤 **Profile:** `{profile_name}`", + f"📂 **Home:** `{display}`", + ] + else: + lines = [ + "👤 **Profile:** default", + f"📂 **Home:** `{display}`", + ] return "\n".join(lines) @@ -5772,10 +4413,6 @@ async def _handle_status_command(self, event: MessageEvent) -> str: session_key = session_entry.session_key is_running = session_key in self._running_agents - # Count pending /queue follow-ups (slot + overflow). - adapter = self.adapters.get(source.platform) if source else None - queue_depth = self._queue_depth(session_key, adapter=adapter) - title = None if self._session_db: try: @@ -5795,105 +4432,11 @@ async def _handle_status_command(self, event: MessageEvent) -> str: f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}", f"**Tokens:** {session_entry.total_tokens:,}", f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}", - ]) - if queue_depth: - lines.append(f"**Queued follow-ups:** {queue_depth}") - lines.extend([ "", f"**Connected Platforms:** {', '.join(connected_platforms)}", ]) return "\n".join(lines) - - async def _handle_agents_command(self, event: MessageEvent) -> str: - """Handle /agents command - list active agents and running tasks.""" - from tools.process_registry import format_uptime_short, process_registry - - now = time.time() - current_session_key = self._session_key_for_source(event.source) - - running_agents: dict = getattr(self, "_running_agents", {}) or {} - running_started: dict = getattr(self, "_running_agents_ts", {}) or {} - - agent_rows: list[dict] = [] - for session_key, agent in running_agents.items(): - started = float(running_started.get(session_key, now)) - elapsed = max(0, int(now - started)) - is_pending = agent is _AGENT_PENDING_SENTINEL - agent_rows.append( - { - "session_key": session_key, - "elapsed": elapsed, - "state": "starting" if is_pending else "running", - "session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""), - "model": "" if is_pending else str(getattr(agent, "model", "") or ""), - } - ) - - agent_rows.sort(key=lambda row: row["elapsed"], reverse=True) - - running_processes: list[dict] = [] - try: - running_processes = [ - p for p in process_registry.list_sessions() - if p.get("status") == "running" - ] - except Exception: - running_processes = [] - - background_tasks = [ - t for t in (getattr(self, "_background_tasks", set()) or set()) - if hasattr(t, "done") and not t.done() - ] - - lines = [ - "🤖 **Active Agents & Tasks**", - "", - f"**Active agents:** {len(agent_rows)}", - ] - - if agent_rows: - for idx, row in enumerate(agent_rows[:12], 1): - current = " · this chat" if row["session_key"] == current_session_key else "" - sid = f" · `{row['session_id']}`" if row["session_id"] else "" - model = f" · `{row['model']}`" if row["model"] else "" - lines.append( - f"{idx}. `{row['session_key']}` · {row['state']} · " - f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}" - ) - if len(agent_rows) > 12: - lines.append(f"... and {len(agent_rows) - 12} more") - - lines.extend( - [ - "", - f"**Running background processes:** {len(running_processes)}", - ] - ) - if running_processes: - for proc in running_processes[:12]: - cmd = " ".join(str(proc.get("command", "")).split()) - if len(cmd) > 90: - cmd = cmd[:87] + "..." - lines.append( - f"- `{proc.get('session_id', '?')}` · " - f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`" - ) - if len(running_processes) > 12: - lines.append(f"... and {len(running_processes) - 12} more") - - lines.extend( - [ - "", - f"**Gateway async jobs:** {len(background_tasks)}", - ] - ) - - if not agent_rows and not running_processes and not background_tasks: - lines.append("") - lines.append("No active agents or running tasks.") - - return "\n".join(lines) async def _handle_stop_command(self, event: MessageEvent) -> str: """Handle /stop command - interrupt a running agent. @@ -5913,49 +4456,22 @@ async def _handle_stop_command(self, event: MessageEvent) -> str: agent = self._running_agents.get(session_key) if agent is _AGENT_PENDING_SENTINEL: # Force-clean the sentinel so the session is unlocked. - await self._interrupt_and_clear_session( - session_key, - source, - interrupt_reason=_INTERRUPT_REASON_STOP, - invalidation_reason="stop_command_pending", - ) - logger.info("STOP (pending) for session %s — sentinel cleared", session_key) + if session_key in self._running_agents: + del self._running_agents[session_key] + logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20]) return "⚡ Stopped. The agent hadn't started yet — you can continue this session." if agent: + agent.interrupt("Stop requested") # Force-clean the session lock so a truly hung agent doesn't # keep it locked forever. - await self._interrupt_and_clear_session( - session_key, - source, - interrupt_reason=_INTERRUPT_REASON_STOP, - invalidation_reason="stop_command_handler", - ) + if session_key in self._running_agents: + del self._running_agents[session_key] return "⚡ Stopped. You can continue this session." else: return "No active task to stop." async def _handle_restart_command(self, event: MessageEvent) -> str: """Handle /restart command - drain active work, then restart the gateway.""" - # Defensive idempotency check: if the previous gateway process - # recorded this same /restart (same platform + update_id) and the new - # process is seeing it *again*, this is a re-delivery caused by PTB's - # graceful-shutdown `get_updates` ACK failing on the way out ("Error - # while calling `get_updates` one more time to mark all fetched - # updates. Suppressing error to ensure graceful shutdown. When - # polling for updates is restarted, updates may be received twice." - # in gateway.log). Ignoring the stale redelivery prevents a - # self-perpetuating restart loop where every fresh gateway - # re-processes the same /restart command and immediately restarts - # again. - if self._is_stale_restart_redelivery(event): - logger.info( - "Ignoring redelivered /restart (platform=%s, update_id=%s) — " - "already processed by a previous gateway instance.", - event.source.platform.value if event.source and event.source.platform else "?", - event.platform_update_id, - ) - return "" - if self._restart_requested or self._draining: count = self._running_agent_count() if count: @@ -5965,6 +4481,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> str: # Save the requester's routing info so the new gateway process can # notify them once it comes back online. try: + import json as _json notify_data = { "platform": event.source.platform.value if event.source.platform else None, "chat_id": event.source.chat_id, @@ -5972,29 +4489,11 @@ async def _handle_restart_command(self, event: MessageEvent) -> str: if event.source.thread_id: notify_data["thread_id"] = event.source.thread_id (_hermes_home / ".restart_notify.json").write_text( - json.dumps(notify_data) + _json.dumps(notify_data) ) except Exception as e: logger.debug("Failed to write restart notify file: %s", e) - # Record the triggering platform + update_id in a dedicated dedup - # marker. Unlike .restart_notify.json (which gets unlinked once the - # new gateway sends the "gateway restarted" notification), this - # marker persists so the new gateway can still detect a delayed - # /restart redelivery from Telegram. Overwritten on every /restart. - try: - dedup_data = { - "platform": event.source.platform.value if event.source.platform else None, - "requested_at": time.time(), - } - if event.platform_update_id is not None: - dedup_data["update_id"] = event.platform_update_id - (_hermes_home / ".restart_last_processed.json").write_text( - json.dumps(dedup_data) - ) - except Exception as e: - logger.debug("Failed to write restart dedup marker: %s", e) - active_agents = self._running_agent_count() # When running under a service manager (systemd/launchd), use the # service restart path: exit with code 75 so the service manager @@ -6010,56 +4509,6 @@ async def _handle_restart_command(self, event: MessageEvent) -> str: return f"⏳ Draining {active_agents} active agent(s) before restart..." return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`." - def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool: - """Return True if this /restart is a Telegram re-delivery we already handled. - - The previous gateway wrote ``.restart_last_processed.json`` with the - triggering platform + update_id when it processed the /restart. If - we now see a /restart on the same platform with an update_id <= that - recorded value AND the marker is recent (< 5 minutes), it's a - redelivery and should be ignored. - - Only applies to Telegram today (the only platform that exposes a - numeric cross-session update ordering); other platforms return False. - """ - if event is None or event.source is None: - return False - if event.platform_update_id is None: - return False - if event.source.platform is None: - return False - # Only Telegram populates platform_update_id currently; be explicit - # so future platforms aren't accidentally gated by this check. - try: - platform_value = event.source.platform.value - except Exception: - return False - if platform_value != "telegram": - return False - - try: - marker_path = _hermes_home / ".restart_last_processed.json" - if not marker_path.exists(): - return False - data = json.loads(marker_path.read_text()) - except Exception: - return False - - if data.get("platform") != platform_value: - return False - recorded_uid = data.get("update_id") - if not isinstance(recorded_uid, int): - return False - # Staleness guard: ignore markers older than 5 minutes. A legitimately - # old marker (e.g. crash recovery where notify never fired) should not - # swallow a fresh /restart from the user. - requested_at = data.get("requested_at") - if isinstance(requested_at, (int, float)): - if time.time() - requested_at > 300: - return False - return event.platform_update_id <= recorded_uid - - async def _handle_help_command(self, event: MessageEvent) -> str: """Handle /help command - list available commands.""" from hermes_cli.commands import gateway_help_lines @@ -6166,8 +4615,9 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: custom_provs = None config_path = _hermes_home / "config.yaml" try: - cfg = _load_gateway_config() - if cfg: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): current_model = model_cfg.get("default", "") @@ -6205,8 +4655,6 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: try: providers = list_authenticated_providers( current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, user_providers=user_provs, custom_providers=custom_provs, max_models=50, @@ -6287,18 +4735,9 @@ async def _on_model_selected( lines = [f"Model switched to `{result.new_model}`"] lines.append(f"Provider: {plabel}") mi = result.model_info - from hermes_cli.model_switch import resolve_display_context_length - ctx = resolve_display_context_length( - result.new_model, - result.target_provider, - base_url=result.base_url or current_base_url or "", - api_key=result.api_key or current_api_key or "", - model_info=mi, - custom_providers=custom_provs, - ) - if ctx: - lines.append(f"Context: {ctx:,} tokens") if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") if mi.max_output: lines.append(f"Max output: {mi.max_output:,} tokens") if mi.has_cost_data(): @@ -6327,8 +4766,6 @@ async def _on_model_selected( try: providers = list_authenticated_providers( current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, user_providers=user_provs, custom_providers=custom_provs, max_models=5, @@ -6433,30 +4870,32 @@ async def _on_model_selected( lines = [f"Model switched to `{result.new_model}`"] lines.append(f"Provider: {provider_label}") - # Context: always resolve via the provider-aware chain so Codex OAuth, - # Copilot, and Nous-enforced caps win over the raw models.dev entry. + # Rich metadata from models.dev mi = result.model_info - from hermes_cli.model_switch import resolve_display_context_length - ctx = resolve_display_context_length( - result.new_model, - result.target_provider, - base_url=result.base_url or current_base_url or "", - api_key=result.api_key or current_api_key or "", - model_info=mi, - custom_providers=custom_provs, - ) - if ctx: - lines.append(f"Context: {ctx:,} tokens") if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") if mi.max_output: lines.append(f"Max output: {mi.max_output:,} tokens") if mi.has_cost_data(): lines.append(f"Cost: {mi.format_cost()}") lines.append(f"Capabilities: {mi.format_capabilities()}") + else: + try: + from agent.model_metadata import get_model_context_length + ctx = get_model_context_length( + result.new_model, + base_url=result.base_url or current_base_url, + api_key=result.api_key or current_api_key, + provider=result.target_provider, + ) + lines.append(f"Context: {ctx:,} tokens") + except Exception: + pass # Cache notice cache_enabled = ( - (base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower()) + ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) or result.api_mode == "anthropic_messages" ) if cache_enabled: @@ -6470,24 +4909,86 @@ async def _on_model_selected( else: lines.append("_(session only -- add `--global` to persist)_") - return "\n".join(lines) + return "\n".join(lines) + + async def _handle_provider_command(self, event: MessageEvent) -> str: + """Handle /provider command - show available providers.""" + import yaml + from hermes_cli.models import ( + list_available_providers, + normalize_provider, + _PROVIDER_LABELS, + ) + + # Resolve current provider from config + current_provider = "openrouter" + model_cfg = {} + config_path = _hermes_home / 'config.yaml' + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_provider = model_cfg.get("provider", current_provider) + except Exception: + pass + + current_provider = normalize_provider(current_provider) + if current_provider == "auto": + try: + from hermes_cli.auth import resolve_provider as _resolve_provider + current_provider = _resolve_provider(current_provider) + except Exception: + current_provider = "openrouter" + + # Detect custom endpoint from config base_url + if current_provider == "openrouter": + _cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else "" + if _cfg_base and "openrouter.ai" not in _cfg_base: + current_provider = "custom" + + current_label = _PROVIDER_LABELS.get(current_provider, current_provider) + + lines = [ + f"🔌 **Current provider:** {current_label} (`{current_provider}`)", + "", + "**Available providers:**", + ] + + providers = list_available_providers() + for p in providers: + marker = " ← active" if p["id"] == current_provider else "" + auth = "✅" if p["authenticated"] else "❌" + aliases = f" _(also: {', '.join(p['aliases'])})_" if p["aliases"] else "" + lines.append(f"{auth} `{p['id']}` — {p['label']}{aliases}{marker}") + lines.append("") + lines.append("Switch: `/model provider:model-name`") + lines.append("Setup: `hermes setup`") + return "\n".join(lines) + async def _handle_personality_command(self, event: MessageEvent) -> str: """Handle /personality command - list or set a personality.""" - from hermes_constants import display_hermes_home + import yaml args = event.get_command_args().strip().lower() config_path = _hermes_home / 'config.yaml' try: - config = _load_gateway_config() - personalities = cfg_get(config, "agent", "personalities", default={}) + if config_path.exists(): + with open(config_path, 'r', encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + personalities = config.get("agent", {}).get("personalities", {}) + else: + config = {} + personalities = {} except Exception: config = {} personalities = {} if not personalities: - return f"No personalities configured in `{display_hermes_home()}/config.yaml`" + return "No personalities configured in `~/.hermes/config.yaml`" if not args: lines = ["🎭 **Available Personalities**\n"] @@ -6571,7 +5072,6 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: message_type=MessageType.TEXT, source=source, raw_message=event.raw_message, - channel_prompt=event.channel_prompt, ) # Let the normal message handler process it @@ -6611,10 +5111,18 @@ async def _handle_set_home_command(self, event: MessageEvent) -> str: env_key = f"{platform_name.upper()}_HOME_CHANNEL" - # Save to .env so it persists across restarts + # Save to config.yaml try: - from hermes_cli.config import save_env_value - save_env_value(env_key, str(chat_id)) + import yaml + config_path = _hermes_home / 'config.yaml' + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + user_config[env_key] = chat_id + atomic_yaml_write(config_path, user_config) + # Also set in the current environment so it takes effect immediately + os.environ[env_key] = str(chat_id) except Exception as e: return f"Failed to save home channel: {e}" @@ -6641,32 +5149,30 @@ async def _handle_voice_command(self, event: MessageEvent) -> str: """Handle /voice [on|off|tts|channel|leave|status] command.""" args = event.get_command_args().strip().lower() chat_id = event.source.chat_id - platform = event.source.platform - voice_key = self._voice_key(platform, chat_id) - adapter = self.adapters.get(platform) + adapter = self.adapters.get(event.source.platform) if args in ("on", "enable"): - self._voice_mode[voice_key] = "voice_only" + self._voice_mode[chat_id] = "voice_only" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) return ( "Voice mode enabled.\n" "I'll reply with voice when you send voice messages.\n" "Use /voice tts to get voice replies for all messages." ) elif args in ("off", "disable"): - self._voice_mode[voice_key] = "off" + self._voice_mode[chat_id] = "off" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) return "Voice mode disabled. Text-only replies." elif args == "tts": - self._voice_mode[voice_key] = "all" + self._voice_mode[chat_id] = "all" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) return ( "Auto-TTS enabled.\n" "All replies will include a voice message." @@ -6676,7 +5182,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str: elif args == "leave": return await self._handle_voice_channel_leave(event) elif args == "status": - mode = self._voice_mode.get(voice_key, "off") + mode = self._voice_mode.get(chat_id, "off") labels = { "off": "Off (text only)", "voice_only": "On (voice reply to voice messages)", @@ -6700,15 +5206,15 @@ async def _handle_voice_command(self, event: MessageEvent) -> str: return f"Voice mode: {labels.get(mode, mode)}" else: # Toggle: off → on, on/all → off - current = self._voice_mode.get(voice_key, "off") + current = self._voice_mode.get(chat_id, "off") if current == "off": - self._voice_mode[voice_key] = "voice_only" + self._voice_mode[chat_id] = "voice_only" self._save_voice_modes() if adapter: - self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) + self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) return "Voice mode enabled." else: - self._voice_mode[voice_key] = "off" + self._voice_mode[chat_id] = "off" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) @@ -6746,7 +5252,8 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str: if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower: return ( "Voice dependencies are missing (PyNaCl / davey). " - f"Install with: `{sys.executable} -m pip install PyNaCl`" + "Install or reinstall Hermes with the messaging extra, e.g. " + "`pip install hermes-agent[messaging]`." ) return f"Failed to join voice channel: {e}" @@ -6754,9 +5261,9 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str: adapter._voice_text_channels[guild_id] = int(event.source.chat_id) if hasattr(adapter, "_voice_sources"): adapter._voice_sources[guild_id] = event.source.to_dict() - self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all" + self._voice_mode[event.source.chat_id] = "all" self._save_voice_modes() - self._set_adapter_auto_tts_enabled(adapter, event.source.chat_id, enabled=True) + self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False) return ( f"Joined voice channel **{voice_channel.name}**.\n" f"I'll speak my replies and listen to you. Use /voice leave to disconnect." @@ -6781,7 +5288,7 @@ async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: except Exception as e: logger.warning("Error leaving voice channel: %s", e) # Always clean up state even if leave raised an exception - self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "off" + self._voice_mode[event.source.chat_id] = "off" self._save_voice_modes() self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True) if hasattr(adapter, "_voice_input_callback"): @@ -6793,7 +5300,7 @@ def _handle_voice_timeout_cleanup(self, chat_id: str) -> None: Cleans up runner-side voice_mode state that the adapter cannot reach. """ - self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off" + self._voice_mode[chat_id] = "off" self._save_voice_modes() adapter = self.adapters.get(Platform.DISCORD) self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) @@ -6879,7 +5386,7 @@ def _should_send_voice_reply( return False chat_id = event.source.chat_id - voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off") + voice_mode = self._voice_mode.get(chat_id, "off") is_voice_input = (event.message_type == MessageType.VOICE) should = ( @@ -7074,7 +5581,7 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str: max_snapshots=cp_cfg.get("max_snapshots", 50), ) - cwd = os.getenv("TERMINAL_CWD", str(Path.home())) + cwd = os.getenv("MESSAGING_CWD", str(Path.home())) arg = event.get_command_args().strip() if not arg: @@ -7167,7 +5674,7 @@ async def _run_background_task( pr = self._provider_routing max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) - reasoning_config = self._resolve_session_reasoning_config(source=source) + reasoning_config = self._load_reasoning_config() self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) @@ -7192,23 +5699,17 @@ def run_sync(): session_id=task_id, platform=platform_key, user_id=source.user_id, - user_name=source.user_name, - chat_id=source.chat_id, - chat_name=source.chat_name, - chat_type=source.chat_type, - thread_id=source.thread_id, session_db=self._session_db, fallback_model=self._fallback_model, ) - try: - return agent.run_conversation( - user_message=prompt, - task_id=task_id, - ) - finally: - self._cleanup_agent_resources(agent) - result = await self._run_in_executor_with_context(run_sync) + return agent.run_conversation( + user_message=prompt, + task_id=task_id, + ) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, run_sync) response = result.get("final_response", "") if result else "" if not response and result and result.get("error"): @@ -7242,18 +5743,16 @@ def run_sync(): chat_id=source.chat_id, image_url=image_url, caption=alt_text, - metadata=_thread_metadata, ) except Exception: pass # Send media files - for media_path, _is_voice in (media_files or []): + for media_path in (media_files or []): try: await adapter.send_document( chat_id=source.chat_id, file_path=media_path, - metadata=_thread_metadata, ) except Exception: pass @@ -7276,28 +5775,187 @@ def run_sync(): except Exception: pass + async def _handle_btw_command(self, event: MessageEvent) -> str: + """Handle /btw — ephemeral side question in the same chat.""" + question = event.get_command_args().strip() + if not question: + return ( + "Usage: /btw \n" + "Example: /btw what module owns session title sanitization?\n\n" + "Answers using session context. No tools, not persisted." + ) + + source = event.source + session_key = self._session_key_for_source(source) + + # Guard: one /btw at a time per session + existing = getattr(self, "_active_btw_tasks", {}).get(session_key) + if existing and not existing.done(): + return "A /btw is already running for this chat. Wait for it to finish." + + if not hasattr(self, "_active_btw_tasks"): + self._active_btw_tasks: dict = {} + + import uuid as _uuid + task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" + _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) + self._background_tasks.add(_task) + self._active_btw_tasks[session_key] = _task + + def _cleanup(task): + self._background_tasks.discard(task) + if self._active_btw_tasks.get(session_key) is task: + self._active_btw_tasks.pop(session_key, None) + + _task.add_done_callback(_cleanup) + + preview = question[:60] + ("..." if len(question) > 60 else "") + return f'💬 /btw: "{preview}"\nReply will appear here shortly.' + + async def _run_btw_task( + self, question: str, source, session_key: str, task_id: str, + ) -> None: + """Execute an ephemeral /btw side question and deliver the answer.""" + from run_agent import AIAgent + + adapter = self.adapters.get(source.platform) + if not adapter: + logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) + return + + _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None + + try: + user_config = _load_gateway_config() + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + user_config=user_config, + ) + if not runtime_kwargs.get("api_key"): + await adapter.send( + source.chat_id, + "❌ /btw failed: no provider credentials configured.", + metadata=_thread_meta, + ) + return + + platform_key = _platform_config_key(source.platform) + reasoning_config = self._load_reasoning_config() + self._service_tier = self._load_service_tier() + turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) + pr = self._provider_routing + + # Snapshot history from running agent or stored transcript + running_agent = self._running_agents.get(session_key) + if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) + else: + session_entry = self.session_store.get_or_create_session(source) + history_snapshot = self.session_store.load_transcript(session_entry.session_id) + + btw_prompt = ( + "[Ephemeral /btw side question. Answer using the conversation " + "context. No tools available. Be direct and concise.]\n\n" + + question + ) + + def run_sync(): + agent = AIAgent( + model=turn_route["model"], + **turn_route["runtime"], + max_iterations=8, + quiet_mode=True, + verbose_logging=False, + enabled_toolsets=[], + reasoning_config=reasoning_config, + service_tier=self._service_tier, + request_overrides=turn_route.get("request_overrides"), + providers_allowed=pr.get("only"), + providers_ignored=pr.get("ignore"), + providers_order=pr.get("order"), + provider_sort=pr.get("sort"), + provider_require_parameters=pr.get("require_parameters", False), + provider_data_collection=pr.get("data_collection"), + session_id=task_id, + platform=platform_key, + session_db=None, + fallback_model=self._fallback_model, + skip_memory=True, + skip_context_files=True, + persist_session=False, + ) + return agent.run_conversation( + user_message=btw_prompt, + conversation_history=history_snapshot, + task_id=task_id, + ) + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, run_sync) + + response = (result.get("final_response") or "") if result else "" + if not response and result and result.get("error"): + response = f"Error: {result['error']}" + if not response: + response = "(No response generated)" + + media_files, response = adapter.extract_media(response) + images, text_content = adapter.extract_images(response) + preview = question[:60] + ("..." if len(question) > 60 else "") + header = f'💬 /btw: "{preview}"\n\n' + + if text_content: + await adapter.send( + chat_id=source.chat_id, + content=header + text_content, + metadata=_thread_meta, + ) + elif not images and not media_files: + await adapter.send( + chat_id=source.chat_id, + content=header + "(No response generated)", + metadata=_thread_meta, + ) + + for image_url, alt_text in (images or []): + try: + await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) + except Exception: + pass + + for media_path in (media_files or []): + try: + await adapter.send_file(chat_id=source.chat_id, file_path=media_path) + except Exception: + pass + + except Exception as e: + logger.exception("/btw task %s failed", task_id) + try: + await adapter.send( + chat_id=source.chat_id, + content=f"❌ /btw failed: {e}", + metadata=_thread_meta, + ) + except Exception: + pass + async def _handle_reasoning_command(self, event: MessageEvent) -> str: """Handle /reasoning command — manage reasoning effort and display toggle. Usage: - /reasoning Show current effort level and display state - /reasoning Set reasoning effort for this session only - /reasoning --global Persist reasoning effort to config.yaml - /reasoning reset Clear this session's reasoning override - /reasoning show|on Show model reasoning in responses - /reasoning hide|off Hide model reasoning from responses + /reasoning Show current effort level and display state + /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh) + /reasoning show|on Show model reasoning in responses + /reasoning hide|off Hide model reasoning from responses """ import yaml - raw_args = event.get_command_args().strip() - args, persist_global = self._parse_reasoning_command_args(raw_args) + args = event.get_command_args().strip().lower() config_path = _hermes_home / "config.yaml" - session_key = self._session_key_for_source(event.source) + self._reasoning_config = self._load_reasoning_config() self._show_reasoning = self._load_show_reasoning() - self._reasoning_config = self._resolve_session_reasoning_config( - source=event.source, - session_key=session_key, - ) def _save_config_key(key_path: str, value): """Save a dot-separated key to config.yaml.""" @@ -7319,7 +5977,7 @@ def _save_config_key(key_path: str, value): logger.error("Failed to save config key %s: %s", key_path, e) return False - if not raw_args: + if not args: # Show current state rc = self._reasoning_config if rc is None: @@ -7329,14 +5987,11 @@ def _save_config_key(key_path: str, value): else: level = rc.get("effort", "medium") display_state = "on ✓" if self._show_reasoning else "off" - has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {}) - scope = "session override" if has_session_override else "global config" return ( "🧠 **Reasoning Settings**\n\n" f"**Effort:** `{level}`\n" - f"**Scope:** {scope}\n" f"**Display:** {display_state}\n\n" - "_Usage:_ `/reasoning [--global]`" + "_Usage:_ `/reasoning `" ) # Display toggle (per-platform) @@ -7356,38 +6011,22 @@ def _save_config_key(key_path: str, value): # Effort level change effort = args.strip() - if effort == "reset": - if persist_global: - return "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning --global` to change the global default." - self._set_session_reasoning_override(session_key, None) - self._reasoning_config = self._load_reasoning_config() - self._evict_cached_agent(session_key) - return "🧠 ✓ Session reasoning override cleared; falling back to global config." if effort == "none": parsed = {"enabled": False} elif effort in ("minimal", "low", "medium", "high", "xhigh"): parsed = {"enabled": True, "effort": effort} else: return ( - f"⚠️ Unknown argument: `{effort or raw_args.lower()}`\n\n" + f"⚠️ Unknown argument: `{effort}`\n\n" "**Valid levels:** none, minimal, low, medium, high, xhigh\n" - "**Display:** show, hide\n" - "**Persist:** add `--global` to save beyond this session" + "**Display:** show, hide" ) self._reasoning_config = parsed - if persist_global: - if _save_config_key("agent.reasoning_effort", effort): - self._set_session_reasoning_override(session_key, None) - self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_" - - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_" + if _save_config_key("agent.reasoning_effort", effort): + return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" + else: + return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" async def _handle_fast_command(self, event: MessageEvent) -> str: """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" @@ -7475,14 +6114,18 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str: ``display.platforms..tool_progress`` so each channel can have its own verbosity level independently. """ + import yaml config_path = _hermes_home / "config.yaml" platform_key = _platform_config_key(event.source.platform) # --- check config gate ------------------------------------------------ try: - user_config = _load_gateway_config() - gate_enabled = cfg_get(user_config, "display", "tool_progress_command", default=False) + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + gate_enabled = user_config.get("display", {}).get("tool_progress_command", False) except Exception: gate_enabled = False @@ -7529,94 +6172,6 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str: logger.warning("Failed to save tool_progress mode: %s", e) return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" - async def _handle_footer_command(self, event: MessageEvent) -> str: - """Handle /footer command — toggle the runtime-metadata footer. - - Usage: - /footer → toggle on/off - /footer on → enable globally - /footer off → disable globally - /footer status → show current state + fields - - The footer is saved to ``display.runtime_footer.enabled`` (global). - Per-platform overrides under ``display.platforms..runtime_footer`` - are respected but not modified here — edit config.yaml directly for - per-platform control. - """ - from gateway.runtime_footer import resolve_footer_config - - config_path = _hermes_home / "config.yaml" - platform_key = _platform_config_key(event.source.platform) - - # --- parse argument ------------------------------------------------- - arg = "" - try: - text = (getattr(event, "message", None) or "").strip() - if text.startswith("/"): - parts = text.split(None, 1) - if len(parts) > 1: - arg = parts[1].strip().lower() - except Exception: - arg = "" - - # --- load config ---------------------------------------------------- - try: - user_config: dict = _load_gateway_config() - except Exception as e: - return f"⚠️ Could not read config.yaml: {e}" - - effective = resolve_footer_config(user_config, platform_key) - - if arg in ("status", "?"): - state = "ON" if effective["enabled"] else "OFF" - fields = ", ".join(effective.get("fields") or []) - return ( - f"📎 Runtime footer: **{state}**\n" - f"Fields: `{fields}`\n" - f"Platform: `{platform_key}`" - ) - - if arg in ("on", "enable", "true", "1"): - new_state = True - elif arg in ("off", "disable", "false", "0"): - new_state = False - elif arg == "": - new_state = not effective["enabled"] - else: - return "Usage: `/footer [on|off|status]`" - - # --- write global flag --------------------------------------------- - try: - if not isinstance(user_config.get("display"), dict): - user_config["display"] = {} - display = user_config["display"] - if not isinstance(display.get("runtime_footer"), dict): - display["runtime_footer"] = {} - display["runtime_footer"]["enabled"] = new_state - atomic_yaml_write(config_path, user_config) - except Exception as e: - logger.warning("Failed to save runtime_footer.enabled: %s", e) - return f"⚠️ Could not save config: {e}" - - state = "ON" if new_state else "OFF" - example = "" - if new_state: - # Show a preview using current agent state if available. - from gateway.runtime_footer import format_runtime_footer - preview = format_runtime_footer( - model=_resolve_gateway_model(user_config) or None, - context_tokens=0, - context_length=None, - fields=effective.get("fields") or ["model", "context_pct", "cwd"], - ) - if preview: - example = f"\nExample: `{preview}`" - return ( - f"📎 Runtime footer: **{state}**" - f"{example}\n" - f"_(saved globally — takes effect on next message)_" - ) - async def _handle_compress_command(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context. @@ -7652,6 +6207,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: for m in history if m.get("role") in ("user", "assistant") and m.get("content") ] + original_count = len(msgs) approx_tokens = estimate_messages_tokens_rough(msgs) tmp_agent = AIAgent( @@ -7659,77 +6215,51 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: model=model, max_iterations=4, quiet_mode=True, - skip_memory=True, enabled_toolsets=["memory"], session_id=session_entry.session_id, ) - try: - tmp_agent._print_fn = lambda *a, **kw: None - - compressor = tmp_agent.context_compressor - if not compressor.has_content_to_compress(msgs): - return "Nothing to compress yet (the transcript is still all protected context)." - - loop = asyncio.get_running_loop() - compressed, _ = await loop.run_in_executor( - None, - lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) - ) + tmp_agent._print_fn = lambda *a, **kw: None + + compressor = tmp_agent.context_compressor + compress_start = compressor.protect_first_n + compress_start = compressor._align_boundary_forward(msgs, compress_start) + compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start) + if compress_start >= compress_end: + return "Nothing to compress yet (the transcript is still all protected context)." + + loop = asyncio.get_event_loop() + compressed, _ = await loop.run_in_executor( + None, + lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) + ) - # _compress_context already calls end_session() on the old session - # (preserving its full transcript in SQLite) and creates a new - # session_id for the continuation. Write the compressed messages - # into the NEW session so the original history stays searchable. - new_session_id = tmp_agent.session_id - if new_session_id != session_entry.session_id: - session_entry.session_id = new_session_id - self.session_store._save() + # _compress_context already calls end_session() on the old session + # (preserving its full transcript in SQLite) and creates a new + # session_id for the continuation. Write the compressed messages + # into the NEW session so the original history stays searchable. + new_session_id = tmp_agent.session_id + if new_session_id != session_entry.session_id: + session_entry.session_id = new_session_id + self.session_store._save() - self.session_store.rewrite_transcript(new_session_id, compressed) - # Reset stored token count — transcript changed, old value is stale - self.session_store.update_session( - session_entry.session_key, last_prompt_tokens=0 - ) - new_tokens = estimate_messages_tokens_rough(compressed) - summary = summarize_manual_compression( - msgs, - compressed, - approx_tokens, - new_tokens, - ) - # Detect summary-generation failure so we can surface a - # visible warning to the user even on the manual /compress - # path (otherwise the failure is silently logged). - _summary_failed = bool(getattr(compressor, "_last_summary_fallback_used", False)) - _dropped_count = int(getattr(compressor, "_last_summary_dropped_count", 0) or 0) - _summary_err = getattr(compressor, "_last_summary_error", None) - # Separately: did the user's CONFIGURED aux model fail - # and we recovered via main? Surface that as an info - # note so they can fix their config. - _aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None) - _aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None) - finally: - self._cleanup_agent_resources(tmp_agent) + self.session_store.rewrite_transcript(new_session_id, compressed) + # Reset stored token count — transcript changed, old value is stale + self.session_store.update_session( + session_entry.session_key, last_prompt_tokens=0 + ) + new_tokens = estimate_messages_tokens_rough(compressed) + summary = summarize_manual_compression( + msgs, + compressed, + approx_tokens, + new_tokens, + ) lines = [f"🗜️ {summary['headline']}"] if focus_topic: lines.append(f"Focus: \"{focus_topic}\"") lines.append(summary["token_line"]) if summary["note"]: lines.append(summary["note"]) - if _summary_failed: - lines.append( - f"⚠️ Summary generation failed ({_summary_err or 'unknown error'}). " - f"{_dropped_count} historical message(s) were removed and replaced " - "with a placeholder; earlier context is no longer recoverable. " - "Consider checking your auxiliary.compression model configuration." - ) - elif _aux_fail_model: - lines.append( - f"ℹ️ Configured compression model `{_aux_fail_model}` failed " - f"({_aux_fail_err or 'unknown error'}). Recovered using your main " - "model — context is intact — but you may want to check " - "`auxiliary.compression.model` in config.yaml." - ) return "\n".join(lines) except Exception as e: logger.warning("Manual compress failed: %s", e) @@ -7818,40 +6348,37 @@ async def _handle_resume_command(self, event: MessageEvent) -> str: logger.debug("Failed to list titled sessions: %s", e) return f"Could not list sessions: {e}" - # Resolve the name to a session ID. + # Resolve the name to a session ID target_id = self._session_db.resolve_session_by_title(name) if not target_id: return ( f"No session found matching '**{name}**'.\n" "Use `/resume` with no arguments to see available sessions." ) - # Compression creates child continuations that hold the live transcript. - # Follow that chain so gateway /resume matches CLI behavior (#15000). - try: - target_id = self._session_db.resolve_resume_session_id(target_id) - except Exception as e: - logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e) # Check if already on that session current_entry = self.session_store.get_or_create_session(source) if current_entry.session_id == target_id: return f"📌 Already on session **{name}**." + # Flush memories for current session before switching + try: + _flush_task = asyncio.create_task( + self._async_flush_memories(current_entry.session_id, session_key) + ) + self._background_tasks.add(_flush_task) + _flush_task.add_done_callback(self._background_tasks.discard) + except Exception as e: + logger.debug("Memory flush on resume failed: %s", e) + # Clear any running agent for this session key - self._release_running_agent_state(session_key) + if session_key in self._running_agents: + del self._running_agents[session_key] # Switch the session entry to point at the old session new_entry = self.session_store.switch_session(session_key, target_id) if not new_entry: return "Failed to switch session." - self._clear_session_boundary_security_state(session_key) - - # Evict any cached agent for this session so the next message - # rebuilds with the correct session_id end-to-end — mirrors - # /branch and /reset. Without this, the cached AIAgent (and its - # memory provider, which cached `_session_id` during initialize()) - # keeps writing into the wrong session's record. See #6672. - self._evict_cached_agent(session_key) # Get the title for confirmation title = self._session_db.get_session_title(target_id) or name @@ -7926,7 +6453,6 @@ async def _handle_branch_command(self, event: MessageEvent) -> str: tool_calls=msg.get("tool_calls"), tool_call_id=msg.get("tool_call_id"), reasoning=msg.get("reasoning"), - reasoning_content=msg.get("reasoning_content"), ) except Exception: pass # Best-effort copy @@ -7941,7 +6467,6 @@ async def _handle_branch_command(self, event: MessageEvent) -> str: new_entry = self.session_store.switch_session(session_key, new_session_id) if not new_entry: return "Branch created but failed to switch to it." - self._clear_session_boundary_security_state(session_key) # Evict any cached agent for this session self._evict_cached_agent(session_key) @@ -7976,38 +6501,6 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: if cached: agent = cached[0] - # Resolve provider/base_url/api_key for the account-usage fetch. - # Prefer the live agent; fall back to persisted billing data on the - # SessionDB row so `/usage` still returns account info between turns - # when no agent is resident. - provider = getattr(agent, "provider", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None - base_url = getattr(agent, "base_url", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None - api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None - if not provider and getattr(self, "_session_db", None) is not None: - try: - _entry_for_billing = self.session_store.get_or_create_session(source) - persisted = self._session_db.get_session(_entry_for_billing.session_id) or {} - except Exception: - persisted = {} - provider = provider or persisted.get("billing_provider") - base_url = base_url or persisted.get("billing_base_url") - - # Fetch account usage off the event loop so slow provider APIs don't - # block the gateway. Failures are non-fatal -- account_lines stays []. - account_lines: list[str] = [] - if provider: - try: - account_snapshot = await asyncio.to_thread( - fetch_account_usage, - provider, - base_url=base_url, - api_key=api_key, - ) - except Exception: - account_snapshot = None - if account_snapshot: - account_lines = render_account_usage_lines(account_snapshot, markdown=True) - if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: lines = [] @@ -8065,10 +6558,6 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: if ctx.compression_count: lines.append(f"Compressions: {ctx.compression_count}") - if account_lines: - lines.append("") - lines.extend(account_lines) - return "\n".join(lines) # No agent at all -- check session history for a rough count @@ -8078,27 +6567,19 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: from agent.model_metadata import estimate_messages_tokens_rough msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")] approx = estimate_messages_tokens_rough(msgs) - lines = [ - "📊 **Session Info**", - f"Messages: {len(msgs)}", - f"Estimated context: ~{approx:,} tokens", - "_(Detailed usage available after the first agent response)_", - ] - if account_lines: - lines.append("") - lines.extend(account_lines) - return "\n".join(lines) - if account_lines: - return "\n".join(account_lines) + return ( + f"📊 **Session Info**\n" + f"Messages: {len(msgs)}\n" + f"Estimated context: ~{approx:,} tokens\n" + f"_(Detailed usage available after the first agent response)_" + ) return "No usage data available for this session." async def _handle_insights_command(self, event: MessageEvent) -> str: """Handle /insights command -- show usage insights and analytics.""" - args = event.get_command_args().strip() - - # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash) - args = re.sub(r'[\u2012\u2013\u2014\u2015](days|source)', r'--\1', args) + import asyncio as _asyncio + args = event.get_command_args().strip() days = 30 source = None @@ -8126,7 +6607,7 @@ async def _handle_insights_command(self, event: MessageEvent) -> str: from hermes_state import SessionDB from agent.insights import InsightsEngine - loop = asyncio.get_running_loop() + loop = _asyncio.get_event_loop() def _run_insights(): db = SessionDB() @@ -8143,7 +6624,7 @@ def _run_insights(): async def _handle_reload_mcp_command(self, event: MessageEvent) -> str: """Handle /reload-mcp command -- disconnect and reconnect all MCP servers.""" - loop = asyncio.get_running_loop() + loop = asyncio.get_event_loop() try: from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock @@ -8192,7 +6673,7 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str: change_detail = ". ".join(change_parts) + ". " if change_parts else "" reload_msg = { "role": "user", - "content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", + "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", } try: session_entry = self.session_store.get_or_create_session(event.source) @@ -8322,45 +6803,56 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: }) async def _handle_debug_command(self, event: MessageEvent) -> str: - """Handle /debug — upload debug report (summary only) and return paste URLs. - - Gateway uploads ONLY the summary report (system info + log tails), - NOT full log files, to protect conversation privacy. Users who need - full log uploads should use ``hermes debug share`` from the CLI. - """ + """Handle /debug — upload debug report + logs and return paste URLs.""" import asyncio from hermes_cli.debug import ( - _capture_dump, collect_debug_report, - upload_to_pastebin, _schedule_auto_delete, - _GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes, + _capture_dump, collect_debug_report, _read_full_log, + upload_to_pastebin, ) loop = asyncio.get_running_loop() # Run blocking I/O (dump capture, log reads, uploads) in a thread. def _collect_and_upload(): - _best_effort_sweep_expired_pastes() dump_text = _capture_dump() report = collect_debug_report(log_lines=200, dump_text=dump_text) + agent_log = _read_full_log("agent") + gateway_log = _read_full_log("gateway") + + if agent_log: + agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log + if gateway_log: + gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log urls = {} + failures = [] + try: urls["Report"] = upload_to_pastebin(report) except Exception as exc: return f"✗ Failed to upload debug report: {exc}" - # Schedule auto-deletion after 6 hours - _schedule_auto_delete(list(urls.values())) + if agent_log: + try: + urls["agent.log"] = upload_to_pastebin(agent_log) + except Exception: + failures.append("agent.log") + + if gateway_log: + try: + urls["gateway.log"] = upload_to_pastebin(gateway_log) + except Exception: + failures.append("gateway.log") - lines = [_GATEWAY_PRIVACY_NOTICE, "", "**Debug report uploaded:**", ""] + lines = ["**Debug report uploaded:**", ""] label_width = max(len(k) for k in urls) for label, url in urls.items(): lines.append(f"`{label:<{label_width}}` {url}") - lines.append("") - lines.append("⏱ Pastes will auto-delete in 6 hours.") - lines.append("For full log uploads, use `hermes debug share` from the CLI.") - lines.append("Share these links with the Hermes team for support.") + if failures: + lines.append(f"\n_(failed to upload: {', '.join(failures)})_") + + lines.append("\nShare these links with the Hermes team for support.") return "\n".join(lines) return await loop.run_in_executor(None, _collect_and_upload) @@ -8485,6 +6977,9 @@ async def _watch_update_progress( the messenger. The user's next message is intercepted by ``_handle_message`` and written to ``.update_response``. """ + import json + import re as _re + pending_path = _hermes_home / ".update_pending.json" claimed_path = _hermes_home / ".update_pending.claimed.json" output_path = _hermes_home / ".update_output.txt" @@ -8529,7 +7024,7 @@ async def _watch_update_progress( return def _strip_ansi(text: str) -> str: - return re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text) + return _re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text) bytes_sent = 0 last_stream_time = loop.time() @@ -8677,6 +7172,9 @@ async def _send_update_notification(self) -> bool: cannot resolve the adapter (e.g. after a gateway restart where the platform hasn't reconnected yet). """ + import json + import re as _re + pending_path = _hermes_home / ".update_pending.json" claimed_path = _hermes_home / ".update_pending.claimed.json" output_path = _hermes_home / ".update_output.txt" @@ -8722,7 +7220,7 @@ async def _send_update_notification(self) -> bool: if adapter and chat_id: # Strip ANSI escape codes for clean display - output = re.sub(r'\x1b\[[0-9;]*m', '', output).strip() + output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip() if output: if len(output) > 3500: output = "…" + output[-3500:] @@ -8755,12 +7253,14 @@ async def _send_update_notification(self) -> bool: async def _send_restart_notification(self) -> None: """Notify the chat that initiated /restart that the gateway is back.""" + import json as _json + notify_path = _hermes_home / ".restart_notify.json" if not notify_path.exists(): return try: - data = json.loads(notify_path.read_text()) + data = _json.loads(notify_path.read_text()) platform_str = data.get("platform") chat_id = data.get("chat_id") thread_id = data.get("thread_id") @@ -8817,36 +7317,7 @@ def _clear_session_env(self, tokens: list) -> None: """Restore session context variables to their pre-handler values.""" from gateway.session_context import clear_session_vars clear_session_vars(tokens) - - async def _run_in_executor_with_context(self, func, *args): - """Run blocking work in the thread pool while preserving session contextvars.""" - loop = asyncio.get_running_loop() - ctx = copy_context() - return await loop.run_in_executor(None, ctx.run, func, *args) - - def _decide_image_input_mode(self) -> str: - """Resolve the image-input routing for the currently active model. - - Returns ``"native"`` (attach pixels on the user turn) or ``"text"`` - (pre-analyze with vision_analyze and prepend the description). See - agent/image_routing.py for the full decision table. - - The active provider/model are read from config.yaml so the decision - tracks ``/model`` switches automatically on the next message. - """ - try: - from agent.image_routing import decide_image_input_mode - from agent.auxiliary_client import _read_main_model, _read_main_provider - from hermes_cli.config import load_config - - cfg = load_config() - provider = _read_main_provider() - model = _read_main_model() - return decide_image_input_mode(provider, model, cfg) - except Exception as exc: - logger.debug("image_routing: decision failed, falling back to text — %s", exc) - return "text" - + async def _enrich_message_with_vision( self, user_text: str, @@ -8869,7 +7340,7 @@ async def _enrich_message_with_vision( The enriched message string with vision descriptions prepended. """ from tools.vision_tools import vision_analyze_tool - from agent.memory_manager import sanitize_context + import json as _json analysis_prompt = ( "Describe everything visible in this image in thorough detail. " @@ -8885,10 +7356,9 @@ async def _enrich_message_with_vision( image_url=path, user_prompt=analysis_prompt, ) - result = json.loads(result_json) + result = _json.loads(result_json) if result.get("success"): description = result.get("analysis", "") - description = sanitize_context(description) enriched_parts.append( f"[The user sent an image~ Here's what I can see:\n{description}]\n" f"[If you need a closer look, use vision_analyze with " @@ -8945,6 +7415,7 @@ async def _enrich_message_with_transcription( return disabled_note from tools.transcription_tools import transcribe_audio + import asyncio enriched_parts = [] for path in audio_paths: @@ -9001,75 +7472,14 @@ async def _enrich_message_with_transcription( return prefix return user_text - def _build_process_event_source(self, evt: dict): - """Resolve the canonical source for a synthetic background-process event. - - Prefer the persisted session-store origin for the event's session key. - Falling back to the currently active foreground event is what causes - cross-topic bleed, so don't do that. - """ - from gateway.session import SessionSource - - session_key = str(evt.get("session_key") or "").strip() - derived_platform = "" - derived_chat_type = "" - derived_chat_id = "" - - if session_key: - try: - self.session_store._ensure_loaded() - entry = self.session_store._entries.get(session_key) - if entry and getattr(entry, "origin", None): - return entry.origin - except Exception as exc: - logger.debug( - "Synthetic process-event session-store lookup failed for %s: %s", - session_key, - exc, - ) - - _parsed = _parse_session_key(session_key) - if _parsed: - derived_platform = _parsed["platform"] - derived_chat_type = _parsed["chat_type"] - derived_chat_id = _parsed["chat_id"] - - platform_name = str(evt.get("platform") or derived_platform or "").strip().lower() - chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() - chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip() - if not platform_name or not chat_type or not chat_id: - return None - - try: - platform = Platform(platform_name) - except Exception: - logger.warning( - "Synthetic process event has invalid platform metadata: %r", - platform_name, - ) - return None - - return SessionSource( - platform=platform, - chat_id=chat_id, - chat_type=chat_type, - thread_id=str(evt.get("thread_id") or "").strip() or None, - user_id=str(evt.get("user_id") or "").strip() or None, - user_name=str(evt.get("user_name") or "").strip() or None, - ) - - async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: + async def _inject_watch_notification(self, synth_text: str, original_event) -> None: """Inject a watch-pattern notification as a synthetic message event. - Routing must come from the queued watch event itself, not from whatever - foreground message happened to be active when the queue was drained. + Uses the source from the original user event to route the notification + back to the correct chat/adapter. """ - source = self._build_process_event_source(evt) + source = getattr(original_event, "source", None) if not source: - logger.warning( - "Dropping watch notification with no routing metadata for process %s", - evt.get("session_id", "unknown"), - ) return platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) adapter = None @@ -9080,18 +7490,14 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: if not adapter: return try: + from gateway.platforms.base import MessageEvent, MessageType synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, source=source, internal=True, ) - logger.info( - "Watch pattern notification — injecting for %s chat=%s thread=%s", - platform_name, - source.chat_id, - source.thread_id, - ) + logger.info("Watch pattern notification — injecting for %s", platform_name) await adapter.handle_message(synth_event) except Exception as e: logger.error("Watch notification injection error: %s", e) @@ -9156,46 +7562,38 @@ async def _run_process_watcher(self, watcher: dict) -> None: from tools.ansi_strip import strip_ansi _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" synth_text = ( - f"[IMPORTANT: Background process {session_id} completed " + f"[SYSTEM: Background process {session_id} completed " f"(exit code {session.exit_code}).\n" f"Command: {session.command}\n" f"Output:\n{_out}]" ) - source = self._build_process_event_source({ - "session_id": session_id, - "session_key": session_key, - "platform": platform_name, - "chat_id": chat_id, - "thread_id": thread_id, - "user_id": user_id, - "user_name": user_name, - }) - if not source: - logger.warning( - "Dropping completion notification with no routing metadata for process %s", - session_id, - ) - break - adapter = None for p, a in self.adapters.items(): - if p == source.platform: + if p.value == platform_name: adapter = a break - if adapter and source.chat_id: + if adapter and chat_id: try: + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + from gateway.config import Platform + _platform_enum = Platform(platform_name) + _source = SessionSource( + platform=_platform_enum, + chat_id=chat_id, + thread_id=thread_id or None, + user_id=user_id or None, + user_name=user_name or None, + ) synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, - source=source, + source=_source, internal=True, ) logger.info( - "Process %s finished — injecting agent notification for session %s chat=%s thread=%s", - session_id, - session_key, - source.chat_id, - source.thread_id, + "Process %s finished — injecting agent notification for session %s", + session_id, session_key, ) await adapter.handle_message(synth_event) except Exception as e: @@ -9251,47 +7649,12 @@ async def _run_process_watcher(self, watcher: dict) -> None: _MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling (#816) - # Config keys whose values MUST invalidate the gateway's cached agent - # when they change. The agent bakes these into its compressor / context - # handling at construction time, so a mid-running-gateway config edit - # would otherwise be silently ignored until the user triggers a - # different cache eviction (model switch, /reset, etc.). - # - # Each entry is a tuple of (section, key) read from the raw config dict. - # Add more here as new baked-at-construction config settings are added. - _CACHE_BUSTING_CONFIG_KEYS: tuple = ( - ("model", "context_length"), - ("compression", "enabled"), - ("compression", "threshold"), - ("compression", "target_ratio"), - ("compression", "protect_last_n"), - ) - - @classmethod - def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: - """Pull the subset of config values that must bust the agent cache. - - Returns a flat dict keyed by 'section.key'. Missing keys and - non-dict sections yield None values, which still contribute to - the signature (so 'absent' vs 'present-and-null' differ). - """ - out: Dict[str, Any] = {} - cfg = user_config if isinstance(user_config, dict) else {} - for section, key in cls._CACHE_BUSTING_CONFIG_KEYS: - section_val = cfg.get(section) - if isinstance(section_val, dict): - out[f"{section}.{key}"] = section_val.get(key) - else: - out[f"{section}.{key}"] = None - return out - @staticmethod def _agent_config_signature( model: str, runtime: dict, enabled_toolsets: list, ephemeral_prompt: str, - cache_keys: dict | None = None, ) -> str: """Compute a stable string key from agent config values. @@ -9299,12 +7662,6 @@ def _agent_config_signature( discarded and rebuilt. When it stays the same, the cached agent is reused — preserving the frozen system prompt and tool schemas for prompt cache hits. - - ``cache_keys`` is an optional flat dict of additional config values - that should invalidate the cache when they change. Callers pass - the output of ``_extract_cache_busting_config(user_config)`` so - edits to model.context_length / compression.* in config.yaml are - picked up on the next gateway message without a manual restart. """ import hashlib, json as _j @@ -9315,8 +7672,6 @@ def _agent_config_signature( _api_key = str(runtime.get("api_key", "") or "") _api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else "" - _cache_keys_sorted = sorted((cache_keys or {}).items()) - blob = _j.dumps( [ model, @@ -9328,7 +7683,6 @@ def _agent_config_signature( # reasoning_config excluded — it's set per-message on the # cached agent and doesn't affect system prompt or tools. ephemeral_prompt or "", - _cache_keys_sorted, ], sort_keys=True, default=str, @@ -9361,152 +7715,6 @@ def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bo override = self._session_model_overrides.get(session_key) return override is not None and override.get("model") == agent_model - def _release_running_agent_state( - self, - session_key: str, - *, - run_generation: Optional[int] = None, - ) -> bool: - """Pop ALL per-running-agent state entries for ``session_key``. - - Replaces ad-hoc ``del self._running_agents[key]`` calls scattered - across the gateway. Those sites had drifted: some popped only - ``_running_agents``; some also ``_running_agents_ts``; only one - path also cleared ``_busy_ack_ts``. Each missed entry was a - small, persistent leak — a (str_key → float) tuple per session - per gateway lifetime. - - Use this at every site that ends a running turn, regardless of - cause (normal completion, /stop, /reset, /resume, sentinel - cleanup, stale-eviction). Per-session state that PERSISTS - across turns (``_session_model_overrides``, ``_voice_mode``, - ``_pending_approvals``, ``_update_prompt_pending``) is NOT - touched here — those have their own lifecycles. - - When ``run_generation`` is provided, only clear the slot if that - generation is still current for the session. This prevents an - older async run whose generation was bumped by /stop or /new from - clobbering a newer run's state during its own unwind. Returns - True when the slot was cleared, False when an ownership guard - blocked it. - """ - if not session_key: - return False - if run_generation is not None and not self._is_session_run_current( - session_key, run_generation - ): - return False - self._running_agents.pop(session_key, None) - self._running_agents_ts.pop(session_key, None) - if hasattr(self, "_busy_ack_ts"): - self._busy_ack_ts.pop(session_key, None) - return True - - def _clear_session_boundary_security_state(self, session_key: str) -> None: - """Clear per-session control state that must not survive a boundary switch.""" - if not session_key: - return - - pending_approvals = getattr(self, "_pending_approvals", None) - if isinstance(pending_approvals, dict): - pending_approvals.pop(session_key, None) - - update_prompt_pending = getattr(self, "_update_prompt_pending", None) - if isinstance(update_prompt_pending, dict): - update_prompt_pending.pop(session_key, None) - - try: - from tools.approval import clear_session as _clear_approval_session - except Exception: - return - - try: - _clear_approval_session(session_key) - except Exception as e: - logger.debug( - "Failed to clear approval state for session boundary %s: %s", - session_key, - e, - ) - - def _begin_session_run_generation(self, session_key: str) -> int: - """Claim a fresh run generation token for ``session_key``. - - Every top-level gateway turn gets a monotonically increasing token. - If a later command like /stop or /new invalidates that token while the - old worker is still unwinding, the late result can be recognized and - dropped instead of bleeding into the fresh session. - """ - if not session_key: - return 0 - generations = self.__dict__.get("_session_run_generation") - if generations is None: - generations = {} - self._session_run_generation = generations - next_generation = int(generations.get(session_key, 0)) + 1 - generations[session_key] = next_generation - return next_generation - - def _invalidate_session_run_generation(self, session_key: str, *, reason: str = "") -> int: - """Invalidate any in-flight run token for ``session_key``.""" - generation = self._begin_session_run_generation(session_key) - if reason: - logger.info( - "Invalidated run generation for %s → %d (%s)", - session_key, - generation, - reason, - ) - return generation - - def _is_session_run_current(self, session_key: str, generation: int) -> bool: - """Return True when ``generation`` is still current for ``session_key``.""" - if not session_key: - return True - generations = self.__dict__.get("_session_run_generation") or {} - return int(generations.get(session_key, 0)) == int(generation) - - def _bind_adapter_run_generation( - self, - adapter: Any, - session_key: str, - generation: int | None, - ) -> None: - """Bind a gateway run generation to the adapter's active-session event.""" - if not adapter or not session_key or generation is None: - return - try: - interrupt_event = getattr(adapter, "_active_sessions", {}).get(session_key) - if interrupt_event is not None: - setattr(interrupt_event, "_hermes_run_generation", int(generation)) - except Exception: - pass - - async def _interrupt_and_clear_session( - self, - session_key: str, - source: SessionSource, - *, - interrupt_reason: str, - invalidation_reason: str, - release_running_state: bool = True, - ) -> None: - """Interrupt the current run and clear queued session state consistently.""" - if not session_key: - return - running_agent = self._running_agents.get(session_key) - if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: - running_agent.interrupt(interrupt_reason) - self._invalidate_session_run_generation(session_key, reason=invalidation_reason) - adapter = self.adapters.get(source.platform) - if adapter and hasattr(adapter, "interrupt_session_activity"): - await adapter.interrupt_session_activity(session_key, source.chat_id) - if adapter and hasattr(adapter, "get_pending_message"): - adapter.get_pending_message(session_key) # consume and discard - self._pending_messages.pop(session_key, None) - if release_running_state: - self._release_running_agent_state(session_key) - def _evict_cached_agent(self, session_key: str) -> None: """Remove a cached agent for a session (called on /new, /model, etc).""" _lock = getattr(self, "_agent_cache_lock", None) @@ -9514,172 +7722,6 @@ def _evict_cached_agent(self, session_key: str) -> None: with _lock: self._agent_cache.pop(session_key, None) - @staticmethod - def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None: - """Reset per-turn state on a cached agent before a new turn starts. - - Both _last_activity_ts and _last_activity_desc are only reset for - fresh external turns (depth 0); they are semantically paired — - desc describes the activity *at* ts, so updating one without the - other would make get_activity_summary() misleading. - For interrupt-recursive turns both are preserved so the inactivity - watchdog can accumulate stuck-turn idle time and fire the 30-min - timeout (#15654). The depth-0 reset is still needed: a session - idle for 29 min would otherwise trip the watchdog before the new - turn makes its first API call (#9051). - """ - if interrupt_depth == 0: - agent._last_activity_ts = time.time() - agent._last_activity_desc = "starting new turn (cached)" - agent._api_call_count = 0 - - def _release_evicted_agent_soft(self, agent: Any) -> None: - """Soft cleanup for cache-evicted agents — preserves session tool state. - - Called from _enforce_agent_cache_cap and _sweep_idle_cached_agents. - Distinct from _cleanup_agent_resources (full teardown) because a - cache-evicted session may resume at any time — its terminal - sandbox, browser daemon, and tracked bg processes must outlive - the Python AIAgent instance so the next agent built for the - same task_id inherits them. - """ - if agent is None: - return - try: - if hasattr(agent, "release_clients"): - agent.release_clients() - else: - # Older agent instance (shouldn't happen in practice) — - # fall back to the legacy full-close path. - self._cleanup_agent_resources(agent) - except Exception: - pass - - def _enforce_agent_cache_cap(self) -> None: - """Evict oldest cached agents when cache exceeds _AGENT_CACHE_MAX_SIZE. - - Must be called with _agent_cache_lock held. Resource cleanup - (memory provider shutdown, tool resource close) is scheduled - on a daemon thread so the caller doesn't block on slow teardown - while holding the cache lock. - - Agents currently in _running_agents are SKIPPED — their clients, - terminal sandboxes, background processes, and child subagents - are all in active use by the running turn. Evicting them would - tear down those resources mid-turn and crash the request. If - every candidate in the LRU order is active, we simply leave the - cache over the cap; it will be re-checked on the next insert. - """ - _cache = getattr(self, "_agent_cache", None) - if _cache is None: - return - # OrderedDict.popitem(last=False) pops oldest; plain dict lacks the - # arg so skip enforcement if a test fixture swapped the cache type. - if not hasattr(_cache, "move_to_end"): - return - - # Snapshot of agent instances that are actively mid-turn. Use id() - # so the lookup is O(1) and doesn't depend on AIAgent.__eq__ (which - # MagicMock overrides in tests). - running_ids = { - id(a) - for a in getattr(self, "_running_agents", {}).values() - if a is not None and a is not _AGENT_PENDING_SENTINEL - } - - # Walk LRU → MRU and evict excess-LRU entries that aren't mid-turn. - # We only consider entries in the first (size - cap) LRU positions - # as eviction candidates. If one of those slots is held by an - # active agent, we SKIP it without compensating by evicting a - # newer entry — that would penalise a freshly-inserted session - # (which has no cache history to retain) while protecting an - # already-cached long-running one. The cache may therefore stay - # temporarily over cap; it will re-check on the next insert, - # after active turns have finished. - excess = max(0, len(_cache) - _AGENT_CACHE_MAX_SIZE) - evict_plan: List[tuple] = [] # [(key, agent), ...] - if excess > 0: - ordered_keys = list(_cache.keys()) - for key in ordered_keys[:excess]: - entry = _cache.get(key) - agent = entry[0] if isinstance(entry, tuple) and entry else None - if agent is not None and id(agent) in running_ids: - continue # active mid-turn; don't evict, don't substitute - evict_plan.append((key, agent)) - - for key, _ in evict_plan: - _cache.pop(key, None) - - remaining_over_cap = len(_cache) - _AGENT_CACHE_MAX_SIZE - if remaining_over_cap > 0: - logger.warning( - "Agent cache over cap (%d > %d); %d excess slot(s) held by " - "mid-turn agents — will re-check on next insert.", - len(_cache), _AGENT_CACHE_MAX_SIZE, remaining_over_cap, - ) - - for key, agent in evict_plan: - logger.info( - "Agent cache at cap; evicting LRU session=%s (cache_size=%d)", - key, len(_cache), - ) - if agent is not None: - threading.Thread( - target=self._release_evicted_agent_soft, - args=(agent,), - daemon=True, - name=f"agent-cache-evict-{key[:24]}", - ).start() - - def _sweep_idle_cached_agents(self) -> int: - """Evict cached agents whose AIAgent has been idle > _AGENT_CACHE_IDLE_TTL_SECS. - - Safe to call from the session expiry watcher without holding the - cache lock — acquires it internally. Returns the number of entries - evicted. Resource cleanup is scheduled on daemon threads. - - Agents currently in _running_agents are SKIPPED for the same reason - as _enforce_agent_cache_cap: tearing down an active turn's clients - mid-flight would crash the request. - """ - _cache = getattr(self, "_agent_cache", None) - _lock = getattr(self, "_agent_cache_lock", None) - if _cache is None or _lock is None: - return 0 - now = time.time() - to_evict: List[tuple] = [] - running_ids = { - id(a) - for a in getattr(self, "_running_agents", {}).values() - if a is not None and a is not _AGENT_PENDING_SENTINEL - } - with _lock: - for key, entry in list(_cache.items()): - agent = entry[0] if isinstance(entry, tuple) and entry else None - if agent is None: - continue - if id(agent) in running_ids: - continue # mid-turn — don't tear it down - last_activity = getattr(agent, "_last_activity_ts", None) - if last_activity is None: - continue - if (now - last_activity) > _AGENT_CACHE_IDLE_TTL_SECS: - to_evict.append((key, agent)) - for key, _ in to_evict: - _cache.pop(key, None) - for key, agent in to_evict: - logger.info( - "Agent cache idle-TTL evict: session=%s (idle=%.0fs)", - key, now - getattr(agent, "_last_activity_ts", now), - ) - threading.Thread( - target=self._release_evicted_agent_soft, - args=(agent,), - daemon=True, - name=f"agent-cache-idle-{key[:24]}", - ).start() - return len(to_evict) - # ------------------------------------------------------------------ # Proxy mode: forward messages to a remote Hermes API server # ------------------------------------------------------------------ @@ -9707,7 +7749,6 @@ async def _run_agent_via_proxy( source: "SessionSource", session_id: str, session_key: str = None, - run_generation: Optional[int] = None, event_message_id: Optional[str] = None, ) -> Dict[str, Any]: """Forward the message to a remote Hermes API server instead of @@ -9743,11 +7784,6 @@ async def _run_agent_via_proxy( proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip() - def _run_still_current() -> bool: - if run_generation is None or not session_key: - return True - return self._is_session_run_current(session_key, run_generation) - # Build messages in OpenAI chat format -------------------------- # # The remote api_server can maintain session continuity via @@ -9812,29 +7848,17 @@ def _run_still_current() -> bool: if _streaming_enabled: try: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig + from gateway.config import Platform _adapter = self.adapters.get(source.platform) if _adapter: _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" - _buffer_only = False if source.platform == Platform.MATRIX: _effective_cursor = "" - _buffer_only = True - # Fresh-final applies to Telegram only — other - # platforms either edit in place cheaply (Discord, - # Slack) or don't have the timestamp-on-edit - # problem. (Ported from openclaw/openclaw#72038.) - _fresh_final_secs = ( - float(getattr(_scfg, "fresh_final_after_seconds", 0.0) or 0.0) - if source.platform == Platform.TELEGRAM - else 0.0 - ) _consumer_cfg = StreamConsumerConfig( edit_interval=_scfg.edit_interval, buffer_threshold=_scfg.buffer_threshold, cursor=_effective_cursor, - buffer_only=_buffer_only, - fresh_final_after_seconds=_fresh_final_secs, ) _stream_consumer = GatewayStreamConsumer( adapter=_adapter, @@ -9886,21 +7910,6 @@ def _run_still_current() -> bool: # Parse SSE stream buffer = "" async for chunk in resp.content.iter_any(): - if not _run_still_current(): - logger.info( - "Discarding stale proxy stream for %s — generation %d is no longer current", - session_key or "?", - run_generation or 0, - ) - return { - "final_response": "", - "messages": [], - "api_calls": 0, - "tools": [], - "history_offset": len(history), - "session_id": session_id, - "response_previewed": False, - } text = chunk.decode("utf-8", errors="replace") buffer += text @@ -9950,21 +7959,6 @@ def _run_still_current() -> bool: stream_task.cancel() _elapsed = time.time() - _start - if not _run_still_current(): - logger.info( - "Discarding stale proxy result for %s — generation %d is no longer current", - session_key or "?", - run_generation or 0, - ) - return { - "final_response": "", - "messages": [], - "api_calls": 0, - "tools": [], - "history_offset": len(history), - "session_id": session_id, - "response_previewed": False, - } logger.info( "proxy response: url=%s session=%s time=%.1fs response=%d chars", proxy_url, (session_id or "")[:20], _elapsed, len(full_response), @@ -9993,10 +7987,8 @@ async def _run_agent( source: SessionSource, session_id: str, session_key: str = None, - run_generation: Optional[int] = None, _interrupt_depth: int = 0, event_message_id: Optional[str] = None, - channel_prompt: Optional[str] = None, ) -> Dict[str, Any]: """ Run the agent with the given message and context. @@ -10019,17 +8011,11 @@ async def _run_agent( source=source, session_id=session_id, session_key=session_key, - run_generation=run_generation, event_message_id=event_message_id, ) from run_agent import AIAgent import queue - - def _run_still_current() -> bool: - if run_generation is None or not session_key: - return True - return self._is_session_run_current(session_key, run_generation) user_config = _load_gateway_config() platform_key = _platform_config_key(source.platform) @@ -10081,62 +8067,16 @@ def _run_still_current() -> bool: 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 - # First-touch onboarding latch: fires at most once per run, even if - # several tools exceed the threshold. - long_tool_hint_fired = [False] - _LONG_TOOL_THRESHOLD_S = 30.0 - + def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): """Callback invoked by agent on tool lifecycle events.""" - if not progress_queue or not _run_still_current(): - return - - # First-touch onboarding: the first time a tool takes longer than - # _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool - # (progress_mode == "all"), append a one-time hint suggesting - # /verbose. We only fire when (a) the user hasn't seen the hint - # before and (b) /verbose is actually usable on this platform - # (gateway gate must be open). The CLI has its own trigger. - if event_type == "tool.completed" and not long_tool_hint_fired[0]: - try: - duration = kwargs.get("duration") or 0 - if duration >= _LONG_TOOL_THRESHOLD_S and progress_mode == "all": - from agent.onboarding import ( - TOOL_PROGRESS_FLAG, - is_seen, - mark_seen, - tool_progress_hint_gateway, - ) - _cfg = _load_gateway_config() - gate_on = bool(cfg_get(_cfg, "display", "tool_progress_command", default=False)) - if gate_on and not is_seen(_cfg, TOOL_PROGRESS_FLAG): - long_tool_hint_fired[0] = True - progress_queue.put(tool_progress_hint_gateway()) - mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG) - except Exception as _hint_err: - logger.debug("tool-progress onboarding hint failed: %s", _hint_err) + if not progress_queue: return # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) if event_type not in ("tool.started",): return - # Suppress tool-progress bubbles once the user has sent `stop`. - # When the LLM response carries N parallel tool calls, the agent - # fires N "tool.started" events back-to-back before checking for - # interrupts — without this guard, a late `stop` still renders - # all N as 🔍 bubbles, making the interrupt feel ignored. - # (agent lives in run_sync's scope; agent_holder[0] is the shared - # handle across nested scopes — see line ~9607.) - try: - _agent_for_interrupt = agent_holder[0] if agent_holder else None - if _agent_for_interrupt is not None and getattr( - _agent_for_interrupt, "is_interrupted", False - ): - return - except Exception: - pass - # "new" mode: only report when tool changes if progress_mode == "new" and tool_name == last_tool[0]: return @@ -10151,7 +8091,8 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non if args: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() - args_str = json.dumps(args, ensure_ascii=False, default=str) + import json as _json + args_str = _json.dumps(args, ensure_ascii=False, default=str) # When tool_preview_length is 0 (default), don't truncate # in verbose mode — the user explicitly asked for full # detail. Platform message-length limits handle the rest. @@ -10217,7 +8158,8 @@ async def send_progress_messages(): # Skip tool progress for platforms that don't support message # editing (e.g. iMessage/BlueBubbles) — each progress update # would become a separate message bubble, which is noisy. - if type(adapter).edit_message is BasePlatformAdapter.edit_message: + from gateway.platforms.base import BasePlatformAdapter as _BaseAdapter + if type(adapter).edit_message is _BaseAdapter.edit_message: while not progress_queue.empty(): try: progress_queue.get_nowait() @@ -10233,52 +8175,14 @@ async def send_progress_messages(): while True: try: - if not _run_still_current(): - while not progress_queue.empty(): - try: - progress_queue.get_nowait() - except Exception: - break - return - raw = progress_queue.get_nowait() - # Drain silently when interrupted: events queued in the - # window between tool parse and interrupt processing - # should not render as bubbles. The "⚡ Interrupting - # current task" message is sent separately and is the - # last progress-flavored bubble the user should see. - try: - _agent_for_interrupt = agent_holder[0] if agent_holder else None - if _agent_for_interrupt is not None and getattr( - _agent_for_interrupt, "is_interrupted", False - ): - # Drop this event and continue draining. - await asyncio.sleep(0) - continue - except Exception: - pass - # Handle dedup messages: update last line with repeat counter if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": _, base_msg, count = raw if progress_lines: progress_lines[-1] = f"{base_msg} (×{count + 1})" msg = progress_lines[-1] if progress_lines else base_msg - elif isinstance(raw, tuple) and len(raw) >= 1 and raw[0] == "__reset__": - # Content bubble just landed on the platform — close off - # the current tool-progress bubble so the next tool - # starts a fresh bubble below the content. Without this, - # tool lines keep editing the ORIGINAL progress message - # above the new content, making the chat appear out of - # order. Mirrors GatewayStreamConsumer.on_segment_break - # on the content side. (Issue: tool + content - # linearization regression after PR #7885.) - progress_msg_id = None - progress_lines = [] - last_progress_msg[0] = None - repeat_count[0] = 0 - continue else: msg = raw progress_lines.append(msg) @@ -10296,9 +8200,6 @@ async def send_progress_messages(): await asyncio.sleep(_remaining) continue - if not _run_still_current(): - return - if can_edit and progress_msg_id is not None: # Try to edit the existing progress message full_text = "\n".join(progress_lines) @@ -10334,8 +8235,7 @@ async def send_progress_messages(): # Restore typing indicator await asyncio.sleep(0.3) - if _run_still_current(): - await adapter.send_typing(source.chat_id, metadata=_progress_metadata) + await adapter.send_typing(source.chat_id, metadata=_progress_metadata) except queue.Empty: await asyncio.sleep(0.3) @@ -10348,24 +8248,6 @@ async def send_progress_messages(): _, base_msg, count = raw if progress_lines: progress_lines[-1] = f"{base_msg} (×{count + 1})" - elif isinstance(raw, tuple) and len(raw) >= 1 and raw[0] == "__reset__": - # Content-bubble marker during drain: close off - # the current progress bubble and start a fresh - # one for any tool lines that arrived after. - if can_edit and progress_lines and progress_msg_id: - _pending_text = "\n".join(progress_lines) - try: - await adapter.edit_message( - chat_id=source.chat_id, - message_id=progress_msg_id, - content=_pending_text, - ) - except Exception: - pass - progress_msg_id = None - progress_lines = [] - last_progress_msg[0] = None - repeat_count[0] = 0 else: progress_lines.append(raw) except Exception: @@ -10393,12 +8275,10 @@ async def send_progress_messages(): stream_consumer_holder = [None] # Mutable container for stream consumer # Bridge sync step_callback → async hooks.emit for agent:step events - _loop_for_step = asyncio.get_running_loop() + _loop_for_step = asyncio.get_event_loop() _hooks_ref = self.hooks def _step_callback_sync(iteration: int, prev_tools: list) -> None: - if not _run_still_current(): - return try: # prev_tools may be list[str] or list[dict] with "name"/"result" # keys. Normalise to keep "tool_names" backward-compatible for @@ -10429,7 +8309,7 @@ def _step_callback_sync(iteration: int, prev_tools: list) -> None: _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None def _status_callback_sync(event_type: str, message: str) -> None: - if not _status_adapter or not _run_still_current(): + if not _status_adapter: return try: asyncio.run_coroutine_threadsafe( @@ -10463,12 +8343,8 @@ def run_sync(): # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value - # Combine platform context, per-channel context, and the user-configured - # ephemeral system prompt. + # Combine platform context with user-configured ephemeral system prompt combined_ephemeral = context_prompt or "" - event_channel_prompt = (channel_prompt or "").strip() - if event_channel_prompt: - combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() if self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() @@ -10489,7 +8365,7 @@ def run_sync(): ) logger.debug( "run_agent resolved: model=%s provider=%s session=%s", - model, runtime_kwargs.get("provider"), session_key or "", + model, runtime_kwargs.get("provider"), (session_key or "")[:30], ) except Exception as exc: return { @@ -10500,10 +8376,7 @@ def run_sync(): } pr = self._provider_routing - reasoning_config = self._resolve_session_reasoning_config( - source=source, - session_key=session_key, - ) + reasoning_config = self._load_reasoning_config() self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() # Set up stream consumer for token streaming or interim commentary. @@ -10546,48 +8419,26 @@ def run_sync(): # Some Matrix clients render the streaming cursor # as a visible tofu/white-box artifact. Keep # streaming text on Matrix, but suppress the cursor. - _buffer_only = False if source.platform == Platform.MATRIX: _effective_cursor = "" - _buffer_only = True - # Fresh-final applies to Telegram only — other - # platforms either edit in place cheaply or don't - # have the edit-timestamp-stays-stale problem. - # (Ported from openclaw/openclaw#72038.) - _fresh_final_secs = ( - float(getattr(_scfg, "fresh_final_after_seconds", 0.0) or 0.0) - if source.platform == Platform.TELEGRAM - else 0.0 - ) _consumer_cfg = StreamConsumerConfig( edit_interval=_scfg.edit_interval, buffer_threshold=_scfg.buffer_threshold, cursor=_effective_cursor, - buffer_only=_buffer_only, - fresh_final_after_seconds=_fresh_final_secs, ) _stream_consumer = GatewayStreamConsumer( adapter=_adapter, chat_id=source.chat_id, config=_consumer_cfg, metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None, - on_new_message=( - (lambda: progress_queue.put(("__reset__",))) - if progress_queue is not None - else None - ), ) if _want_stream_deltas: - def _stream_delta_cb(text: str) -> None: - if _run_still_current(): - _stream_consumer.on_delta(text) + _stream_delta_cb = _stream_consumer.on_delta stream_consumer_holder[0] = _stream_consumer except Exception as _sc_err: logger.debug("Could not set up stream consumer: %s", _sc_err) def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: - if not _run_still_current(): - return if _stream_consumer is not None: if already_streamed: _stream_consumer.on_segment_break() @@ -10618,7 +8469,6 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: turn_route["runtime"], enabled_toolsets, combined_ephemeral, - cache_keys=self._extract_cache_busting_config(user_config), ) agent = None _cache_lock = getattr(self, "_agent_cache_lock", None) @@ -10628,14 +8478,12 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: cached = _cache.get(session_key) if cached and cached[1] == _sig: agent = cached[0] - # Refresh LRU order so the cap enforcement evicts - # truly-oldest entries, not the one we just used. - if hasattr(_cache, "move_to_end"): - try: - _cache.move_to_end(session_key) - except KeyError: - pass - self._init_cached_agent_for_turn(agent, _interrupt_depth) + # Reset activity timestamp so the inactivity timeout + # handler doesn't see stale idle time from the previous + # turn and immediately kill this agent. (#9051) + agent._last_activity_ts = time.time() + agent._last_activity_desc = "starting new turn (cached)" + agent._api_call_count = 0 logger.debug("Reusing cached agent for session %s", session_key) if agent is None: @@ -10661,19 +8509,12 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: session_id=session_id, platform=platform_key, user_id=source.user_id, - user_name=source.user_name, - chat_id=source.chat_id, - chat_name=source.chat_name, - chat_type=source.chat_type, - thread_id=source.thread_id, - gateway_session_key=session_key, session_db=self._session_db, fallback_model=self._fallback_model, ) if _cache_lock and _cache is not None: with _cache_lock: _cache[session_key] = (agent, _sig) - self._enforce_agent_cache_cap() logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig) # Per-message state — callbacks and reasoning config change every @@ -10685,14 +8526,11 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: agent.status_callback = _status_callback_sync agent.reasoning_config = reasoning_config agent.service_tier = self._service_tier - agent.request_overrides = turn_route.get("request_overrides") or {} - - _bg_review_release = threading.Event() - _bg_review_pending: list[str] = [] - _bg_review_pending_lock = threading.Lock() + agent.request_overrides = turn_route.get("request_overrides") - def _deliver_bg_review_message(message: str) -> None: - if not _status_adapter or not _run_still_current(): + # Background review delivery — send "💾 Memory updated" etc. to user + def _bg_review_send(message: str) -> None: + if not _status_adapter: return try: asyncio.run_coroutine_threadsafe( @@ -10706,39 +8544,7 @@ def _deliver_bg_review_message(message: str) -> None: except Exception as _e: logger.debug("background_review_callback error: %s", _e) - def _release_bg_review_messages() -> None: - _bg_review_release.set() - with _bg_review_pending_lock: - pending = list(_bg_review_pending) - _bg_review_pending.clear() - for queued in pending: - _deliver_bg_review_message(queued) - - # Background review delivery — send "💾 Memory updated" etc. to user - def _bg_review_send(message: str) -> None: - if not _status_adapter or not _run_still_current(): - return - if not _bg_review_release.is_set(): - with _bg_review_pending_lock: - if not _bg_review_release.is_set(): - _bg_review_pending.append(message) - return - _deliver_bg_review_message(message) - agent.background_review_callback = _bg_review_send - # Register the release hook on the adapter so base.py's finally - # block can fire it after delivering the main response. - if _status_adapter and session_key: - if getattr(type(_status_adapter), "register_post_delivery_callback", None) is not None: - _status_adapter.register_post_delivery_callback( - session_key, - _release_bg_review_messages, - generation=run_generation, - ) - else: - _pdc = getattr(_status_adapter, "_post_delivery_callbacks", None) - if _pdc is not None: - _pdc[session_key] = _release_bg_review_messages # Store agent reference for interrupt support agent_holder[0] = agent @@ -10847,7 +8653,7 @@ def _approval_notify_sync(approval_data: dict) -> None: # false positives from MagicMock auto-attribute creation in tests. if getattr(type(_status_adapter), "send_exec_approval", None) is not None: try: - _approval_result = asyncio.run_coroutine_threadsafe( + asyncio.run_coroutine_threadsafe( _status_adapter.send_exec_approval( chat_id=_status_chat_id, command=cmd, @@ -10857,12 +8663,7 @@ def _approval_notify_sync(approval_data: dict) -> None: ), _loop_for_step, ).result(timeout=15) - if _approval_result.success: - return - logger.warning( - "Button-based approval failed (send returned error), falling back to text: %s", - _approval_result.error, - ) + return except Exception as _e: logger.warning( "Button-based approval failed, falling back to text: %s", _e @@ -10900,64 +8701,7 @@ def _approval_notify_sync(approval_data: dict) -> None: # restart, crash, SIGTERM). Prepend a system note so the model # finishes processing the pending tool results before addressing # the user's new message. (#4493) - # - # Session-level resume_pending (set on drain-timeout shutdown) - # escalates the wording — the transcript's last role may be - # anything (tool, assistant with unfinished work, etc.), so we - # give a stronger, reason-aware instruction that subsumes the - # tool-tail case. - # - # Freshness gate (#16802): both branches are gated on the age - # of the last persisted transcript row. That is the correct - # "when did we last do anything here" signal for both the - # resume_pending path (restart watchdog) and the tool-tail - # path (in-flight tool loop killed). We read ``history[-1]`` - # here because ``agent_history`` has already stripped the - # ``timestamp`` field off tool/tool_call rows for API purity - # (see the `k != "timestamp"` filter above). Rows without a - # timestamp (legacy transcripts) are treated as fresh so the - # historical auto-continue behaviour is preserved. - _freshness_window = _auto_continue_freshness_window() - _interruption_is_fresh = _is_fresh_gateway_interruption( - _last_transcript_timestamp(history), - window_secs=_freshness_window, - ) - - _resume_entry = None - if session_key: - try: - _resume_entry = self.session_store._entries.get(session_key) - except Exception: - _resume_entry = None - _is_resume_pending = bool( - _resume_entry is not None - and getattr(_resume_entry, "resume_pending", False) - and _interruption_is_fresh - ) - _has_fresh_tool_tail = bool( - agent_history - and agent_history[-1].get("role") == "tool" - and _interruption_is_fresh - ) - - if _is_resume_pending: - _reason = getattr(_resume_entry, "resume_reason", None) or "restart_timeout" - _reason_phrase = ( - "a gateway restart" - if _reason == "restart_timeout" - else "a gateway shutdown" - if _reason == "shutdown_timeout" - else "a gateway interruption" - ) - message = ( - f"[System note: Your previous turn in this session was interrupted " - f"by {_reason_phrase}. The conversation history below is intact. " - f"If it contains unfinished tool result(s), process them first and " - f"summarize what was accomplished, then address the user's new " - f"message below.]\n\n" - + message - ) - elif _has_fresh_tool_tail: + if agent_history and agent_history[-1].get("role") == "tool": message = ( "[System note: Your previous turn was interrupted before you could " "process the last tool result(s). The conversation history contains " @@ -10971,39 +8715,7 @@ def _approval_notify_sync(approval_data: dict) -> None: _approval_session_token = set_current_session_key(_approval_session_key) register_gateway_notify(_approval_session_key, _approval_notify_sync) try: - # If _prepare_inbound_message_text buffered image paths for native - # attachment, wrap the user turn as an OpenAI-style multimodal - # content list. Consume-and-clear so subsequent turns on the same - # runner instance don't re-attach stale images. - _native_imgs = list(getattr(self, "_pending_native_image_paths", []) or []) - self._pending_native_image_paths = [] - if _native_imgs: - try: - from agent.image_routing import build_native_content_parts - _parts, _skipped = build_native_content_parts( - message, - _native_imgs, - ) - if _skipped: - logger.warning( - "Native image attachment: skipped %d unreadable path(s): %s", - len(_skipped), _skipped, - ) - if any(p.get("type") == "image_url" for p in _parts): - _run_message: Any = _parts - else: - # All images failed to read — fall back to plain text. - _run_message = message - except Exception as _img_exc: - logger.warning( - "Native image attachment failed, falling back to text: %s", - _img_exc, - ) - _run_message = message - else: - _run_message = message - - result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id) + result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id) finally: unregister_gateway_notify(_approval_session_key) reset_current_session_key(_approval_session_token) @@ -11020,17 +8732,15 @@ def _approval_notify_sync(approval_data: dict) -> None: _last_prompt_toks = 0 _input_toks = 0 _output_toks = 0 - _context_length = 0 _agent = agent_holder[0] if _agent and hasattr(_agent, "context_compressor"): _last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0) _input_toks = getattr(_agent, "session_prompt_tokens", 0) _output_toks = getattr(_agent, "session_completion_tokens", 0) - _context_length = getattr(_agent.context_compressor, "context_length", 0) or 0 _resolved_model = getattr(_agent, "model", None) if _agent else None if not final_response: - error_msg = f"⚠️ {result['error']}" if result.get("error") else "" + error_msg = f"⚠️ {result['error']}" if result.get("error") else "(No response generated)" return { "final_response": error_msg, "messages": result.get("messages", []), @@ -11043,7 +8753,6 @@ def _approval_notify_sync(approval_data: dict) -> None: "input_tokens": _input_toks, "output_tokens": _output_toks, "model": _resolved_model, - "context_length": _context_length, } # Scan tool results for MEDIA: tags that need to be delivered @@ -11112,27 +8821,12 @@ def _approval_notify_sync(approval_data: dict) -> None: try: from agent.title_generator import maybe_auto_title all_msgs = result_holder[0].get("messages", []) if result_holder[0] else [] - # Route title-generation failures through the agent's - # user-visible warning channel so a depleted auxiliary - # provider doesn't silently leave sessions untitled - # (issue #15775). - _title_failure_cb = getattr( - agent, "_emit_auxiliary_failure", None - ) maybe_auto_title( self._session_db, effective_session_id, message, final_response, all_msgs, - failure_callback=_title_failure_cb, - main_runtime={ - "model": getattr(agent, "model", None), - "provider": getattr(agent, "provider", None), - "base_url": getattr(agent, "base_url", None), - "api_key": getattr(agent, "api_key", None), - "api_mode": getattr(agent, "api_mode", None), - } if agent else None, ) except Exception: pass @@ -11148,7 +8842,6 @@ def _approval_notify_sync(approval_data: dict) -> None: "input_tokens": _input_toks, "output_tokens": _output_toks, "model": _resolved_model, - "context_length": _context_length, "session_id": effective_session_id, "response_previewed": result.get("response_previewed", False), } @@ -11178,24 +8871,10 @@ async def track_agent(): # Wait for agent to be created while agent_holder[0] is None: await asyncio.sleep(0.05) - if not session_key: - return - # Only promote the sentinel to the real agent if this run is still - # current. If /stop or /new bumped the generation while we were - # spinning up, leave the newer run's slot alone — we'll be - # discarded by the stale-result check in _handle_message_with_agent. - if run_generation is not None and not self._is_session_run_current( - session_key, run_generation - ): - logger.info( - "Skipping stale agent promotion for %s — generation %s is no longer current", - session_key or "", - run_generation, - ) - return - self._running_agents[session_key] = agent_holder[0] - if self._draining: - self._update_runtime_status("draining") + if session_key: + self._running_agents[session_key] = agent_holder[0] + if self._draining: + self._update_runtime_status("draining") tracking_task = asyncio.create_task(track_agent()) @@ -11250,9 +8929,9 @@ async def monitor_for_interrupt(): # Periodic "still working" notifications for long-running tasks. # Fires every N seconds so the user knows the agent hasn't died. # Config: agent.gateway_notify_interval in config.yaml, or - # HERMES_AGENT_NOTIFY_INTERVAL env var. Default 180s (3 min). + # HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min). # 0 = disable notifications. - _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 180)) + _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600)) _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None _notify_start = time.time() @@ -11305,8 +8984,9 @@ async def _notify_long_running(): _agent_warning_raw = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900)) _agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None _warning_fired = False + loop = asyncio.get_event_loop() _executor_task = asyncio.ensure_future( - self._run_in_executor_with_context(run_sync) + loop.run_in_executor(None, run_sync) ) _inactivity_timeout = False @@ -11336,7 +9016,7 @@ async def _notify_long_running(): logger.info( "Backup interrupt detected for session %s " "(monitor task state: %s)", - session_key, + session_key[:20], "done" if interrupt_monitor.done() else "running", ) _backup_agent.interrupt(_bp_text) @@ -11396,7 +9076,7 @@ async def _notify_long_running(): logger.info( "Backup interrupt detected for session %s " "(monitor task state: %s)", - session_key, + session_key[:20], "done" if interrupt_monitor.done() else "running", ) _backup_agent.interrupt(_bp_text) @@ -11429,7 +9109,7 @@ async def _notify_long_running(): # Interrupt the agent if it's still running so the thread # pool worker is freed. if _timed_out_agent and hasattr(_timed_out_agent, "interrupt"): - _timed_out_agent.interrupt(_INTERRUPT_REASON_TIMEOUT) + _timed_out_agent.interrupt("Execution timed out (inactivity)") _timeout_mins = int(_agent_timeout // 60) or 1 @@ -11493,37 +9173,12 @@ async def _notify_long_running(): pending = None if result and adapter and session_key: pending_event = _dequeue_pending_event(adapter, session_key) - # /queue overflow: after consuming the adapter's "next-up" - # slot, promote the next queued event into it so the - # recursive run's drain will see it. This keeps the slot - # occupied for the full FIFO chain, which (a) preserves - # order, and (b) causes any mid-chain /queue to correctly - # route to overflow rather than jumping the queue. - pending_event = self._promote_queued_event(session_key, adapter, pending_event) if result.get("interrupted") and not pending_event and result.get("interrupt_message"): - interrupt_message = result.get("interrupt_message") - if _is_control_interrupt_message(interrupt_message): - logger.info( - "Ignoring control interrupt message for session %s: %s", - session_key or "?", - interrupt_message, - ) - else: - pending = interrupt_message + pending = result.get("interrupt_message") elif pending_event: pending = pending_event.text or _build_media_placeholder(pending_event) logger.debug("Processing queued message after agent completion: '%s...'", pending[:40]) - # Leftover /steer: if a steer arrived after the last tool batch - # (e.g. during the final API call), the agent couldn't inject it - # and returned it in result["pending_steer"]. Deliver it as the - # next user turn so it isn't silently dropped. - if result and not pending and not pending_event: - _leftover_steer = result.get("pending_steer") - if _leftover_steer: - pending = _leftover_steer - logger.debug("Delivering leftover /steer as next turn: '%s...'", pending[:40]) - # Safety net: if the pending text is a slash command (e.g. "/stop", # "/new"), discard it — commands should never be passed to the agent # as user input. The primary fix is in base.py (commands bypass the @@ -11549,7 +9204,7 @@ async def _notify_long_running(): if self._draining and (pending_event or pending): logger.info( "Discarding pending follow-up for session %s during gateway %s", - session_key or "?", + session_key[:20] if session_key else "?", self._status_action_label(), ) pending_event = None @@ -11596,18 +9251,16 @@ async def _notify_long_running(): pass except Exception as e: logger.debug("Stream consumer wait before queued message failed: %s", e) - _previewed = bool(result.get("response_previewed")) _already_streamed = bool( - (_sc and getattr(_sc, "final_response_sent", False)) - or _previewed + _sc + and ( + getattr(_sc, "final_response_sent", False) + or getattr(_sc, "already_sent", False) + ) ) first_response = result.get("final_response", "") if first_response and not _already_streamed: try: - logger.info( - "Queued follow-up for session %s: final stream delivery not confirmed; sending first response before continuing.", - session_key or "?", - ) await adapter.send( source.chat_id, first_response, @@ -11615,32 +9268,6 @@ async def _notify_long_running(): ) except Exception as e: logger.warning("Failed to send first response before queued message: %s", e) - elif first_response: - logger.info( - "Queued follow-up for session %s: skipping resend because final streamed delivery was confirmed.", - session_key or "?", - ) - # Release deferred bg-review notifications now that the - # first response has been delivered. Pop from the - # adapter's callback dict (prevents double-fire in - # base.py's finally block) and call it. - if getattr(type(adapter), "pop_post_delivery_callback", None) is not None: - _bg_cb = adapter.pop_post_delivery_callback( - session_key, - generation=run_generation, - ) - if callable(_bg_cb): - try: - _bg_cb() - except Exception: - pass - elif adapter and hasattr(adapter, "_post_delivery_callbacks"): - _bg_cb = adapter._post_delivery_callbacks.pop(session_key, None) - if callable(_bg_cb): - try: - _bg_cb() - except Exception: - pass # else: interrupted — discard the interrupted response ("Operation # interrupted." is just noise; the user already knows they sent a # new message). @@ -11649,7 +9276,6 @@ async def _notify_long_running(): next_source = source next_message = pending next_message_id = None - next_channel_prompt = None if pending_event is not None: next_source = getattr(pending_event, "source", None) or source next_message = await self._prepare_inbound_message_text( @@ -11660,20 +9286,6 @@ async def _notify_long_running(): if next_message is None: return result next_message_id = getattr(pending_event, "message_id", None) - next_channel_prompt = getattr(pending_event, "channel_prompt", None) - - # Restart typing indicator so the user sees activity while - # the follow-up turn runs. The outer _process_message_background - # typing task is still alive but may be stale. - _followup_adapter = self.adapters.get(source.platform) - if _followup_adapter: - try: - await _followup_adapter.send_typing( - source.chat_id, - metadata=_status_thread_metadata, - ) - except Exception: - pass return await self._run_agent( message=next_message, @@ -11682,10 +9294,8 @@ async def _notify_long_running(): source=next_source, session_id=session_id, session_key=session_key, - run_generation=run_generation, _interrupt_depth=_interrupt_depth + 1, event_message_id=next_message_id, - channel_prompt=next_channel_prompt, ) finally: # Stop progress sender, interrupt monitor, and notification task @@ -11707,15 +9317,10 @@ async def _notify_long_running(): # Clean up tracking tracking_task.cancel() + if session_key and session_key in self._running_agents: + del self._running_agents[session_key] if session_key: - # Only release the slot if this run's generation still owns - # it. A /stop or /new that bumped the generation while we - # were unwinding has already installed its own state; this - # guard prevents an old run from clobbering it on the way - # out. - self._release_running_agent_state( - session_key, run_generation=run_generation - ) + self._running_agents_ts.pop(session_key, None) if self._draining: self._update_runtime_status("draining") @@ -11732,31 +9337,12 @@ async def _notify_long_running(): # BUT: never suppress delivery when the agent failed — the error # message is new content the user hasn't seen, and it must reach # them even if streaming had sent earlier partial output. - # - # Also never suppress when the final response is "(empty)" — this - # means the model failed to produce content after tool calls (common - # with mimo-v2-pro, GLM-5, etc.). The stream consumer may have - # sent intermediate text ("Let me search for that…") alongside the - # tool call, setting already_sent=True, but that text is NOT the - # final answer. Suppressing delivery here leaves the user staring - # at silence. (#10xxx — "agent stops after web search") _sc = stream_consumer_holder[0] - if isinstance(response, dict) and not response.get("failed"): - _final = response.get("final_response") or "" - _is_empty_sentinel = not _final or _final == "(empty)" - _streamed = bool( - _sc and getattr(_sc, "final_response_sent", False) - ) - # response_previewed means the interim_assistant_callback already - # sent the final text via the adapter (non-streaming path). - _previewed = bool(response.get("response_previewed")) - if not _is_empty_sentinel and (_streamed or _previewed): - logger.info( - "Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s).", - session_key or "?", - _streamed, - _previewed, - ) + if _sc and isinstance(response, dict) and not response.get("failed"): + if ( + getattr(_sc, "final_response_sent", False) + or getattr(_sc, "already_sent", False) + ): response["already_sent"] = True return response @@ -11773,17 +9359,13 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in cron delivery path so live adapters can be used for E2EE rooms. Also refreshes the channel directory every 5 minutes and prunes the - image/audio/document cache + expired ``hermes debug share`` pastes - once per hour. + image/audio/document cache once per hour. """ from cron.scheduler import tick as cron_tick from gateway.platforms.base import cleanup_image_cache, cleanup_document_cache - from hermes_cli.debug import _sweep_expired_pastes IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes - PASTE_SWEEP_EVERY = 60 # ticks — once per hour - CURATOR_EVERY = 60 # ticks — poll hourly (inner gate handles the real cadence) logger.info("Cron ticker started (interval=%ds)", interval) tick_count = 0 @@ -11798,15 +9380,8 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in if tick_count % CHANNEL_DIR_EVERY == 0 and adapters: try: from gateway.channel_directory import build_channel_directory - if loop is not None: - # build_channel_directory is async (Slack web calls), and - # this ticker runs in a background thread. Schedule onto - # the gateway event loop and wait briefly for completion - # so refresh failures are still logged via the except. - fut = asyncio.run_coroutine_threadsafe( - build_channel_directory(adapters), loop - ) - fut.result(timeout=30) + future = asyncio.run_coroutine_threadsafe(build_channel_directory(adapters), loop) + future.result(timeout=10) except Exception as e: logger.debug("Channel directory refresh error: %s", e) @@ -11824,32 +9399,6 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in except Exception as e: logger.debug("Document cache cleanup error: %s", e) - if tick_count % PASTE_SWEEP_EVERY == 0: - try: - deleted, remaining = _sweep_expired_pastes() - if deleted: - logger.info( - "Paste sweep: deleted %d expired paste(s), %d pending", - deleted, remaining, - ) - except Exception as e: - logger.debug("Paste sweep error: %s", e) - - # Curator — piggy-back on the existing cron ticker so long-running - # gateways get weekly skill maintenance without needing restarts. - # maybe_run_curator() is internally gated by config.interval_hours - # (7 days by default), so CURATOR_EVERY is just the poll rate — the - # real work only fires once per config interval. - if tick_count % CURATOR_EVERY == 0: - try: - from agent.curator import maybe_run_curator - maybe_run_curator( - idle_for_seconds=float("inf"), - on_summary=lambda msg: logger.info("curator: %s", msg), - ) - except Exception as e: - logger.debug("Curator tick error: %s", e) - stop_event.wait(timeout=interval) logger.info("Cron ticker stopped") @@ -11873,32 +9422,15 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # The PID file is scoped to HERMES_HOME, so future multi-profile # setups (each profile using a distinct HERMES_HOME) will naturally # allow concurrent instances without tripping this guard. - from gateway.status import ( - acquire_gateway_runtime_lock, - get_running_pid, - get_process_start_time, - release_gateway_runtime_lock, - remove_pid_file, - terminate_pid, - ) + import time as _time + from gateway.status import get_running_pid, remove_pid_file, terminate_pid existing_pid = get_running_pid() if existing_pid is not None and existing_pid != os.getpid(): if replace: - existing_start_time = get_process_start_time(existing_pid) logger.info( "Replacing existing gateway instance (PID %d) with --replace.", existing_pid, ) - # Record a takeover marker so the target's shutdown handler - # recognises its SIGTERM as a planned takeover and exits 0 - # (rather than exit 1, which would trigger systemd's - # Restart=on-failure and start a flap loop against us). - # Best-effort — proceed even if the write fails. - try: - from gateway.status import write_takeover_marker - write_takeover_marker(existing_pid) - except Exception as e: - logger.debug("Could not write takeover marker: %s", e) try: terminate_pid(existing_pid, force=False) except ProcessLookupError: @@ -11908,19 +9440,12 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = "Permission denied killing PID %d. Cannot replace.", existing_pid, ) - # Marker is scoped to a specific target; clean it up on - # give-up so it doesn't grief an unrelated future shutdown. - try: - from gateway.status import clear_takeover_marker - clear_takeover_marker() - except Exception: - pass return False # Wait up to 10 seconds for the old process to exit for _ in range(20): try: os.kill(existing_pid, 0) - time.sleep(0.5) + _time.sleep(0.5) except (ProcessLookupError, PermissionError): break # Process is gone else: @@ -11931,32 +9456,16 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = ) try: terminate_pid(existing_pid, force=True) - time.sleep(0.5) + _time.sleep(0.5) except (ProcessLookupError, PermissionError, OSError): pass remove_pid_file() - # remove_pid_file() is a no-op when the PID doesn't match. - # Force-unlink to cover the old-process-crashed case. - try: - (get_hermes_home() / "gateway.pid").unlink(missing_ok=True) - except Exception: - pass - # Clean up any takeover marker the old process didn't consume - # (e.g. SIGKILL'd before its shutdown handler could read it). - try: - from gateway.status import clear_takeover_marker - clear_takeover_marker() - except Exception: - pass # Also release all scoped locks left by the old process. # Stopped (Ctrl+Z) processes don't release locks on exit, # leaving stale lock files that block the new gateway from starting. try: from gateway.status import release_all_scoped_locks - _released = release_all_scoped_locks( - owner_pid=existing_pid, - owner_start_time=existing_start_time, - ) + _released = release_all_scoped_locks() if _released: logger.info("Released %d stale scoped lock(s) from old gateway.", _released) except Exception: @@ -12018,27 +9527,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # Set up signal handlers def shutdown_signal_handler(): nonlocal _signal_initiated_shutdown - # Planned --replace takeover check: when a sibling gateway is - # taking over via --replace, it wrote a marker naming this PID - # before sending SIGTERM. If present, treat the signal as a - # planned shutdown and exit 0 so systemd's Restart=on-failure - # doesn't revive us (which would flap-fight the replacer when - # both services are enabled, e.g. hermes.service + hermes- - # gateway.service from pre-rename installs). - planned_takeover = False - try: - from gateway.status import consume_takeover_marker_for_self - planned_takeover = consume_takeover_marker_for_self() - except Exception as e: - logger.debug("Takeover marker check failed: %s", e) - - if planned_takeover: - logger.info( - "Received SIGTERM as a planned --replace takeover — exiting cleanly" - ) - else: - _signal_initiated_shutdown = True - logger.info("Received SIGTERM/SIGINT — initiating shutdown") + _signal_initiated_shutdown = True + logger.info("Received SIGTERM/SIGINT — initiating shutdown") # Diagnostic: log all hermes-related processes so we can identify # what triggered the signal (hermes update, hermes gateway restart, # a stale detached subprocess, etc.). @@ -12067,7 +9557,7 @@ def shutdown_signal_handler(): def restart_signal_handler(): runner.request_restart(detached=False, via_service=True) - loop = asyncio.get_running_loop() + loop = asyncio.get_event_loop() if threading.current_thread() is threading.main_thread(): for sig in (signal.SIGINT, signal.SIGTERM): try: @@ -12082,50 +9572,6 @@ def restart_signal_handler(): else: logger.info("Skipping signal handlers (not running in main thread).") - # Claim the PID file BEFORE bringing up any platform adapters. - # This closes the --replace race window: two concurrent `gateway run - # --replace` invocations both pass the termination-wait above, but - # only the winner of the O_CREAT|O_EXCL race below will ever open - # Telegram polling, Discord gateway sockets, etc. The loser exits - # cleanly before touching any external service. - import atexit - from gateway.status import write_pid_file, remove_pid_file, get_running_pid - _current_pid = get_running_pid() - if _current_pid is not None and _current_pid != os.getpid(): - logger.error( - "Another gateway instance (PID %d) started during our startup. " - "Exiting to avoid double-running.", _current_pid - ) - return False - if not acquire_gateway_runtime_lock(): - logger.error( - "Gateway runtime lock is already held by another instance. Exiting." - ) - return False - try: - write_pid_file() - except FileExistsError: - release_gateway_runtime_lock() - logger.error( - "PID file race lost to another gateway instance. Exiting." - ) - return False - atexit.register(remove_pid_file) - atexit.register(release_gateway_runtime_lock) - - # 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 - # internally; calling it from the loop thread would freeze platform - # heartbeats (Discord shard, Telegram polling) until it returned. - # See #16856. - try: - from tools.mcp_tool import discover_mcp_tools - _loop = asyncio.get_running_loop() - await _loop.run_in_executor(None, discover_mcp_tools) - except Exception as e: - logger.debug("MCP tool discovery failed: %s", e) - # Start the gateway success = await runner.start() if not success: @@ -12135,6 +9581,12 @@ def restart_signal_handler(): logger.error("Gateway exiting cleanly: %s", runner.exit_reason) return True + # Write PID file so CLI can detect gateway is running + import atexit + from gateway.status import write_pid_file, remove_pid_file + write_pid_file() + atexit.register(remove_pid_file) + # Start background cron ticker so scheduled jobs fire automatically. # Pass the event loop so cron delivery can use live adapters (E2EE support). cron_stop = threading.Event() @@ -12199,9 +9651,9 @@ def main(): config = None if args.config: - import yaml + import json with open(args.config, encoding="utf-8") as f: - data = yaml.safe_load(f) + data = json.load(f) config = GatewayConfig.from_dict(data) # Run the gateway - exit with code 1 if no platforms connected,