From 04fc5ea9eed72cd9f4feff5d7a42588b337475ee Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:03:07 -0700 Subject: [PATCH] refactor(gateway/slash): extract session-transcript commands into gateway/slash_commands_session.py (GatewaySessionCommandsMixin) --- gateway/slash_commands.py | 1503 +--------------------------- gateway/slash_commands_session.py | 1526 +++++++++++++++++++++++++++++ 2 files changed, 1529 insertions(+), 1500 deletions(-) create mode 100644 gateway/slash_commands_session.py diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 2d5b28c50d..b3125a314f 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -24,33 +24,26 @@ from typing import Any, Optional, Union from agent.account_usage import fetch_account_usage, render_account_usage_lines from agent.i18n import t -from agent.turn_context import extract_api_content_sidecar from gateway.config import HomeChannel, Platform, PlatformConfig, persist_home_channel from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType from gateway.session import ( AsyncSessionStore, - SessionSource, - build_session_key, - is_shared_multi_user_session, ) from hermes_cli.config import atomic_config_write, cfg_get from utils import ( atomic_json_write, is_truthy_value, ) -import contextlib from gateway.slash_commands_model import ( # noqa: F401 — _model_switch_skew_guard re-exported for callers/tests GatewayModelCommandsMixin, _model_switch_skew_guard, ) +from gateway.slash_commands_session import GatewaySessionCommandsMixin + logger = logging.getLogger("gateway.run") -# Upper bound on the off-loop agent-resource cleanup during a /new or /reset (see -# _handle_reset_command). A stuck teardown must not block the event loop; past this the reset -# proceeds and the cleanup is left to finish (or leak) in its worker thread. -_RESET_CLEANUP_TIMEOUT_S = 30.0 # /rollback result keys -> i18n line for files the safe restore left alone. _ROLLBACK_SKIP_LINES = ( @@ -109,38 +102,6 @@ def _int_value(value: Any) -> int: return 0 -def _manual_compression_reply_lines(summary: dict, compressor, focus_topic) -> list[str]: - """Lines for the manual /compress confirmation, surfacing summariser/aux-model failures. - - ``_last_compress_aborted`` = no usable summary, messages unchanged (force=True bypasses any - cooldown). Provider exception text is force-redacted at this UI boundary even when global - redaction is off. A configured aux model that failed and was recovered via main is an info - note so the user can fix their config. - """ - lines = [f"🗜️ {summary['headline']}"] - if focus_topic: - lines.append(t("gateway.compress.focus_line", topic=focus_topic)) - lines.append(summary["token_line"]) - if summary["note"]: - lines.append(summary["note"]) - summary_err = getattr(compressor, "_last_summary_error", None) - if summary_err: - from agent.redact import redact_sensitive_text - summary_err = redact_sensitive_text(summary_err, force=True) - aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None) - if getattr(compressor, "_last_compress_aborted", False): - lines.append(t("gateway.compress.aborted", error=(summary_err or "unknown error"))) - elif aux_fail_model: - lines.append( - t( - "gateway.compress.aux_failed", - model=aux_fail_model, - error=(getattr(compressor, "_last_aux_model_failure_error", None) or "unknown error"), - ) - ) - return lines - - def _status_model_route(status_agent, persisted_route: dict, session_row: dict, session_entry): """``(model, provider, context_used, context_total)`` for /status. @@ -373,36 +334,6 @@ def _spawn_detached_update(hermes_cmd, output_path, exit_code_path) -> None: subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) -def _reset_process_scoped_tool_state() -> None: - """Drop env-passthrough and credential-file state at a conversation boundary (best-effort).""" - try: - from tools.env_passthrough import clear_env_passthrough - clear_env_passthrough() - except Exception: - pass - try: - from tools.credential_files import clear_credential_files - clear_credential_files() - except Exception: - pass - - -_BRANCH_COPIED_FIELDS = ( - "content", "tool_calls", "tool_call_id", "finish_reason", "reasoning", "reasoning_content", - "reasoning_details", "codex_reasoning_items", "codex_message_items", "timestamp", -) - - -def _branch_row(msg: dict) -> dict: - """Transcript row copied into a /branch child. Keeps the api_content sidecar so the branch's - first turn replays the parent's exact wire bytes (warm provider prompt cache), not a cold prefill.""" - row = {k: msg.get(k) for k in _BRANCH_COPIED_FIELDS} - row["role"] = msg.get("role", "user") - row["tool_name"] = msg.get("tool_name") or msg.get("name") - row["api_content"] = extract_api_content_sidecar(msg) - return row - - def _home_thread_from_source(source) -> Optional[str]: """The thread id /sethome should persist on the home target, or None. @@ -422,7 +353,7 @@ def _home_thread_from_source(source) -> Optional[str]: return str(thread_id) -class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): +class GatewaySlashCommandsMixin(GatewayModelCommandsMixin, GatewaySessionCommandsMixin): """In-session slash-command handlers for GatewayRunner.""" async_session_store: AsyncSessionStore @@ -552,174 +483,6 @@ class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/" - async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: - """Handle /new or /reset command.""" - source = event.source - - # Get existing session key - session_key = self._session_key_for_source(source) - self._invalidate_session_run_generation(session_key, reason="session_reset") - # Evict the running-agent slot now that the generation is bumped: the in-flight run's own - # guarded release (old generation) returns False and would leave a zombie slot that silently - # drops all later messages. Idempotent, so the run's finally calling it again is harmless. - self._release_running_agent_state(session_key) - - # 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) - - # Close the old agent's tool resources (sandboxes, browser daemons, subprocesses) before - # evicting it; getattr-guarded since test fixtures may skip __init__. _cleanup_agent_resources - # is blocking and this handler runs ON the event loop (confirm-button click), so an inline - # call wedges the loop — offload to a worker thread with a bounded timeout. - _old_agent = self._cached_agent_for(session_key) - if _old_agent is not None: - try: - await asyncio.wait_for( - self._run_in_executor_with_context(self._cleanup_agent_resources, _old_agent), - timeout=_RESET_CLEANUP_TIMEOUT_S, - ) - except asyncio.TimeoutError: - # wait_for cancels the await, but the worker thread cannot be cancelled — a wedged - # teardown keeps running (or leaks) for the gateway's lifetime. The reset proceeds. - logger.warning( - "Agent resource cleanup for session %s exceeded %ss during " - "/new reset; proceeding with reset (the worker thread is left " - "to finish on its own). (#35994)", - session_key, _RESET_CLEANUP_TIMEOUT_S, - ) - except Exception as cleanup_exc: - logger.warning( - "Agent resource cleanup for session %s failed during /new " - "reset: %s (#35994)", - session_key, cleanup_exc, - ) - self._evict_cached_agent(session_key) - - # Conversation boundary: clear ALL conversation-scoped per-session state (model/reasoning - # overrides, one-turn restores, model notes, last-resolved cache, /queue overflow) + - # security state in one funnel call. See _CONVERSATION_SCOPED_STATE in gateway/run.py. - self._clear_conversation_scope(session_key, reason="session_reset") - - # The old conversation's in-flight async delegations end WITH it: once the session id rotates - # their completions have no live owner (orphaned payload on the shared queue, wasted tokens). - # Interrupt by expiring durable session id (parent_session_id), routing key as legacy fallback. - try: - from tools.async_delegation import interrupt_for_session - - interrupt_for_session( - session_key=session_key, - parent_session_id=str(getattr(old_entry, "session_id", "") or ""), - reason="session_reset", - ) - except Exception: - pass - _reset_process_scoped_tool_state() - - # Reset the session - new_entry = await self.async_session_store.reset_session(session_key) - - # (Conversation-scoped overrides + security state were already - # cleared via _clear_conversation_scope above.) - - _old_sid = old_entry.session_id if old_entry else None - platform_value = source.platform.value if source.platform else "" - - # Fire plugin on_session_finalize hook (session boundary). Off-loop + bounded: finalize - # hooks can block arbitrarily (observability trace exports) and this handler runs on the - # gateway event loop (see GatewayRunner._finalize_session_off_loop). - with contextlib.suppress(Exception): - await self._finalize_session_off_loop( - session_id=_old_sid, - platform=platform_value, - reason="new_session", - old_session_id=_old_sid, - new_session_id=new_entry.session_id if new_entry else None, - ) - - # Emit session:end (session is ending) then session:reset hooks. - hook_payload = {"platform": platform_value, "user_id": source.user_id, "session_key": session_key} - await self.hooks.emit("session:end", dict(hook_payload)) - await self.hooks.emit("session:reset", dict(hook_payload)) - - # Resolve session config info to surface to the user, scoped to the - # profile serving this source so a multiplexed /reset //new banner - # reports the profile's model, not the base config's (#59003). - try: - session_info = await asyncio.to_thread( - self._reset_notice_session_info, source - ) - except Exception: - session_info = "" - - if new_entry: - header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_default") - else: - # No existing session, just create one - new_entry = await self.async_session_store.get_or_create_session(source, force_new=True) - header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_new") - - # Set session title if provided with /new - _title_arg = event.get_command_args().strip() - if _title_arg and self._session_db and new_entry: - header = await self._reset_titled_header(header, new_entry.session_id, _title_arg) - - # When /new runs inside a Telegram DM topic lane, rewrite the (chat_id, thread_id) → - # session_id binding so the next message uses the freshly-created session. Otherwise the - # binding-lookup at the top of _handle_message_with_agent switches right back to the old one. - if await asyncio.to_thread(self._is_telegram_topic_lane, source) and new_entry is not None: - try: - await asyncio.to_thread(self._record_telegram_topic_binding, source, new_entry) - except Exception: - logger.debug("Failed to rebind Telegram topic after /new", exc_info=True) - - # Fire plugin on_session_reset hook (new session guaranteed to exist) - try: - from hermes_cli.lifecycle import invoke_hook as _invoke_hook - _new_sid = new_entry.session_id if new_entry else None - _invoke_hook( - "on_session_reset", - session_id=_new_sid, - platform=platform_value, - reason="new_session", - old_session_id=_old_sid, - new_session_id=_new_sid, - ) - except Exception: - pass - - # Append a random tip to the reset message - try: - from hermes_cli.tips import get_random_tip - _tip_line = t("gateway.reset.tip", tip=get_random_tip()) - except Exception: - _tip_line = "" - - if session_info: - return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}") - return EphemeralReply(f"{header}{_tip_line}") - - async def _reset_titled_header(self, header: str, session_id: str, title_arg: str) -> str: - """Apply ``/new <title>``: titled header on success, else the header plus a rejection note.""" - from hermes_state import SessionDB - note = "" - try: - sanitized = SessionDB.sanitize_title(title_arg) - except ValueError as e: - sanitized = None - note = t("gateway.reset.title_rejected", error=str(e)) - if sanitized: - try: - await self._session_db.set_session_title(session_id, sanitized) - header = t("gateway.reset.header_titled", title=sanitized) - except ValueError as e: - note = t("gateway.reset.title_error_untitled", error=str(e)) - except Exception: - pass - elif not note: - # sanitize_title returned empty (whitespace-only / unprintable) - note = t("gateway.reset.title_empty_untitled") - return header + note async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show the profile serving this source and its home. @@ -1194,216 +957,6 @@ class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): context_length = 0 return used, context_length, model_name - def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]: - """Best-effort origin lookup for gateway session IDs.""" - lookup = getattr(type(self.session_store), "lookup_by_session_id", None) - if callable(lookup): - entry = lookup(self.session_store, session_id) - return getattr(entry, "origin", None) if entry is not None else None - - # Test doubles and older stores may not expose the public lookup helper. - # Keep the Matrix resume guard fail-closed if no origin can be resolved. - entries = getattr(self.session_store, "_entries", {}) or {} - for entry in entries.values(): - if getattr(entry, "session_id", None) == session_id: - return getattr(entry, "origin", None) - return None - - @staticmethod - def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool: - return ( - origin is not None - and origin.platform == Platform.MATRIX - and current.platform == Platform.MATRIX - and origin.chat_id == current.chat_id - # thread_id is part of the session key (build_session_key appends it for every chat - # type when present) and Matrix scopes a turn to the current room/thread, so a live - # session in another thread of the SAME room is a DIFFERENT session: thread A must not - # resume/enumerate a target from thread B. Non-threaded rooms compare "" == "" unchanged. - and str(getattr(current, "thread_id", "") or "") - == str(getattr(origin, "thread_id", "") or "") - ) - - def _same_origin_chat(self, current: SessionSource, origin: Optional[SessionSource]) -> bool: - """Platform-agnostic counterpart to ``_same_matrix_room``. - - Per-participant sessions (``build_session_key`` with the default ``group_sessions_per_user``) - must be participant-scoped here too, else a co-member could resume another member's live - session (IDOR). Only an explicitly shared group/thread (``is_shared_multi_user_session``) shares. - """ - if origin is None or current is None: - return False - if origin.platform != current.platform: - return False - if origin.chat_id != current.chat_id: - return False - # thread_id is part of the session key for every chat type (build_session_key appends it - # unconditionally), so threads of the same parent chat are DIFFERENT sessions. - # is_shared_multi_user_session only decides sharing WITHIN a thread — require thread equality - # before any sharing logic so a live origin in thread A cannot match a caller in thread B. - if str(getattr(current, "thread_id", "") or "") != str( - getattr(origin, "thread_id", "") or "" - ): - return False - chat_type = (getattr(current, "chat_type", "") or "").lower() - # DM-like chats are always per-user. - if chat_type in {"dm", "direct", "private", ""}: - # chat_id was already required equal above and, when present, IS the DM session key, so - # an equal non-empty chat_id suffices. build_session_key falls back to the participant - # (``user_id_alt or user_id`` — Signal/Feishu key on user_id_alt) only when there is NO - # chat_id; mirror that and fail closed on a missing/different participant so two - # no-chat_id DM origins are never conflated. - if str(getattr(current, "chat_id", "") or ""): - return True - cur_pid = str(current.user_id_alt or current.user_id or "") - org_pid = str(origin.user_id_alt or origin.user_id or "") - return bool(cur_pid) and cur_pid == org_pid - # Non-DM: scope by participant whenever the session key for this source - # is per-user. is_shared_multi_user_session mirrors build_session_key's - # isolation rules exactly, so the guard stays in lock-step with the key. - if self._is_shared_session_source(current): - return True - # Per-user key: compare the participant id the key is actually built - # from (user_id_alt or user_id — Signal/Feishu key on user_id_alt). - cur_pid = current.user_id_alt or current.user_id - org_pid = origin.user_id_alt or origin.user_id - if cur_pid and org_pid: - return cur_pid == org_pid - # Per-user key but a participant id is missing on one side: cannot prove - # the same owner — fail closed. - return False - - def _is_shared_session_source(self, source: SessionSource) -> bool: - """Whether *source*'s session key is shared by every participant (not per-user). - - Mirrors build_session_key's isolation rules exactly, so the guards stay in lock-step with the key. - """ - return 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), - ) - - def _resume_caller_is_admin(self, source: SessionSource) -> bool: - """Whether *source* is an EXPLICITLY-configured admin allowed cross-origin /resume or /sessions. - - Stricter than ``SlashAccessPolicy.is_admin()``, which returns True for every allowed caller - when slash gating is DISABLED; cross-origin DATA ACCESS needs a real configured admin, else - the default (no admin list) config would make every caller cross-origin-capable (IDOR). - """ - try: - from gateway.slash_access import policy_for_source - policy = policy_for_source(self.config, source) - uid = getattr(source, "user_id", None) - return bool(policy.enabled and uid and policy.is_admin(uid)) - except Exception: - return False - - async def _resume_target_allowed( - self, source: SessionSource, target_id: str, allow_override: bool = False - ) -> bool: - """Whether *source* may resume the persisted session *target_id*. - - Generalizes the Matrix-only room guard to every adapter so a caller cannot bind to another - user's/room's session (IDOR). Uses the live origin when the target is active, else the DB - row's source + user_id; the row must PROVE ownership or fail closed. Admin ``--all`` bypasses. - """ - if allow_override and self._resume_caller_is_admin(source): - return True - # Use the live origin only when it resolves to a real SessionSource; a - # store that can't resolve it (or an unexpected lookup error) must not - # silently allow/deny — fall through to the deterministic DB scoping. - try: - origin = self._gateway_session_origin_for_id(target_id) - except Exception: - origin = None - if isinstance(origin, SessionSource): - return self._same_origin_chat(source, origin) - # Inactive/persisted-only: best-effort scope by DB row source + user. - try: - row = await self._session_db.get_session(target_id) or {} - except Exception: - return False - caller_src = source.platform.value if source.platform else None - row_src = row.get("source") - if row_src and caller_src and str(row_src) != str(caller_src): - return False # different platform / source - caller_uid = str(getattr(source, "user_id", "") or "") - row_uid = str(row.get("user_id") or "") - # Chat/thread origin recorded at session creation. Rows once stored only source + user_id, - # so a same-user row could belong to a DIFFERENT chat; comparing the persisted origin closes - # that gap. Legacy rows (NULL) fail closed — resume via a live session or an admin override. - caller_chat = str(getattr(source, "chat_id", "") or "") - row_chat = str(row.get("chat_id") or "") - caller_thread = str(getattr(source, "thread_id", "") or "") - row_thread = str(row.get("thread_id") or "") - chat_type = (getattr(source, "chat_type", "") or "").lower() - caller_is_dm = chat_type in {"dm", "direct", "private", ""} - # build_session_key keys the participant on ``user_id_alt or user_id``, but the sessions table - # has no user_id_alt column, so a row cannot prove the canonical participant for an alt-keyed - # (Signal/Feishu) caller: per-user row_uid == caller_uid checks must fail closed (CWE-639). - caller_keys_on_alt = bool(str(getattr(source, "user_id_alt", "") or "")) - if caller_uid: - # Identity-bearing caller: the row must PROVE the same owner AND platform AND chat/thread. - # A blank/legacy source can't prove the platform (row_src above only rejects a *mismatching* - # non-blank one); a different thread is a different session. Any gap fails closed. - origin_ok = ( - bool(row_src) and bool(caller_src) - and str(row_src) == str(caller_src) - and row_thread == caller_thread - ) - if not origin_ok: - return False - if caller_is_dm: - # DMs are keyed on user_id; require the same owner. A no-chat_id DM is keyed PURELY on - # the participant (so an alt-keyed caller fails closed); when both sides carry chat_id, - # equality is the DM key and suffices, and a mismatching chat_id is rejected. - if caller_keys_on_alt and not (bool(row_chat) and bool(caller_chat)): - return False - return ( - bool(row_uid) and row_uid == caller_uid - and row_chat == caller_chat - ) - # Non-DM (group/channel/forum/thread): build_session_key includes chat_id, so a row (or - # caller) with NO chat provenance cannot prove same-chat. Require both non-blank and - # equal — a legacy NULL-chat row fails closed even when both normalize to "". (CWE-639) - if not (bool(row_chat) and bool(caller_chat) and row_chat == caller_chat): - return False - # Same non-DM chat/thread: mirror build_session_key's participant scoping. A SHARED - # group/thread session (group_sessions_per_user=False, or a shared thread) is one session - # for every participant, so the same-chat proof suffices — do NOT also require user-id - # equality (it would block co-members). A per-user session still requires the same owner. - if self._is_shared_session_source(source): - return True - # Per-user non-DM: the session key includes the participant (``user_id_alt or - # user_id``). If the caller keys on user_id_alt, the persisted row (user_id only) cannot - # prove the canonical participant, so fail closed rather than matching on user_id alone. - if caller_keys_on_alt: - return False - return bool(row_uid) and row_uid == caller_uid - # No caller identity: the row carries only source + user_id, so a same-platform row can belong - # to a DIFFERENT chat or user — same platform alone is NOT ownership proof; fail closed - # (CWE-639). Same-chat resume of an ACTIVE session still works via the live-origin branch. - return False - - async def _resume_row_visible( - self, source: SessionSource, row: dict, allow_all: bool - ) -> bool: - """Whether a titled-session listing *row* belongs to the caller's origin. - - Prevents cross-origin enumeration of session ids/previews via the numbered /resume list; - keeps Matrix room-scoping, scopes every other platform to the caller unless admin ``--all``. - """ - sid = str(row.get("id") or "") - if source.platform == Platform.MATRIX: - # Cross-room enumeration is cross-ORIGIN data access: gate the ``--all`` short-circuit - # behind a real configured admin, exactly like the non-Matrix branch below. - if allow_all and self._resume_caller_is_admin(source): - return True - return self._same_matrix_room(source, self._gateway_session_origin_for_id(sid)) - if allow_all and self._resume_caller_is_admin(source): - return True - return await self._resume_target_allowed(source, sid, allow_override=False) async def _handle_agents_command(self, event: MessageEvent) -> str: """Handle /agents command - list active agents and running tasks.""" @@ -1785,90 +1338,6 @@ class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): return self._telegramized_command_reply(event, reply.text) - async def _handle_retry_command(self, event: MessageEvent) -> str: - """Handle /retry command - re-send the last user message.""" - source = event.source - session_entry = await self.async_session_store.get_or_create_session(source) - history = await self.async_session_store.load_transcript(session_entry.session_id) - - # Find the last *real* user message. Timeline bookkeeping rows carry role=user + - # display_kind (model_switch / async_delegation_complete / auto_continue / hidden); clients - # never count them as user turns. - last_user_idx = None - # The canonical projection excludes bookkeeping and pure handoffs while - # still recognizing a real ask embedded in a compaction carrier. - from agent.context_compressor import ( - history_before_user_originated_turn, - retryable_user_text, - split_user_originated_turn, - user_originated_turn_view, - ) - - for i in range(len(history) - 1, -1, -1): - msg = history[i] - if user_originated_turn_view(msg) is not None: - last_user_idx = i - break - - if last_user_idx is None: - return t("gateway.retry.no_previous") - - # Resolve the live text and the scaffold-preserving prefix before any - # transcript write. Messaging retries cannot reconstruct attachments; - # reject media/unknown content without truncating the session. - try: - truncated, live_view = history_before_user_originated_turn( - history, last_user_idx - ) - last_user_msg = retryable_user_text(live_view.get("content")) - handoff, _ = split_user_originated_turn(history[last_user_idx]) - except ValueError as exc: - return f"Cannot retry that message safely: {exc}" - - if handoff is not None: - # A composite carrier is one physical row containing both the retained summary and the - # live ask. Let the carrier-aware rewind archive that row/tail and insert its pure - # scaffold atomically. - try: - rewind_result = await self.async_session_store.rewind_session( - session_entry.session_id, - 1, - require_retryable_composite=True, - ) - except ValueError as exc: - return f"Cannot retry that message safely: {exc}" - if rewind_result is None: - return "Retry failed; transcript was not changed." - # The store reselects and validates the latest carrier on the same - # snapshot used by the atomic rewind. A concurrent newer turn can - # therefore never be removed while this handler resends stale text. - last_user_msg = rewind_result["target_text"] - else: - # After in-place compaction the pre-compaction transcript lives on as - # active=0/compacted=1 rows under this session id. active_only preserves that archive; a - # separate existence probe could fail open or race with the write. - if not await self.async_session_store.rewrite_transcript( - session_entry.session_id, - truncated, - active_only=True, - reject_active_turn_lease=True, - ): - return "Retry failed; transcript was not changed." - # Reset stored token count — transcript was truncated - session_entry.last_prompt_tokens = 0 - - # Re-send by creating a fake text event with the old message - retry_event = MessageEvent( - text=last_user_msg, - message_type=MessageType.TEXT, - source=source, - raw_message=event.raw_message, - channel_prompt=event.channel_prompt, - ) - - # Let the normal message handler process it - return await self._handle_message(retry_event) - async def _handle_goal_command(self, event: "MessageEvent") -> str: """Handle /goal for gateway platforms. @@ -2334,48 +1803,6 @@ class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): pass return output - async def _handle_undo_command(self, event: MessageEvent) -> str: - """Handle /undo [N] — back up N user turns (default 1), soft-deleting the truncated rows and - echoing the backed-up text. Evicts the cached agent so the next message rebuilds context - from the active-only transcript (gateway analogue of the CLI's history surgery). - """ - source = event.source - - # Parse optional turn count: "/undo" → 1, "/undo 3" → 3. - n = 1 - raw_args = event.get_command_args().strip() - if raw_args: - try: - n = int(raw_args.split()[0]) - except (ValueError, IndexError): - return t("gateway.undo.invalid_count", arg=raw_args.split()[0]) - if n < 1: - n = 1 - - session_entry = await self.async_session_store.get_or_create_session(source) - result = await self.async_session_store.rewind_session(session_entry.session_id, n) - - if result is None: - return t("gateway.undo.nothing") - - # Reset stored token count — transcript was truncated. - session_entry.last_prompt_tokens = 0 - # Evict the cached agent so the next turn rebuilds from the active-only - # transcript and memory providers refresh their per-session caches. - try: - session_key = build_session_key(source) - self._evict_cached_agent(session_key) - except Exception as e: - logger.debug("undo: cached-agent eviction skipped: %s", e) - - target_text = result["target_text"] - preview = target_text[:200] + "..." if len(target_text) > 200 else target_text - return t( - "gateway.undo.removed", - turns=result["turns_undone"], - count=result["rewound_count"], - preview=preview, - ) async def _handle_set_home_command(self, event: MessageEvent) -> str: """Handle /sethome command -- set the current chat as the platform's home channel.""" @@ -3026,930 +2453,6 @@ class GatewaySlashCommandsMixin(GatewayModelCommandsMixin): example = t("gateway.footer.example_line", preview=preview) return t("gateway.footer.saved", state=state, example=example) - async def _handle_compress_command(self, event: MessageEvent) -> str: - """Profile-scoping wrapper around manual /compress. - - Multiplexed gateways resolve credentials through the fail-closed per-profile secret scope; - slash dispatch (unlike ``_run_agent``) does not install it, so an unscoped /compress would - raise ``UnscopedSecretError``. Single-profile gateways skip this. - """ - if not getattr(getattr(self, "config", None), "multiplex_profiles", False): - return await self._handle_compress_command_inner(event) - - from gateway.run import _profile_runtime_scope - - profile_home = self._resolve_profile_home_for_source(event.source) - with _profile_runtime_scope(profile_home): - return await self._handle_compress_command_inner(event) - - async def _compress_codex_app_server_session( - self, session_key: str, session_id: str - ) -> str: - """Manual /compress for codex_app_server sessions. - - Compacts the LIVE cached agent's app-server thread (``thread/compact/start``, ``force=True`` - bypasses the ``codex_app_server_auto`` gate) and keeps the agent cached. Never builds a - temporary agent or rewrites the mirror: neither can shrink the server-side thread. - """ - from gateway.run import _AGENT_PENDING_SENTINEL - - agent = self._cached_agent_for(session_key) - if ( - agent is None - or agent is _AGENT_PENDING_SENTINEL - or getattr(agent, "_codex_session", None) is None - ): - return ( - "🗜️ Nothing to compact: this session runs on the Codex " - "app-server runtime, whose context lives in a Codex-owned " - "thread that only exists while the agent is active. Send a " - "message first, then /compress — or /reset to start fresh." - ) - - compressor = getattr(agent, "context_compressor", None) - count_before = getattr(compressor, "compression_count", 0) - try: - await self._run_in_executor_with_context( - lambda: agent._compress_context( - [], "", force=True, - ) - ) - except Exception as exc: - return t("gateway.compress.failed", error=exc) - count_after = getattr(compressor, "compression_count", 0) - if count_after > count_before: - return ( - "🗜️ Codex app-server thread compacted (thread/compact). " - "The transcript mirror is unchanged by design — the " - "app-server now carries the compacted context." - ) - return ( - "⚠️ Codex app-server compaction did not complete — the thread " - "is unchanged. Check the app-server logs, retry /compress, or " - "/reset for a clean session." - ) - - async def _handle_compress_command_inner(self, event: MessageEvent) -> str: - """Handle /compress command -- manually compress conversation context. - - Optional ``/compress <focus>`` tells the summariser what to preserve, discarding the rest. - """ - source = event.source - session_entry = await self.async_session_store.get_or_create_session(source) - history = await self.async_session_store.load_transcript(session_entry.session_id) - - if not history or len(history) < 4: - return t("gateway.compress.not_enough") - - # Parse args: either a focus topic (full compress) or the - # boundary-aware "here [N]" form (partial compress). - from hermes_cli.partial_compress import ( - extract_compress_flags, - parse_partial_compress_args, - rejoin_compressed_head_and_tail, - split_history_for_partial_compress, - summarize_compress_preview, - ) - from agent.conversation_compression import ( - finalize_context_engine_compression_notification, - ) - _raw_args = (event.get_command_args() or "").strip() - # Strip --preview/--dry-run/--aggressive before positional parsing - # so the flags coexist with 'here [N]' / focus-topic forms. - _raw_args, _preview, _aggressive = extract_compress_flags(_raw_args) - partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args) - - _agg_note = "" - if _aggressive: - # LLM-free hard truncation is not supported on this surface — it would need its own - # transcript-persistence branch outside the guarded _compress_context rotation machinery. - _agg_note = t("gateway.compress.aggressive_unsupported") - if not _preview: - return _agg_note - - if _preview: - # Report what WOULD be compressed — no agent, no writes. - from agent.model_metadata import estimate_request_tokens_rough - _pv_msgs = [ - {"role": m.get("role"), "content": m.get("content")} - for m in history - if m.get("role") in {"user", "assistant"} and m.get("content") - ] - report = summarize_compress_preview( - _pv_msgs, partial, keep_last, focus_topic, estimate_request_tokens_rough(_pv_msgs) - ) - lines = [f"🗜️ {line}" for line in report["lines"]] - if _aggressive: - lines.append(_agg_note) - return "\n".join(lines) - - try: - from agent.manual_compression_feedback import summarize_manual_compression - from agent.model_metadata import estimate_request_tokens_rough - from gateway.run import _platform_config_key - - session_key = self._session_key_for_source(source) - # Preserve the platform + stable gateway session identity of a normal turn so external - # context engines bind this agent to the original conversation, not a default "cli" host. - platform_key = ( - _platform_config_key(source.platform) if source.platform else None - ) - model, runtime_kwargs = self._resolve_session_agent_runtime( - source=source, - session_key=session_key, - ) - if str(runtime_kwargs.get("api_mode") or "").lower() == "codex_app_server": - # codex app-server: the model's context is the server-side thread owned by the LIVE - # cached agent; a temporary agent has none (and finally-eviction would destroy the - # real context). Compact the live thread and KEEP the agent cached; no mirror fallback. - return await self._compress_codex_app_server_session( - session_key, session_entry.session_id - ) - if not runtime_kwargs.get("api_key"): - return t("gateway.compress.no_provider") - - # Pass the FULL transcript (tool results included), like auto-compress: user/assistant- - # only starves tool-result pruning and can trip the protect-first/last early-return. - msgs = [ - m for m in history - if m.get("role") in {"user", "assistant", "tool"} - ] - - # Boundary-aware split: only the head is summarized; the most recent `keep_last` - # exchanges are preserved verbatim. The split snaps the tail to a user-turn start so the - # rejoined transcript keeps role alternation valid. - tail: list = [] - head = msgs - if partial: - head, tail = split_history_for_partial_compress(msgs, keep_last) - if not tail: - # Degenerate split — fall back to full compression. - partial = False - head = msgs - - # Bind the temporary compression agent to the source's platform + stable gateway session - # key. Assign directly (not setdefault: a resolver value would be a stale placeholder, - # and it avoids duplicate-kwarg TypeError); platform only when known so None -> "cli" holds. - if platform_key is not None: - runtime_kwargs["platform"] = platform_key - runtime_kwargs["gateway_session_key"] = session_key - - tmp_agent = await self._build_manual_compression_agent( - session_entry.session_id, model, runtime_kwargs - ) - try: - # Estimate with system prompt + tool schemas included so the figure reflects real - # request pressure, not a transcript-only underestimate. Must be computed after - # tmp_agent is built so _cached_system_prompt/tools are populated. - _sys_prompt = getattr(tmp_agent, "_cached_system_prompt", "") or "" - _tools = getattr(tmp_agent, "tools", None) or None - approx_tokens = estimate_request_tokens_rough( - msgs, system_prompt=_sys_prompt, tools=_tools - ) - - compressor = tmp_agent.context_compressor - if not compressor.has_content_to_compress(head): - return t("gateway.compress.nothing_to_do") - - # Not a bare run_in_executor: the profile secret scope is a contextvar and the - # default-executor hop would drop it, making the compressor's aux-client credential - # resolution fail closed under multiplexing. - compressed, _ = await self._run_in_executor_with_context( - lambda: tmp_agent._compress_context( - head, - "", - approx_tokens=approx_tokens, - focus_topic=focus_topic, - force=True, - defer_context_engine_notification=True, - ) - ) - - # If _compress_context returned unchanged because a concurrent compression lock is - # held, tell the user clearly instead of showing the misleading "No changes from - # compression" no-op text. - _lock_skipped = getattr(tmp_agent, "_compression_skipped_due_to_lock", None) - if _lock_skipped is True or isinstance(_lock_skipped, str): - from agent.manual_compression_feedback import ( - describe_compression_lock_skip, - ) - return describe_compression_lock_skip(_lock_skipped) - - if partial and tail: - compressed = rejoin_compressed_head_and_tail(compressed, tail) - - await self._persist_manual_compression(tmp_agent, session_entry, source, compressed) - finalize_context_engine_compression_notification( - tmp_agent, - committed=True, - ) - new_tokens = estimate_request_tokens_rough( - compressed, system_prompt=_sys_prompt, tools=_tools - ) - summary = summarize_manual_compression( - msgs, - compressed, - approx_tokens, - new_tokens, - compression_state=compressor, - ) - finally: - finalize_context_engine_compression_notification( - tmp_agent, - committed=False, - ) - # Evict cached agent so next turn rebuilds system prompt - # from current files (SOUL.md, memory, etc.). - self._evict_cached_agent(session_key) - # Off-loop + bounded: temporary-agent teardown can block on - # subprocess/network/SQLite work. - await self._cleanup_agent_resources_off_loop( - tmp_agent, context="manual compression" - ) - return "\n".join(_manual_compression_reply_lines(summary, compressor, focus_topic)) - except Exception as e: - logger.warning("Manual compress failed: %s", e) - return t("gateway.compress.failed", error=e) - - async def _build_manual_compression_agent(self, session_id: str, model, runtime_kwargs: dict): - """Build the throwaway AIAgent that performs a manual /compress rewrite of *session_id*.""" - from run_agent import AIAgent - from gateway.run import _GATEWAY_HYGIENE_PLATFORM, _seed_hygiene_system_prompt - - # The manual compression helper runs outside the live session's fully initialized prompt - # environment and _compress_context may persist its cached system prompt — restore the - # exact live-session prompt so provider blocks are retained. - session_row = None - get_session = getattr(self._session_db, "get_session", None) - if callable(get_session): - try: - session_row = await get_session(session_id) - except Exception as exc: - logger.warning( - "Manual compression could not restore the system prompt " - "for session %s: %s. Preserving an empty prompt so the " - "live turn rebuilds it with its configured providers.", - session_id, - exc, - exc_info=True, - ) - - # This agent performs a lossy rewrite. When compression.checkpoint_required is on, the - # memory provider must be loaded so _compress_context() can write the pre-compression - # checkpoint; otherwise keep the historical fast path (no provider init). - from hermes_cli.config import load_config as _load_cfg - from utils import is_truthy_value as _is_truthy - - _checkpoint_required = _is_truthy( - ((_load_cfg() or {}).get("compression") or {}).get("checkpoint_required"), - default=False, - ) - tmp_agent = AIAgent( - **runtime_kwargs, - model=model, - max_iterations=4, - quiet_mode=True, - skip_memory=not _checkpoint_required, - enabled_toolsets=["memory"], - session_id=session_id, - session_db=getattr(self._session_db, "_db", self._session_db), - ) - _seed_hygiene_system_prompt(tmp_agent, session_row) - # Keep the real source platform during construction so external context engines bind - # correctly. If compression has to rebuild the prompt, stamp that provider-less fallback - # as stale for the next real gateway turn. - tmp_agent.platform = _GATEWAY_HYGIENE_PLATFORM - tmp_agent._print_fn = lambda *a, **kw: None - # Prevent close() from ending the newly rotated session — the gateway session entry now - # points at the new id and must remain open for the next user turn. - tmp_agent._end_session_on_close = False - return tmp_agent - - async def _persist_manual_compression(self, tmp_agent, session_entry, source, compressed) -> None: - """Commit a manual /compress result to the session store. - - _compress_context either rotated (new continuation id — write compressed messages into the - NEW session so the original stays searchable) or compacted in place (compression.in_place: - same id, transcript replaced). Persist BEFORE repointing the live session: repoint first + - failed DB write would leave the entry on an empty session while reporting success; a failed - write is fatal so old history stays reachable. Only rewrite when rotation produced a NEW id: - in-place compaction already archived + inserted rows and rewrite_transcript() - (active_only=False) would DELETE the archived turns; an unchanged id without in-place means - rotation FAILED and a rewrite would leave only the summary. - """ - new_session_id = tmp_agent.session_id - if new_session_id != session_entry.session_id: - if not await self.async_session_store.rewrite_transcript(new_session_id, compressed): - raise RuntimeError( - f"failed to persist compressed transcript for session {new_session_id}" - ) - session_entry.session_id = new_session_id - await self.async_session_store._save() - await asyncio.to_thread( - self._sync_telegram_topic_binding, - source, session_entry, reason="compress-command", - ) - elif not getattr(tmp_agent, "_last_compaction_in_place", False): - logger.warning( - "Manual /compress: session rotation did not occur " - "(session_id unchanged) and in-place mode is off — " - "preserving original transcript instead of overwriting " - "it (#44794)." - ) - # Reset stored token count — transcript changed, old value is stale - await self.async_session_store.update_session(session_entry.session_key, last_prompt_tokens=0) - - async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str: - """Handle /topic for Telegram DM user-managed topic sessions.""" - source = event.source - if source.platform != Platform.TELEGRAM or source.chat_type != "dm": - return t("gateway.topic.not_telegram_dm") - if not self._session_db: - return self._session_db_unavailable_reply() - - # Authorization: /topic activates multi-session mode and mutates SQLite side tables. - # Unauthorized senders (not in allowlist) must not be able to do that. Gateway routes - # already authorize the message before reaching here, but defense in depth. - auth_fn = getattr(self, "_is_user_authorized", None) - if callable(auth_fn): - try: - if not auth_fn(source): - return t("gateway.topic.unauthorized") - except Exception: - logger.debug("Topic auth check failed", exc_info=True) - - args = event.get_command_args().strip() - - # /topic help — inline usage without leaving the bot. - if args.lower() in {"help", "?", "-h", "--help"}: - return self._telegram_topic_help_text() - - # /topic off — clean disable path so users don't have to edit the DB. - if args.lower() in {"off", "disable", "stop"}: - return await self._disable_telegram_topic_mode_for_chat(source) - - if args: - if not source.thread_id: - return t("gateway.topic.restore_needs_topic") - return await self._restore_telegram_topic_session(event, args) - - capabilities = await self._get_telegram_topic_capabilities(source) - if capabilities.get("checked"): - if capabilities.get("has_topics_enabled") is False: - # Debounce the BotFather screenshot: don't re-send on every - # /topic while threads are still disabled. - if self._should_send_telegram_capability_hint(source): - await self._send_telegram_topic_setup_image(source) - return t("gateway.topic.topics_disabled") - if capabilities.get("allows_users_to_create_topics") is False: - if self._should_send_telegram_capability_hint(source): - await self._send_telegram_topic_setup_image(source) - return t("gateway.topic.topics_user_disallowed") - - try: - await self._session_db.enable_telegram_topic_mode( - chat_id=str(source.chat_id), - user_id=str(source.user_id), - profile_name=self._telegram_topic_profile_name(source), - has_topics_enabled=capabilities.get("has_topics_enabled"), - allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"), - ) - except Exception as exc: - logger.exception("Failed to enable Telegram topic mode") - return t("gateway.topic.enable_failed", error=exc) - - if not source.thread_id: - await self._ensure_telegram_system_topic(source) - - if source.thread_id: - try: - binding = await self._session_db.get_telegram_topic_binding( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - profile_name=self._telegram_topic_profile_name(source), - ) - except Exception: - logger.debug("Failed to read Telegram topic binding", exc_info=True) - binding = None - if binding: - session_id = str(binding.get("session_id") or "") - title = None - try: - title = await self._session_db.get_session_title(session_id) - except Exception: - title = None - session_label = title or t("gateway.topic.untitled_session") - return t( - "gateway.topic.bound_status", - label=session_label, - session_id=session_id, - ) - return t("gateway.topic.thread_ready") - - return await self._telegram_topic_root_status_message(source) - - async def _handle_save_command(self, event: MessageEvent) -> str: - """Handle /save — export the current session and send it as a document.""" - from hermes_cli.session_export import ( - SAVE_USAGE, - default_save_filename, - normalize_save_format, - render_session_for_save, - ) - - parts = event.get_command_args().split() - if not parts: - return SAVE_USAGE - redact = False - if parts[-1].lower() in ("redact", "--redact"): - redact = True - parts = parts[:-1] - if not parts: - return SAVE_USAGE - - try: - fmt = normalize_save_format(parts[0]) - except ValueError as e: - return f"{e}\n\n{SAVE_USAGE}" - - source = event.source - session_entry = await self.async_session_store.get_or_create_session(source) - session_id = session_entry.session_id - - if not self._session_db: - return "Session database not available." - filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt) - # The filename is echoed to the platform only — never trust path - # separators from chat input. - filename = os.path.basename(filename) or default_save_filename(session_id, fmt) - - # self._session_db is an AsyncSessionDB — every forwarded call is - # offloaded to a thread and must be awaited. - export_data = await self._session_db.export_session(session_id) - if not export_data: - return f"No stored messages found for this session ({session_id})." - - if redact: - from hermes_cli.session_export_md import redact_session_data - - export_data = redact_session_data(export_data) - - import tempfile - - temp_dir = tempfile.mkdtemp(prefix="hermes_save_") - temp_path = os.path.join(temp_dir, filename) - try: - # Off-loop: rendering a long session and writing it to disk are CPU/disk-bound and scale - # with transcript size (multi-MB for long sessions). Inline they stall every other chat - # on the gateway event loop (Pattern A). One thread hop covers both. - def _render_and_write() -> None: - rendered = render_session_for_save(export_data, fmt) - with open(temp_path, "w", encoding="utf-8") as f: - f.write(rendered) - - await asyncio.to_thread(_render_and_write) - - adapter = self.get_adapter(source.platform) - if adapter: - await adapter.send_document( - chat_id=source.chat_id, - file_path=temp_path, - caption=f"Session export: {filename}", - file_name=filename, - ) - return "Export complete." - return "Platform adapter not found to send the document." - except Exception as e: - logger.warning("Session /save failed: %s", e) - return f"Error exporting session: {e}" - finally: - try: - os.remove(temp_path) - os.rmdir(temp_dir) - except Exception: - pass - - async def _handle_title_command(self, event: MessageEvent) -> str: - """Handle /title command — set or show the current session's title.""" - source = event.source - session_entry = await self.async_session_store.get_or_create_session(source) - session_id = session_entry.session_id - - if not self._session_db: - return self._session_db_unavailable_reply() - - # Ensure session exists in SQLite DB (it may only exist in session_store - # if this is the first command in a new session) - existing_title = await self._session_db.get_session_title(session_id) - if existing_title is None: - # Session doesn't exist in DB yet — create it - try: - await self._session_db.create_session( - session_id=session_id, - source=source.platform.value if source.platform else "unknown", - user_id=source.user_id, - # Persist the messaging origin so a later /resume of this - # titled-but-now-inactive session can prove it belongs to the - # caller's chat/thread (IDOR scoping). - chat_id=source.chat_id, - chat_type=source.chat_type, - thread_id=source.thread_id, - ) - except Exception: - pass # Session might already exist, ignore errors - - title_arg = event.get_command_args().strip() - if title_arg: - # Sanitize the title before setting - try: - from hermes_state import SessionDB - sanitized = SessionDB.sanitize_title(title_arg) - except ValueError as e: - return t("gateway.shared.warn_passthrough", error=e) - if not sanitized: - return t("gateway.title.empty_after_clean") - # Set the title - try: - if await self._session_db.set_session_title(session_id, sanitized): - # Propagate the user-chosen title to the visible Telegram forum topic name too. - # Auto-generated titles already rename the topic; without this, /title only - # updated the DB title and the topic kept its auto-assigned name. - schedule_rename = getattr( - self, "_schedule_telegram_topic_title_rename", None - ) - if callable(schedule_rename): - try: - await asyncio.to_thread(schedule_rename, source, session_id, sanitized) - except Exception: - logger.debug( - "Failed to rename Telegram topic from /title", - exc_info=True, - ) - return t("gateway.title.set_to", title=sanitized) - else: - return t("gateway.title.not_found") - except ValueError as e: - return t("gateway.shared.warn_passthrough", error=e) - else: - # Show the current title and session ID - title = await self._session_db.get_session_title(session_id) - if title: - return t("gateway.title.current_with_title", session_id=session_id, title=title) - else: - return t("gateway.title.current_no_title", session_id=session_id) - - async def _handle_resume_command(self, event: MessageEvent) -> str: - """Handle /resume command — list or switch to a previous session.""" - if not self._session_db: - return self._session_db_unavailable_reply() - - source = await asyncio.to_thread( - self._normalize_source_for_session_key, event.source - ) - session_key = self._session_key_for_source(source) - raw_args = event.get_command_args().strip() - try: - parts = shlex.split(raw_args) - except ValueError as exc: - return t("gateway.resume.parse_error", error=exc) - allow_all = "--all" in parts - allow_cross_room = "--cross-room" in parts - name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip() - - # Strip common outer brackets/quotes users may type literally from the - # usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior. - if len(name) >= 2 and ( - (name[0] == "<" and name[-1] == ">") - or (name[0] == "[" and name[-1] == "]") - or (name[0] == '"' and name[-1] == '"') - or (name[0] == "'" and name[-1] == "'") - ): - name = name[1:-1].strip() - - async def _list_titled_sessions() -> list[dict]: - """Titled sessions visible to the caller (origin-scoped unless admin ``--all``).""" - user_source = source.platform.value if source.platform else None - widen = allow_all and self._resume_caller_is_admin(source) - sessions = await self._session_db.list_sessions_rich( - source=user_source, - session_key=None if widen else session_key, - limit=10, - ) - titled = [s for s in sessions if s.get("title")][:10] - return [s for s in titled if await self._resume_row_visible(source, s, allow_all)] - - if not name: - # List recent titled sessions for this user/platform - try: - titled = await _list_titled_sessions() - return self._resume_listing_reply(source, titled, allow_all) - except Exception as e: - logger.debug("Failed to list titled sessions: %s", e) - return t("gateway.resume.list_failed", error=e) - - # Resolve a numbered choice or a title to a session ID. - if name.isdigit(): - try: - titled = await _list_titled_sessions() - except Exception as e: - logger.debug("Failed to list titled sessions for numeric resume: %s", e) - return t("gateway.resume.list_failed", error=e) - index = int(name) - if index < 1 or index > len(titled): - return t("gateway.resume.out_of_range", index=index) - target = titled[index - 1] - target_id = target.get("id") - name = target.get("title") or name - else: - # Try direct session ID lookup first (so `/resume <session_id>` - # works in the gateway, not just `/resume <title>`). - session = await self._session_db.get_session(name) - if session: - target_id = session["id"] - else: - target_id = await self._session_db.resolve_session_by_title(name) - if not target_id: - return t("gateway.resume.not_found", name=name) - # Compression creates child continuations that hold the live transcript. - # Follow that chain so gateway /resume matches CLI behavior (#15000). - try: - target_id = await 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) - - if source.platform == Platform.MATRIX: - target_origin = self._gateway_session_origin_for_id(target_id) - if not self._same_matrix_room(source, target_origin) and not allow_cross_room: - if target_origin is None: - return t("gateway.resume.matrix_blocked_no_origin", name=name) - return t( - "gateway.resume.matrix_blocked_other_room", - room=target_origin.chat_name or target_origin.chat_id, - name=name, - ) - elif not await self._resume_target_allowed( - source, target_id, allow_override=(allow_all or allow_cross_room) - ): - # IDOR guard: a session id/title is a routing handle, not authority. Bind /resume to the - # caller's own platform/user/chat on every non-Matrix adapter so one user can't attach - # to another's persisted transcript. - return t("gateway.resume.blocked_not_owner", name=name) - - # Check if already on that session - current_entry = await self.async_session_store.get_or_create_session(source) - if current_entry.session_id == target_id: - return t("gateway.resume.already_on", name=name) - - # Clear any running agent for this session key - self._release_running_agent_state(session_key) - - # Switch the session entry to point at the old session - new_entry = await self.async_session_store.switch_session(session_key, target_id) - if not new_entry: - return t("gateway.resume.switch_failed") - - # Conversation boundary: clear ALL conversation-scoped per-session state (model/reasoning - # overrides #10702, one-turn restores, model notes, last-resolved cache #58403, /queue - # overflow) + security state in one funnel call. - self._clear_conversation_scope(session_key, reason="resume") - - # Evict any cached agent for this session so the next message rebuilds with the correct - # session_id end-to-end — mirrors /branch and /reset. Otherwise the cached AIAgent (and its - # memory provider, which cached _session_id at initialize()) keeps writing to the wrong session. - self._evict_cached_agent(session_key) - - # Get the title for confirmation - title = await self._session_db.get_session_title(target_id) or name - - # Count messages for context - history = await self.async_session_store.load_transcript(target_id) - msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 - msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" - - if source.platform == Platform.MATRIX and allow_cross_room: - return t( - "gateway.resume.matrix_cross_room_success", - title=title, - room=source.chat_name or source.chat_id, - msg_part=msg_part, - ) - if not msg_count: - return t("gateway.resume.resumed_no_count", title=title) - if msg_count == 1: - return t("gateway.resume.resumed_one", title=title, count=msg_count) - return t("gateway.resume.resumed_many", title=title, count=msg_count) - - def _resume_listing_reply(self, source, titled: list[dict], allow_all: bool) -> str: - """Numbered /resume list. A non-admin ``--all`` silently falls back to same-origin scoping; - say so instead of rendering an unexplained narrower list (sibling of the /sessions notice).""" - scope_note = ( - t("gateway.resume.all_requires_admin") - if allow_all and not self._resume_caller_is_admin(source) - else None - ) - if not titled: - if source.platform == Platform.MATRIX and not allow_all: - return t("gateway.resume.matrix_no_named_sessions") - base = t("gateway.resume.no_named_sessions") - return f"{base}\n{scope_note}" if scope_note else base - lines = [t("gateway.resume.list_header")] - for idx, s in enumerate(titled[:10], start=1): - title = s["title"] - if source.platform == Platform.MATRIX and allow_all: - origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) - if origin: - title = f"{title} — {origin.chat_name or origin.chat_id}" - preview = s.get("preview", "")[:40] - preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" - lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) - if scope_note: - lines.append(scope_note) - lines.append(t("gateway.resume.list_footer_numbered")) - return "\n".join(lines) - - async def _handle_sessions_command(self, event: MessageEvent) -> str: - """Handle /sessions — list previous sessions for gateway chats.""" - if not self._session_db: - return self._session_db_unavailable_reply() - - from hermes_cli.session_listing import ( - format_gateway_session_listing, - parse_session_listing_args, - query_session_listing, - ) - - raw_args = event.get_command_args().strip() - try: - include_all, include_unnamed, target, search_query = ( - parse_session_listing_args(raw_args) - ) - except ValueError as exc: - return t("gateway.resume.parse_error", error=exc) - - if search_query == "": - return "Usage: `/sessions search <query>`" - - if target: - resume_event = dataclasses.replace(event, text=f"/resume {target}") - return await self._handle_resume_command(resume_event) - - source = await asyncio.to_thread( - self._normalize_source_for_session_key, event.source - ) - session_key = self._session_key_for_source(source) - - # A cross-origin listing (`/sessions all`) is honored only for an admin, mirroring the - # `/resume --all` override. `all` is just a parsed user argument; ungated, any caller could - # enumerate other origins' session ids/titles/previews — the enumeration half of the IDOR. - cross_origin = include_all and self._resume_caller_is_admin(source) - # Don't silently no-op a requested widening: a non-admin `/sessions all` - # used to render the same scoped list with zero feedback, which reads - # as "my session vanished" (community report, Aug 2026). - scope_notice = None - if include_all and not cross_origin: - scope_notice = ( - "_Note: `all` (cross-chat listing) requires a configured admin; " - "showing this chat's sessions only._" - ) - current_entry = await self.async_session_store.get_or_create_session(source) - rows = await asyncio.to_thread( - query_session_listing, - getattr(self._session_db, "_db", self._session_db), - source=source.platform.value if source.platform else None, - session_key=None if cross_origin else session_key, - current_session_id=current_entry.session_id, - include_current_session=True, - include_all_sources=cross_origin, - include_unnamed=include_unnamed, - search_query=search_query, - # Search filters at SQL level, so over-fetch before the visibility - # cut: origin-invisible matches would otherwise consume the page. - limit=50 if search_query else 10, - exclude_sources=["tool"], - ) - if not cross_origin: - # Scope the listing to the caller's own origin on every adapter so - # session ids/previews from other users/rooms aren't enumerable. - rows = [ - row for row in rows - if await self._resume_row_visible(source, row, allow_all=False) - ] - rows = rows[:10] - if search_query: - title = f"Sessions matching “{search_query}”" - else: - title = "Sessions" if include_unnamed else "Named Sessions" - return format_gateway_session_listing( - rows, - include_source=cross_origin, - title=title, - notice=scope_notice, - ) - - async def _handle_branch_command(self, event: MessageEvent) -> str: - """Handle /branch [name] — fork the current session into a new independent copy so the - user can explore a different approach without losing the original. - """ - import uuid as _uuid - - if not self._session_db: - return self._session_db_unavailable_reply() - - source = event.source - session_key = self._session_key_for_source(source) - - # Load the current session and its transcript - current_entry = await self.async_session_store.get_or_create_session(source) - history = await self.async_session_store.load_transcript(current_entry.session_id) - if not history: - return t("gateway.branch.no_conversation") - - branch_name = event.get_command_args().strip() - - # Generate the new session ID - from datetime import datetime as _dt - now = _dt.now() - timestamp_str = now.strftime("%Y%m%d_%H%M%S") - short_uuid = _uuid.uuid4().hex[:6] - new_session_id = f"{timestamp_str}_{short_uuid}" - - # Determine branch title - if branch_name: - branch_title = branch_name - else: - current_title = await self._session_db.get_session_title(current_entry.session_id) - base = current_title or "branch" - branch_title = await self._session_db.get_next_title_in_lineage(base) - - parent_session_id = current_entry.session_id - - # Serialize the parent's full origin (same shape as the reset path's db_create_kwargs in - # gateway/session.py, #82633) so the branch row carries complete identity from birth. Prefer - # the live entry's origin (it may hold richer metadata than the triggering event's source). - _branch_origin = current_entry.origin or source - _branch_origin_json = None - if _branch_origin is not None: - try: - import json as _json - - _branch_origin_json = _json.dumps(_branch_origin.to_dict()) - except Exception: - _branch_origin_json = None - - # Create the new session with parent link. Persist a stable ``_branched_from`` marker in - # model_config so list_sessions_rich() keeps the branch visible in /resume and /sessions - # even after the parent is reopened and re-ended with a different end_reason. - try: - await self._session_db.create_session( - session_id=new_session_id, - source=source.platform.value if source.platform else "gateway", - model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, - model_config={"_branched_from": parent_session_id}, - parent_session_id=parent_session_id, - # Forward ALL gateway routing columns at CREATE time: otherwise they're NULL until - # switch_session() calls _record_gateway_session_peer(), and a crash in between (each - # append_message is best-effort) leaves the branch unroutable — by chat/thread lookup - # and by /resume's IDOR guard. user_id feeds the full-peer-tuple fallback lookup; - # origin_json/display_name complete the identity (same shape as session.py's reset - # path) so state.db consumers see a fully formed row with no backfill gap. - user_id=source.user_id, - session_key=session_key, - chat_id=source.chat_id, - chat_type=source.chat_type, - thread_id=source.thread_id, - origin_json=_branch_origin_json, - display_name=current_entry.display_name, - ) - except Exception as e: - logger.error("Failed to create branch session: %s", e) - return t("gateway.branch.create_failed", error=e) - - # Copy conversation history to the new session in bounded-chunk transactions: one txn per - # row was the removed write-amplification pattern, and a history can be hundreds of rows. - # Best-effort like the old loop — a failed copy still yields a usable (partial) branch. - try: - await self._session_db.append_messages_batch( - new_session_id, [_branch_row(msg) for msg in history], chunk_rows=500, - ) - except Exception: - pass # Best-effort copy - - # Set title - with contextlib.suppress(Exception): - await self._session_db.set_session_title(new_session_id, branch_title) - - # Switch the session store entry to the new session - new_entry = await self.async_session_store.switch_session(session_key, new_session_id) - if not new_entry: - return t("gateway.branch.switch_failed") - self._clear_session_boundary_security_state(session_key) - - # Evict any cached agent for this session - self._evict_cached_agent(session_key) - - msg_count = len([m for m in history if m.get("role") == "user"]) - key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" - return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id) async def _handle_topup_command(self, event: MessageEvent) -> str: """Handle /topup -- show the Nous balance and hand off to the portal. diff --git a/gateway/slash_commands_session.py b/gateway/slash_commands_session.py new file mode 100644 index 0000000000..9f6f3ca3a4 --- /dev/null +++ b/gateway/slash_commands_session.py @@ -0,0 +1,1526 @@ +"""Gateway slash commands that rotate, switch, fork or rewrite the session transcript: /new, /resume, /sessions, /branch, /title, /save, /undo, /retry, /topic, /compress. + +Split out of ``gateway/slash_commands.py``; bound onto ``GatewayRunner`` through +``GatewaySlashCommandsMixin``. Origin internals are imported lazily (``from gateway.slash_commands +import ...``) inside the bodies to avoid the import cycle. +""" + +from __future__ import annotations + +import logging +import asyncio +import contextlib +import dataclasses +import os +import shlex +from agent.i18n import t +from agent.turn_context import extract_api_content_sidecar +from gateway.config import Platform +from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType +from gateway.session import SessionSource, build_session_key, is_shared_multi_user_session +from typing import Optional, Union + +# Log-record parity with gateway/run.py and the origin module. +logger = logging.getLogger("gateway.run") + +# Upper bound on the off-loop agent-resource cleanup during a /new or /reset (see +# _handle_reset_command). A stuck teardown must not block the event loop; past this the reset +# proceeds and the cleanup is left to finish (or leak) in its worker thread. +_RESET_CLEANUP_TIMEOUT_S = 30.0 + + +def _manual_compression_reply_lines(summary: dict, compressor, focus_topic) -> list[str]: + """Lines for the manual /compress confirmation, surfacing summariser/aux-model failures. + + ``_last_compress_aborted`` = no usable summary, messages unchanged (force=True bypasses any + cooldown). Provider exception text is force-redacted at this UI boundary even when global + redaction is off. A configured aux model that failed and was recovered via main is an info + note so the user can fix their config. + """ + lines = [f"🗜️ {summary['headline']}"] + if focus_topic: + lines.append(t("gateway.compress.focus_line", topic=focus_topic)) + lines.append(summary["token_line"]) + if summary["note"]: + lines.append(summary["note"]) + summary_err = getattr(compressor, "_last_summary_error", None) + if summary_err: + from agent.redact import redact_sensitive_text + summary_err = redact_sensitive_text(summary_err, force=True) + aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None) + if getattr(compressor, "_last_compress_aborted", False): + lines.append(t("gateway.compress.aborted", error=(summary_err or "unknown error"))) + elif aux_fail_model: + lines.append( + t( + "gateway.compress.aux_failed", + model=aux_fail_model, + error=(getattr(compressor, "_last_aux_model_failure_error", None) or "unknown error"), + ) + ) + return lines + + +def _reset_process_scoped_tool_state() -> None: + """Drop env-passthrough and credential-file state at a conversation boundary (best-effort).""" + try: + from tools.env_passthrough import clear_env_passthrough + clear_env_passthrough() + except Exception: + pass + try: + from tools.credential_files import clear_credential_files + clear_credential_files() + except Exception: + pass + +_BRANCH_COPIED_FIELDS = ( + "content", "tool_calls", "tool_call_id", "finish_reason", "reasoning", "reasoning_content", + "reasoning_details", "codex_reasoning_items", "codex_message_items", "timestamp", +) + + +def _branch_row(msg: dict) -> dict: + """Transcript row copied into a /branch child. Keeps the api_content sidecar so the branch's + first turn replays the parent's exact wire bytes (warm provider prompt cache), not a cold prefill.""" + row = {k: msg.get(k) for k in _BRANCH_COPIED_FIELDS} + row["role"] = msg.get("role", "user") + row["tool_name"] = msg.get("tool_name") or msg.get("name") + row["api_content"] = extract_api_content_sidecar(msg) + return row + + +class GatewaySessionCommandsMixin: + """Gateway slash commands that rotate, switch, fork or rewrite the session transcript: /new, /resume, /sessions, /branch, /title, /save, /undo, /retry, /topic, /compress.""" + + async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: + """Handle /new or /reset command.""" + source = event.source + + # Get existing session key + session_key = self._session_key_for_source(source) + self._invalidate_session_run_generation(session_key, reason="session_reset") + # Evict the running-agent slot now that the generation is bumped: the in-flight run's own + # guarded release (old generation) returns False and would leave a zombie slot that silently + # drops all later messages. Idempotent, so the run's finally calling it again is harmless. + self._release_running_agent_state(session_key) + + # 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) + + # Close the old agent's tool resources (sandboxes, browser daemons, subprocesses) before + # evicting it; getattr-guarded since test fixtures may skip __init__. _cleanup_agent_resources + # is blocking and this handler runs ON the event loop (confirm-button click), so an inline + # call wedges the loop — offload to a worker thread with a bounded timeout. + _old_agent = self._cached_agent_for(session_key) + if _old_agent is not None: + try: + await asyncio.wait_for( + self._run_in_executor_with_context(self._cleanup_agent_resources, _old_agent), + timeout=_RESET_CLEANUP_TIMEOUT_S, + ) + except asyncio.TimeoutError: + # wait_for cancels the await, but the worker thread cannot be cancelled — a wedged + # teardown keeps running (or leaks) for the gateway's lifetime. The reset proceeds. + logger.warning( + "Agent resource cleanup for session %s exceeded %ss during " + "/new reset; proceeding with reset (the worker thread is left " + "to finish on its own). (#35994)", + session_key, _RESET_CLEANUP_TIMEOUT_S, + ) + except Exception as cleanup_exc: + logger.warning( + "Agent resource cleanup for session %s failed during /new " + "reset: %s (#35994)", + session_key, cleanup_exc, + ) + self._evict_cached_agent(session_key) + + # Conversation boundary: clear ALL conversation-scoped per-session state (model/reasoning + # overrides, one-turn restores, model notes, last-resolved cache, /queue overflow) + + # security state in one funnel call. See _CONVERSATION_SCOPED_STATE in gateway/run.py. + self._clear_conversation_scope(session_key, reason="session_reset") + + # The old conversation's in-flight async delegations end WITH it: once the session id rotates + # their completions have no live owner (orphaned payload on the shared queue, wasted tokens). + # Interrupt by expiring durable session id (parent_session_id), routing key as legacy fallback. + try: + from tools.async_delegation import interrupt_for_session + + interrupt_for_session( + session_key=session_key, + parent_session_id=str(getattr(old_entry, "session_id", "") or ""), + reason="session_reset", + ) + except Exception: + pass + _reset_process_scoped_tool_state() + + # Reset the session + new_entry = await self.async_session_store.reset_session(session_key) + + # (Conversation-scoped overrides + security state were already + # cleared via _clear_conversation_scope above.) + + _old_sid = old_entry.session_id if old_entry else None + platform_value = source.platform.value if source.platform else "" + + # Fire plugin on_session_finalize hook (session boundary). Off-loop + bounded: finalize + # hooks can block arbitrarily (observability trace exports) and this handler runs on the + # gateway event loop (see GatewayRunner._finalize_session_off_loop). + with contextlib.suppress(Exception): + await self._finalize_session_off_loop( + session_id=_old_sid, + platform=platform_value, + reason="new_session", + old_session_id=_old_sid, + new_session_id=new_entry.session_id if new_entry else None, + ) + + # Emit session:end (session is ending) then session:reset hooks. + hook_payload = {"platform": platform_value, "user_id": source.user_id, "session_key": session_key} + await self.hooks.emit("session:end", dict(hook_payload)) + await self.hooks.emit("session:reset", dict(hook_payload)) + + # Resolve session config info to surface to the user, scoped to the + # profile serving this source so a multiplexed /reset //new banner + # reports the profile's model, not the base config's (#59003). + try: + session_info = await asyncio.to_thread( + self._reset_notice_session_info, source + ) + except Exception: + session_info = "" + + if new_entry: + header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_default") + else: + # No existing session, just create one + new_entry = await self.async_session_store.get_or_create_session(source, force_new=True) + header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t("gateway.reset.header_new") + + # Set session title if provided with /new <title> + _title_arg = event.get_command_args().strip() + if _title_arg and self._session_db and new_entry: + header = await self._reset_titled_header(header, new_entry.session_id, _title_arg) + + # When /new runs inside a Telegram DM topic lane, rewrite the (chat_id, thread_id) → + # session_id binding so the next message uses the freshly-created session. Otherwise the + # binding-lookup at the top of _handle_message_with_agent switches right back to the old one. + if await asyncio.to_thread(self._is_telegram_topic_lane, source) and new_entry is not None: + try: + await asyncio.to_thread(self._record_telegram_topic_binding, source, new_entry) + except Exception: + logger.debug("Failed to rebind Telegram topic after /new", exc_info=True) + + # Fire plugin on_session_reset hook (new session guaranteed to exist) + try: + from hermes_cli.lifecycle import invoke_hook as _invoke_hook + _new_sid = new_entry.session_id if new_entry else None + _invoke_hook( + "on_session_reset", + session_id=_new_sid, + platform=platform_value, + reason="new_session", + old_session_id=_old_sid, + new_session_id=_new_sid, + ) + except Exception: + pass + + # Append a random tip to the reset message + try: + from hermes_cli.tips import get_random_tip + _tip_line = t("gateway.reset.tip", tip=get_random_tip()) + except Exception: + _tip_line = "" + + if session_info: + return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}") + return EphemeralReply(f"{header}{_tip_line}") + + async def _reset_titled_header(self, header: str, session_id: str, title_arg: str) -> str: + """Apply ``/new <title>``: titled header on success, else the header plus a rejection note.""" + from hermes_state import SessionDB + note = "" + try: + sanitized = SessionDB.sanitize_title(title_arg) + except ValueError as e: + sanitized = None + note = t("gateway.reset.title_rejected", error=str(e)) + if sanitized: + try: + await self._session_db.set_session_title(session_id, sanitized) + header = t("gateway.reset.header_titled", title=sanitized) + except ValueError as e: + note = t("gateway.reset.title_error_untitled", error=str(e)) + except Exception: + pass + elif not note: + # sanitize_title returned empty (whitespace-only / unprintable) + note = t("gateway.reset.title_empty_untitled") + return header + note + + def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]: + """Best-effort origin lookup for gateway session IDs.""" + lookup = getattr(type(self.session_store), "lookup_by_session_id", None) + if callable(lookup): + entry = lookup(self.session_store, session_id) + return getattr(entry, "origin", None) if entry is not None else None + + # Test doubles and older stores may not expose the public lookup helper. + # Keep the Matrix resume guard fail-closed if no origin can be resolved. + entries = getattr(self.session_store, "_entries", {}) or {} + for entry in entries.values(): + if getattr(entry, "session_id", None) == session_id: + return getattr(entry, "origin", None) + return None + + @staticmethod + def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool: + return ( + origin is not None + and origin.platform == Platform.MATRIX + and current.platform == Platform.MATRIX + and origin.chat_id == current.chat_id + # thread_id is part of the session key (build_session_key appends it for every chat + # type when present) and Matrix scopes a turn to the current room/thread, so a live + # session in another thread of the SAME room is a DIFFERENT session: thread A must not + # resume/enumerate a target from thread B. Non-threaded rooms compare "" == "" unchanged. + and str(getattr(current, "thread_id", "") or "") + == str(getattr(origin, "thread_id", "") or "") + ) + + def _same_origin_chat(self, current: SessionSource, origin: Optional[SessionSource]) -> bool: + """Platform-agnostic counterpart to ``_same_matrix_room``. + + Per-participant sessions (``build_session_key`` with the default ``group_sessions_per_user``) + must be participant-scoped here too, else a co-member could resume another member's live + session (IDOR). Only an explicitly shared group/thread (``is_shared_multi_user_session``) shares. + """ + if origin is None or current is None: + return False + if origin.platform != current.platform: + return False + if origin.chat_id != current.chat_id: + return False + # thread_id is part of the session key for every chat type (build_session_key appends it + # unconditionally), so threads of the same parent chat are DIFFERENT sessions. + # is_shared_multi_user_session only decides sharing WITHIN a thread — require thread equality + # before any sharing logic so a live origin in thread A cannot match a caller in thread B. + if str(getattr(current, "thread_id", "") or "") != str( + getattr(origin, "thread_id", "") or "" + ): + return False + chat_type = (getattr(current, "chat_type", "") or "").lower() + # DM-like chats are always per-user. + if chat_type in {"dm", "direct", "private", ""}: + # chat_id was already required equal above and, when present, IS the DM session key, so + # an equal non-empty chat_id suffices. build_session_key falls back to the participant + # (``user_id_alt or user_id`` — Signal/Feishu key on user_id_alt) only when there is NO + # chat_id; mirror that and fail closed on a missing/different participant so two + # no-chat_id DM origins are never conflated. + if str(getattr(current, "chat_id", "") or ""): + return True + cur_pid = str(current.user_id_alt or current.user_id or "") + org_pid = str(origin.user_id_alt or origin.user_id or "") + return bool(cur_pid) and cur_pid == org_pid + # Non-DM: scope by participant whenever the session key for this source + # is per-user. is_shared_multi_user_session mirrors build_session_key's + # isolation rules exactly, so the guard stays in lock-step with the key. + if self._is_shared_session_source(current): + return True + # Per-user key: compare the participant id the key is actually built + # from (user_id_alt or user_id — Signal/Feishu key on user_id_alt). + cur_pid = current.user_id_alt or current.user_id + org_pid = origin.user_id_alt or origin.user_id + if cur_pid and org_pid: + return cur_pid == org_pid + # Per-user key but a participant id is missing on one side: cannot prove + # the same owner — fail closed. + return False + + def _is_shared_session_source(self, source: SessionSource) -> bool: + """Whether *source*'s session key is shared by every participant (not per-user). + + Mirrors build_session_key's isolation rules exactly, so the guards stay in lock-step with the key. + """ + return 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), + ) + + def _resume_caller_is_admin(self, source: SessionSource) -> bool: + """Whether *source* is an EXPLICITLY-configured admin allowed cross-origin /resume or /sessions. + + Stricter than ``SlashAccessPolicy.is_admin()``, which returns True for every allowed caller + when slash gating is DISABLED; cross-origin DATA ACCESS needs a real configured admin, else + the default (no admin list) config would make every caller cross-origin-capable (IDOR). + """ + try: + from gateway.slash_access import policy_for_source + policy = policy_for_source(self.config, source) + uid = getattr(source, "user_id", None) + return bool(policy.enabled and uid and policy.is_admin(uid)) + except Exception: + return False + + async def _resume_target_allowed( + self, source: SessionSource, target_id: str, allow_override: bool = False + ) -> bool: + """Whether *source* may resume the persisted session *target_id*. + + Generalizes the Matrix-only room guard to every adapter so a caller cannot bind to another + user's/room's session (IDOR). Uses the live origin when the target is active, else the DB + row's source + user_id; the row must PROVE ownership or fail closed. Admin ``--all`` bypasses. + """ + if allow_override and self._resume_caller_is_admin(source): + return True + # Use the live origin only when it resolves to a real SessionSource; a + # store that can't resolve it (or an unexpected lookup error) must not + # silently allow/deny — fall through to the deterministic DB scoping. + try: + origin = self._gateway_session_origin_for_id(target_id) + except Exception: + origin = None + if isinstance(origin, SessionSource): + return self._same_origin_chat(source, origin) + # Inactive/persisted-only: best-effort scope by DB row source + user. + try: + row = await self._session_db.get_session(target_id) or {} + except Exception: + return False + caller_src = source.platform.value if source.platform else None + row_src = row.get("source") + if row_src and caller_src and str(row_src) != str(caller_src): + return False # different platform / source + caller_uid = str(getattr(source, "user_id", "") or "") + row_uid = str(row.get("user_id") or "") + # Chat/thread origin recorded at session creation. Rows once stored only source + user_id, + # so a same-user row could belong to a DIFFERENT chat; comparing the persisted origin closes + # that gap. Legacy rows (NULL) fail closed — resume via a live session or an admin override. + caller_chat = str(getattr(source, "chat_id", "") or "") + row_chat = str(row.get("chat_id") or "") + caller_thread = str(getattr(source, "thread_id", "") or "") + row_thread = str(row.get("thread_id") or "") + chat_type = (getattr(source, "chat_type", "") or "").lower() + caller_is_dm = chat_type in {"dm", "direct", "private", ""} + # build_session_key keys the participant on ``user_id_alt or user_id``, but the sessions table + # has no user_id_alt column, so a row cannot prove the canonical participant for an alt-keyed + # (Signal/Feishu) caller: per-user row_uid == caller_uid checks must fail closed (CWE-639). + caller_keys_on_alt = bool(str(getattr(source, "user_id_alt", "") or "")) + if caller_uid: + # Identity-bearing caller: the row must PROVE the same owner AND platform AND chat/thread. + # A blank/legacy source can't prove the platform (row_src above only rejects a *mismatching* + # non-blank one); a different thread is a different session. Any gap fails closed. + origin_ok = ( + bool(row_src) and bool(caller_src) + and str(row_src) == str(caller_src) + and row_thread == caller_thread + ) + if not origin_ok: + return False + if caller_is_dm: + # DMs are keyed on user_id; require the same owner. A no-chat_id DM is keyed PURELY on + # the participant (so an alt-keyed caller fails closed); when both sides carry chat_id, + # equality is the DM key and suffices, and a mismatching chat_id is rejected. + if caller_keys_on_alt and not (bool(row_chat) and bool(caller_chat)): + return False + return ( + bool(row_uid) and row_uid == caller_uid + and row_chat == caller_chat + ) + # Non-DM (group/channel/forum/thread): build_session_key includes chat_id, so a row (or + # caller) with NO chat provenance cannot prove same-chat. Require both non-blank and + # equal — a legacy NULL-chat row fails closed even when both normalize to "". (CWE-639) + if not (bool(row_chat) and bool(caller_chat) and row_chat == caller_chat): + return False + # Same non-DM chat/thread: mirror build_session_key's participant scoping. A SHARED + # group/thread session (group_sessions_per_user=False, or a shared thread) is one session + # for every participant, so the same-chat proof suffices — do NOT also require user-id + # equality (it would block co-members). A per-user session still requires the same owner. + if self._is_shared_session_source(source): + return True + # Per-user non-DM: the session key includes the participant (``user_id_alt or + # user_id``). If the caller keys on user_id_alt, the persisted row (user_id only) cannot + # prove the canonical participant, so fail closed rather than matching on user_id alone. + if caller_keys_on_alt: + return False + return bool(row_uid) and row_uid == caller_uid + # No caller identity: the row carries only source + user_id, so a same-platform row can belong + # to a DIFFERENT chat or user — same platform alone is NOT ownership proof; fail closed + # (CWE-639). Same-chat resume of an ACTIVE session still works via the live-origin branch. + return False + + async def _resume_row_visible( + self, source: SessionSource, row: dict, allow_all: bool + ) -> bool: + """Whether a titled-session listing *row* belongs to the caller's origin. + + Prevents cross-origin enumeration of session ids/previews via the numbered /resume list; + keeps Matrix room-scoping, scopes every other platform to the caller unless admin ``--all``. + """ + sid = str(row.get("id") or "") + if source.platform == Platform.MATRIX: + # Cross-room enumeration is cross-ORIGIN data access: gate the ``--all`` short-circuit + # behind a real configured admin, exactly like the non-Matrix branch below. + if allow_all and self._resume_caller_is_admin(source): + return True + return self._same_matrix_room(source, self._gateway_session_origin_for_id(sid)) + if allow_all and self._resume_caller_is_admin(source): + return True + return await self._resume_target_allowed(source, sid, allow_override=False) + + async def _handle_retry_command(self, event: MessageEvent) -> str: + """Handle /retry command - re-send the last user message.""" + source = event.source + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) + + # Find the last *real* user message. Timeline bookkeeping rows carry role=user + + # display_kind (model_switch / async_delegation_complete / auto_continue / hidden); clients + # never count them as user turns. + last_user_idx = None + # The canonical projection excludes bookkeeping and pure handoffs while + # still recognizing a real ask embedded in a compaction carrier. + from agent.context_compressor import ( + history_before_user_originated_turn, + retryable_user_text, + split_user_originated_turn, + user_originated_turn_view, + ) + + for i in range(len(history) - 1, -1, -1): + msg = history[i] + if user_originated_turn_view(msg) is not None: + last_user_idx = i + break + + if last_user_idx is None: + return t("gateway.retry.no_previous") + + # Resolve the live text and the scaffold-preserving prefix before any + # transcript write. Messaging retries cannot reconstruct attachments; + # reject media/unknown content without truncating the session. + try: + truncated, live_view = history_before_user_originated_turn( + history, last_user_idx + ) + last_user_msg = retryable_user_text(live_view.get("content")) + handoff, _ = split_user_originated_turn(history[last_user_idx]) + except ValueError as exc: + return f"Cannot retry that message safely: {exc}" + + if handoff is not None: + # A composite carrier is one physical row containing both the retained summary and the + # live ask. Let the carrier-aware rewind archive that row/tail and insert its pure + # scaffold atomically. + try: + rewind_result = await self.async_session_store.rewind_session( + session_entry.session_id, + 1, + require_retryable_composite=True, + ) + except ValueError as exc: + return f"Cannot retry that message safely: {exc}" + if rewind_result is None: + return "Retry failed; transcript was not changed." + # The store reselects and validates the latest carrier on the same + # snapshot used by the atomic rewind. A concurrent newer turn can + # therefore never be removed while this handler resends stale text. + last_user_msg = rewind_result["target_text"] + else: + # After in-place compaction the pre-compaction transcript lives on as + # active=0/compacted=1 rows under this session id. active_only preserves that archive; a + # separate existence probe could fail open or race with the write. + if not await self.async_session_store.rewrite_transcript( + session_entry.session_id, + truncated, + active_only=True, + reject_active_turn_lease=True, + ): + return "Retry failed; transcript was not changed." + # Reset stored token count — transcript was truncated + session_entry.last_prompt_tokens = 0 + + # Re-send by creating a fake text event with the old message + retry_event = MessageEvent( + text=last_user_msg, + message_type=MessageType.TEXT, + source=source, + raw_message=event.raw_message, + channel_prompt=event.channel_prompt, + ) + + # Let the normal message handler process it + return await self._handle_message(retry_event) + + async def _handle_undo_command(self, event: MessageEvent) -> str: + """Handle /undo [N] — back up N user turns (default 1), soft-deleting the truncated rows and + echoing the backed-up text. Evicts the cached agent so the next message rebuilds context + from the active-only transcript (gateway analogue of the CLI's history surgery). + """ + source = event.source + + # Parse optional turn count: "/undo" → 1, "/undo 3" → 3. + n = 1 + raw_args = event.get_command_args().strip() + if raw_args: + try: + n = int(raw_args.split()[0]) + except (ValueError, IndexError): + return t("gateway.undo.invalid_count", arg=raw_args.split()[0]) + if n < 1: + n = 1 + + session_entry = await self.async_session_store.get_or_create_session(source) + result = await self.async_session_store.rewind_session(session_entry.session_id, n) + + if result is None: + return t("gateway.undo.nothing") + + # Reset stored token count — transcript was truncated. + session_entry.last_prompt_tokens = 0 + # Evict the cached agent so the next turn rebuilds from the active-only + # transcript and memory providers refresh their per-session caches. + try: + session_key = build_session_key(source) + self._evict_cached_agent(session_key) + except Exception as e: + logger.debug("undo: cached-agent eviction skipped: %s", e) + + target_text = result["target_text"] + preview = target_text[:200] + "..." if len(target_text) > 200 else target_text + return t( + "gateway.undo.removed", + turns=result["turns_undone"], + count=result["rewound_count"], + preview=preview, + ) + + async def _handle_compress_command(self, event: MessageEvent) -> str: + """Profile-scoping wrapper around manual /compress. + + Multiplexed gateways resolve credentials through the fail-closed per-profile secret scope; + slash dispatch (unlike ``_run_agent``) does not install it, so an unscoped /compress would + raise ``UnscopedSecretError``. Single-profile gateways skip this. + """ + if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + return await self._handle_compress_command_inner(event) + + from gateway.run import _profile_runtime_scope + + profile_home = self._resolve_profile_home_for_source(event.source) + with _profile_runtime_scope(profile_home): + return await self._handle_compress_command_inner(event) + + async def _compress_codex_app_server_session( + self, session_key: str, session_id: str + ) -> str: + """Manual /compress for codex_app_server sessions. + + Compacts the LIVE cached agent's app-server thread (``thread/compact/start``, ``force=True`` + bypasses the ``codex_app_server_auto`` gate) and keeps the agent cached. Never builds a + temporary agent or rewrites the mirror: neither can shrink the server-side thread. + """ + from gateway.run import _AGENT_PENDING_SENTINEL + + agent = self._cached_agent_for(session_key) + if ( + agent is None + or agent is _AGENT_PENDING_SENTINEL + or getattr(agent, "_codex_session", None) is None + ): + return ( + "🗜️ Nothing to compact: this session runs on the Codex " + "app-server runtime, whose context lives in a Codex-owned " + "thread that only exists while the agent is active. Send a " + "message first, then /compress — or /reset to start fresh." + ) + + compressor = getattr(agent, "context_compressor", None) + count_before = getattr(compressor, "compression_count", 0) + try: + await self._run_in_executor_with_context( + lambda: agent._compress_context( + [], "", force=True, + ) + ) + except Exception as exc: + return t("gateway.compress.failed", error=exc) + count_after = getattr(compressor, "compression_count", 0) + if count_after > count_before: + return ( + "🗜️ Codex app-server thread compacted (thread/compact). " + "The transcript mirror is unchanged by design — the " + "app-server now carries the compacted context." + ) + return ( + "⚠️ Codex app-server compaction did not complete — the thread " + "is unchanged. Check the app-server logs, retry /compress, or " + "/reset for a clean session." + ) + + async def _handle_compress_command_inner(self, event: MessageEvent) -> str: + """Handle /compress command -- manually compress conversation context. + + Optional ``/compress <focus>`` tells the summariser what to preserve, discarding the rest. + """ + source = event.source + session_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(session_entry.session_id) + + if not history or len(history) < 4: + return t("gateway.compress.not_enough") + + # Parse args: either a focus topic (full compress) or the + # boundary-aware "here [N]" form (partial compress). + from hermes_cli.partial_compress import ( + extract_compress_flags, + parse_partial_compress_args, + rejoin_compressed_head_and_tail, + split_history_for_partial_compress, + summarize_compress_preview, + ) + from agent.conversation_compression import ( + finalize_context_engine_compression_notification, + ) + _raw_args = (event.get_command_args() or "").strip() + # Strip --preview/--dry-run/--aggressive before positional parsing + # so the flags coexist with 'here [N]' / focus-topic forms. + _raw_args, _preview, _aggressive = extract_compress_flags(_raw_args) + partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args) + + _agg_note = "" + if _aggressive: + # LLM-free hard truncation is not supported on this surface — it would need its own + # transcript-persistence branch outside the guarded _compress_context rotation machinery. + _agg_note = t("gateway.compress.aggressive_unsupported") + if not _preview: + return _agg_note + + if _preview: + # Report what WOULD be compressed — no agent, no writes. + from agent.model_metadata import estimate_request_tokens_rough + _pv_msgs = [ + {"role": m.get("role"), "content": m.get("content")} + for m in history + if m.get("role") in {"user", "assistant"} and m.get("content") + ] + report = summarize_compress_preview( + _pv_msgs, partial, keep_last, focus_topic, estimate_request_tokens_rough(_pv_msgs) + ) + lines = [f"🗜️ {line}" for line in report["lines"]] + if _aggressive: + lines.append(_agg_note) + return "\n".join(lines) + + try: + from agent.manual_compression_feedback import summarize_manual_compression + from agent.model_metadata import estimate_request_tokens_rough + from gateway.run import _platform_config_key + + session_key = self._session_key_for_source(source) + # Preserve the platform + stable gateway session identity of a normal turn so external + # context engines bind this agent to the original conversation, not a default "cli" host. + platform_key = ( + _platform_config_key(source.platform) if source.platform else None + ) + model, runtime_kwargs = self._resolve_session_agent_runtime( + source=source, + session_key=session_key, + ) + if str(runtime_kwargs.get("api_mode") or "").lower() == "codex_app_server": + # codex app-server: the model's context is the server-side thread owned by the LIVE + # cached agent; a temporary agent has none (and finally-eviction would destroy the + # real context). Compact the live thread and KEEP the agent cached; no mirror fallback. + return await self._compress_codex_app_server_session( + session_key, session_entry.session_id + ) + if not runtime_kwargs.get("api_key"): + return t("gateway.compress.no_provider") + + # Pass the FULL transcript (tool results included), like auto-compress: user/assistant- + # only starves tool-result pruning and can trip the protect-first/last early-return. + msgs = [ + m for m in history + if m.get("role") in {"user", "assistant", "tool"} + ] + + # Boundary-aware split: only the head is summarized; the most recent `keep_last` + # exchanges are preserved verbatim. The split snaps the tail to a user-turn start so the + # rejoined transcript keeps role alternation valid. + tail: list = [] + head = msgs + if partial: + head, tail = split_history_for_partial_compress(msgs, keep_last) + if not tail: + # Degenerate split — fall back to full compression. + partial = False + head = msgs + + # Bind the temporary compression agent to the source's platform + stable gateway session + # key. Assign directly (not setdefault: a resolver value would be a stale placeholder, + # and it avoids duplicate-kwarg TypeError); platform only when known so None -> "cli" holds. + if platform_key is not None: + runtime_kwargs["platform"] = platform_key + runtime_kwargs["gateway_session_key"] = session_key + + tmp_agent = await self._build_manual_compression_agent( + session_entry.session_id, model, runtime_kwargs + ) + try: + # Estimate with system prompt + tool schemas included so the figure reflects real + # request pressure, not a transcript-only underestimate. Must be computed after + # tmp_agent is built so _cached_system_prompt/tools are populated. + _sys_prompt = getattr(tmp_agent, "_cached_system_prompt", "") or "" + _tools = getattr(tmp_agent, "tools", None) or None + approx_tokens = estimate_request_tokens_rough( + msgs, system_prompt=_sys_prompt, tools=_tools + ) + + compressor = tmp_agent.context_compressor + if not compressor.has_content_to_compress(head): + return t("gateway.compress.nothing_to_do") + + # Not a bare run_in_executor: the profile secret scope is a contextvar and the + # default-executor hop would drop it, making the compressor's aux-client credential + # resolution fail closed under multiplexing. + compressed, _ = await self._run_in_executor_with_context( + lambda: tmp_agent._compress_context( + head, + "", + approx_tokens=approx_tokens, + focus_topic=focus_topic, + force=True, + defer_context_engine_notification=True, + ) + ) + + # If _compress_context returned unchanged because a concurrent compression lock is + # held, tell the user clearly instead of showing the misleading "No changes from + # compression" no-op text. + _lock_skipped = getattr(tmp_agent, "_compression_skipped_due_to_lock", None) + if _lock_skipped is True or isinstance(_lock_skipped, str): + from agent.manual_compression_feedback import ( + describe_compression_lock_skip, + ) + return describe_compression_lock_skip(_lock_skipped) + + if partial and tail: + compressed = rejoin_compressed_head_and_tail(compressed, tail) + + await self._persist_manual_compression(tmp_agent, session_entry, source, compressed) + finalize_context_engine_compression_notification( + tmp_agent, + committed=True, + ) + new_tokens = estimate_request_tokens_rough( + compressed, system_prompt=_sys_prompt, tools=_tools + ) + summary = summarize_manual_compression( + msgs, + compressed, + approx_tokens, + new_tokens, + compression_state=compressor, + ) + finally: + finalize_context_engine_compression_notification( + tmp_agent, + committed=False, + ) + # Evict cached agent so next turn rebuilds system prompt + # from current files (SOUL.md, memory, etc.). + self._evict_cached_agent(session_key) + # Off-loop + bounded: temporary-agent teardown can block on + # subprocess/network/SQLite work. + await self._cleanup_agent_resources_off_loop( + tmp_agent, context="manual compression" + ) + return "\n".join(_manual_compression_reply_lines(summary, compressor, focus_topic)) + except Exception as e: + logger.warning("Manual compress failed: %s", e) + return t("gateway.compress.failed", error=e) + + async def _build_manual_compression_agent(self, session_id: str, model, runtime_kwargs: dict): + """Build the throwaway AIAgent that performs a manual /compress rewrite of *session_id*.""" + from run_agent import AIAgent + from gateway.run import _GATEWAY_HYGIENE_PLATFORM, _seed_hygiene_system_prompt + + # The manual compression helper runs outside the live session's fully initialized prompt + # environment and _compress_context may persist its cached system prompt — restore the + # exact live-session prompt so provider blocks are retained. + session_row = None + get_session = getattr(self._session_db, "get_session", None) + if callable(get_session): + try: + session_row = await get_session(session_id) + except Exception as exc: + logger.warning( + "Manual compression could not restore the system prompt " + "for session %s: %s. Preserving an empty prompt so the " + "live turn rebuilds it with its configured providers.", + session_id, + exc, + exc_info=True, + ) + + # This agent performs a lossy rewrite. When compression.checkpoint_required is on, the + # memory provider must be loaded so _compress_context() can write the pre-compression + # checkpoint; otherwise keep the historical fast path (no provider init). + from hermes_cli.config import load_config as _load_cfg + from utils import is_truthy_value as _is_truthy + + _checkpoint_required = _is_truthy( + ((_load_cfg() or {}).get("compression") or {}).get("checkpoint_required"), + default=False, + ) + tmp_agent = AIAgent( + **runtime_kwargs, + model=model, + max_iterations=4, + quiet_mode=True, + skip_memory=not _checkpoint_required, + enabled_toolsets=["memory"], + session_id=session_id, + session_db=getattr(self._session_db, "_db", self._session_db), + ) + _seed_hygiene_system_prompt(tmp_agent, session_row) + # Keep the real source platform during construction so external context engines bind + # correctly. If compression has to rebuild the prompt, stamp that provider-less fallback + # as stale for the next real gateway turn. + tmp_agent.platform = _GATEWAY_HYGIENE_PLATFORM + tmp_agent._print_fn = lambda *a, **kw: None + # Prevent close() from ending the newly rotated session — the gateway session entry now + # points at the new id and must remain open for the next user turn. + tmp_agent._end_session_on_close = False + return tmp_agent + + async def _persist_manual_compression(self, tmp_agent, session_entry, source, compressed) -> None: + """Commit a manual /compress result to the session store. + + _compress_context either rotated (new continuation id — write compressed messages into the + NEW session so the original stays searchable) or compacted in place (compression.in_place: + same id, transcript replaced). Persist BEFORE repointing the live session: repoint first + + failed DB write would leave the entry on an empty session while reporting success; a failed + write is fatal so old history stays reachable. Only rewrite when rotation produced a NEW id: + in-place compaction already archived + inserted rows and rewrite_transcript() + (active_only=False) would DELETE the archived turns; an unchanged id without in-place means + rotation FAILED and a rewrite would leave only the summary. + """ + new_session_id = tmp_agent.session_id + if new_session_id != session_entry.session_id: + if not await self.async_session_store.rewrite_transcript(new_session_id, compressed): + raise RuntimeError( + f"failed to persist compressed transcript for session {new_session_id}" + ) + session_entry.session_id = new_session_id + await self.async_session_store._save() + await asyncio.to_thread( + self._sync_telegram_topic_binding, + source, session_entry, reason="compress-command", + ) + elif not getattr(tmp_agent, "_last_compaction_in_place", False): + logger.warning( + "Manual /compress: session rotation did not occur " + "(session_id unchanged) and in-place mode is off — " + "preserving original transcript instead of overwriting " + "it (#44794)." + ) + # Reset stored token count — transcript changed, old value is stale + await self.async_session_store.update_session(session_entry.session_key, last_prompt_tokens=0) + + async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str: + """Handle /topic for Telegram DM user-managed topic sessions.""" + source = event.source + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return t("gateway.topic.not_telegram_dm") + if not self._session_db: + return self._session_db_unavailable_reply() + + # Authorization: /topic activates multi-session mode and mutates SQLite side tables. + # Unauthorized senders (not in allowlist) must not be able to do that. Gateway routes + # already authorize the message before reaching here, but defense in depth. + auth_fn = getattr(self, "_is_user_authorized", None) + if callable(auth_fn): + try: + if not auth_fn(source): + return t("gateway.topic.unauthorized") + except Exception: + logger.debug("Topic auth check failed", exc_info=True) + + args = event.get_command_args().strip() + + # /topic help — inline usage without leaving the bot. + if args.lower() in {"help", "?", "-h", "--help"}: + return self._telegram_topic_help_text() + + # /topic off — clean disable path so users don't have to edit the DB. + if args.lower() in {"off", "disable", "stop"}: + return await self._disable_telegram_topic_mode_for_chat(source) + + if args: + if not source.thread_id: + return t("gateway.topic.restore_needs_topic") + return await self._restore_telegram_topic_session(event, args) + + capabilities = await self._get_telegram_topic_capabilities(source) + if capabilities.get("checked"): + if capabilities.get("has_topics_enabled") is False: + # Debounce the BotFather screenshot: don't re-send on every + # /topic while threads are still disabled. + if self._should_send_telegram_capability_hint(source): + await self._send_telegram_topic_setup_image(source) + return t("gateway.topic.topics_disabled") + if capabilities.get("allows_users_to_create_topics") is False: + if self._should_send_telegram_capability_hint(source): + await self._send_telegram_topic_setup_image(source) + return t("gateway.topic.topics_user_disallowed") + + try: + await self._session_db.enable_telegram_topic_mode( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + profile_name=self._telegram_topic_profile_name(source), + has_topics_enabled=capabilities.get("has_topics_enabled"), + allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"), + ) + except Exception as exc: + logger.exception("Failed to enable Telegram topic mode") + return t("gateway.topic.enable_failed", error=exc) + + if not source.thread_id: + await self._ensure_telegram_system_topic(source) + + if source.thread_id: + try: + binding = await self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + profile_name=self._telegram_topic_profile_name(source), + ) + except Exception: + logger.debug("Failed to read Telegram topic binding", exc_info=True) + binding = None + if binding: + session_id = str(binding.get("session_id") or "") + title = None + try: + title = await self._session_db.get_session_title(session_id) + except Exception: + title = None + session_label = title or t("gateway.topic.untitled_session") + return t( + "gateway.topic.bound_status", + label=session_label, + session_id=session_id, + ) + return t("gateway.topic.thread_ready") + + return await self._telegram_topic_root_status_message(source) + + async def _handle_save_command(self, event: MessageEvent) -> str: + """Handle /save — export the current session and send it as a document.""" + from hermes_cli.session_export import ( + SAVE_USAGE, + default_save_filename, + normalize_save_format, + render_session_for_save, + ) + + parts = event.get_command_args().split() + if not parts: + return SAVE_USAGE + redact = False + if parts[-1].lower() in ("redact", "--redact"): + redact = True + parts = parts[:-1] + if not parts: + return SAVE_USAGE + + try: + fmt = normalize_save_format(parts[0]) + except ValueError as e: + return f"{e}\n\n{SAVE_USAGE}" + + source = event.source + session_entry = await self.async_session_store.get_or_create_session(source) + session_id = session_entry.session_id + + if not self._session_db: + return "Session database not available." + filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt) + # The filename is echoed to the platform only — never trust path + # separators from chat input. + filename = os.path.basename(filename) or default_save_filename(session_id, fmt) + + # self._session_db is an AsyncSessionDB — every forwarded call is + # offloaded to a thread and must be awaited. + export_data = await self._session_db.export_session(session_id) + if not export_data: + return f"No stored messages found for this session ({session_id})." + + if redact: + from hermes_cli.session_export_md import redact_session_data + + export_data = redact_session_data(export_data) + + import tempfile + + temp_dir = tempfile.mkdtemp(prefix="hermes_save_") + temp_path = os.path.join(temp_dir, filename) + try: + # Off-loop: rendering a long session and writing it to disk are CPU/disk-bound and scale + # with transcript size (multi-MB for long sessions). Inline they stall every other chat + # on the gateway event loop (Pattern A). One thread hop covers both. + def _render_and_write() -> None: + rendered = render_session_for_save(export_data, fmt) + with open(temp_path, "w", encoding="utf-8") as f: + f.write(rendered) + + await asyncio.to_thread(_render_and_write) + + adapter = self.get_adapter(source.platform) + if adapter: + await adapter.send_document( + chat_id=source.chat_id, + file_path=temp_path, + caption=f"Session export: {filename}", + file_name=filename, + ) + return "Export complete." + return "Platform adapter not found to send the document." + except Exception as e: + logger.warning("Session /save failed: %s", e) + return f"Error exporting session: {e}" + finally: + try: + os.remove(temp_path) + os.rmdir(temp_dir) + except Exception: + pass + + async def _handle_title_command(self, event: MessageEvent) -> str: + """Handle /title command — set or show the current session's title.""" + source = event.source + session_entry = await self.async_session_store.get_or_create_session(source) + session_id = session_entry.session_id + + if not self._session_db: + return self._session_db_unavailable_reply() + + # Ensure session exists in SQLite DB (it may only exist in session_store + # if this is the first command in a new session) + existing_title = await self._session_db.get_session_title(session_id) + if existing_title is None: + # Session doesn't exist in DB yet — create it + try: + await self._session_db.create_session( + session_id=session_id, + source=source.platform.value if source.platform else "unknown", + user_id=source.user_id, + # Persist the messaging origin so a later /resume of this + # titled-but-now-inactive session can prove it belongs to the + # caller's chat/thread (IDOR scoping). + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + ) + except Exception: + pass # Session might already exist, ignore errors + + title_arg = event.get_command_args().strip() + if title_arg: + # Sanitize the title before setting + try: + from hermes_state import SessionDB + sanitized = SessionDB.sanitize_title(title_arg) + except ValueError as e: + return t("gateway.shared.warn_passthrough", error=e) + if not sanitized: + return t("gateway.title.empty_after_clean") + # Set the title + try: + if await self._session_db.set_session_title(session_id, sanitized): + # Propagate the user-chosen title to the visible Telegram forum topic name too. + # Auto-generated titles already rename the topic; without this, /title only + # updated the DB title and the topic kept its auto-assigned name. + schedule_rename = getattr( + self, "_schedule_telegram_topic_title_rename", None + ) + if callable(schedule_rename): + try: + await asyncio.to_thread(schedule_rename, source, session_id, sanitized) + except Exception: + logger.debug( + "Failed to rename Telegram topic from /title", + exc_info=True, + ) + return t("gateway.title.set_to", title=sanitized) + else: + return t("gateway.title.not_found") + except ValueError as e: + return t("gateway.shared.warn_passthrough", error=e) + else: + # Show the current title and session ID + title = await self._session_db.get_session_title(session_id) + if title: + return t("gateway.title.current_with_title", session_id=session_id, title=title) + else: + return t("gateway.title.current_no_title", session_id=session_id) + + async def _handle_resume_command(self, event: MessageEvent) -> str: + """Handle /resume command — list or switch to a previous session.""" + if not self._session_db: + return self._session_db_unavailable_reply() + + source = await asyncio.to_thread( + self._normalize_source_for_session_key, event.source + ) + session_key = self._session_key_for_source(source) + raw_args = event.get_command_args().strip() + try: + parts = shlex.split(raw_args) + except ValueError as exc: + return t("gateway.resume.parse_error", error=exc) + allow_all = "--all" in parts + allow_cross_room = "--cross-room" in parts + name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip() + + # Strip common outer brackets/quotes users may type literally from the + # usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior. + if len(name) >= 2 and ( + (name[0] == "<" and name[-1] == ">") + or (name[0] == "[" and name[-1] == "]") + or (name[0] == '"' and name[-1] == '"') + or (name[0] == "'" and name[-1] == "'") + ): + name = name[1:-1].strip() + + async def _list_titled_sessions() -> list[dict]: + """Titled sessions visible to the caller (origin-scoped unless admin ``--all``).""" + user_source = source.platform.value if source.platform else None + widen = allow_all and self._resume_caller_is_admin(source) + sessions = await self._session_db.list_sessions_rich( + source=user_source, + session_key=None if widen else session_key, + limit=10, + ) + titled = [s for s in sessions if s.get("title")][:10] + return [s for s in titled if await self._resume_row_visible(source, s, allow_all)] + + if not name: + # List recent titled sessions for this user/platform + try: + titled = await _list_titled_sessions() + return self._resume_listing_reply(source, titled, allow_all) + except Exception as e: + logger.debug("Failed to list titled sessions: %s", e) + return t("gateway.resume.list_failed", error=e) + + # Resolve a numbered choice or a title to a session ID. + if name.isdigit(): + try: + titled = await _list_titled_sessions() + except Exception as e: + logger.debug("Failed to list titled sessions for numeric resume: %s", e) + return t("gateway.resume.list_failed", error=e) + index = int(name) + if index < 1 or index > len(titled): + return t("gateway.resume.out_of_range", index=index) + target = titled[index - 1] + target_id = target.get("id") + name = target.get("title") or name + else: + # Try direct session ID lookup first (so `/resume <session_id>` + # works in the gateway, not just `/resume <title>`). + session = await self._session_db.get_session(name) + if session: + target_id = session["id"] + else: + target_id = await self._session_db.resolve_session_by_title(name) + if not target_id: + return t("gateway.resume.not_found", name=name) + # Compression creates child continuations that hold the live transcript. + # Follow that chain so gateway /resume matches CLI behavior (#15000). + try: + target_id = await 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) + + if source.platform == Platform.MATRIX: + target_origin = self._gateway_session_origin_for_id(target_id) + if not self._same_matrix_room(source, target_origin) and not allow_cross_room: + if target_origin is None: + return t("gateway.resume.matrix_blocked_no_origin", name=name) + return t( + "gateway.resume.matrix_blocked_other_room", + room=target_origin.chat_name or target_origin.chat_id, + name=name, + ) + elif not await self._resume_target_allowed( + source, target_id, allow_override=(allow_all or allow_cross_room) + ): + # IDOR guard: a session id/title is a routing handle, not authority. Bind /resume to the + # caller's own platform/user/chat on every non-Matrix adapter so one user can't attach + # to another's persisted transcript. + return t("gateway.resume.blocked_not_owner", name=name) + + # Check if already on that session + current_entry = await self.async_session_store.get_or_create_session(source) + if current_entry.session_id == target_id: + return t("gateway.resume.already_on", name=name) + + # Clear any running agent for this session key + self._release_running_agent_state(session_key) + + # Switch the session entry to point at the old session + new_entry = await self.async_session_store.switch_session(session_key, target_id) + if not new_entry: + return t("gateway.resume.switch_failed") + + # Conversation boundary: clear ALL conversation-scoped per-session state (model/reasoning + # overrides #10702, one-turn restores, model notes, last-resolved cache #58403, /queue + # overflow) + security state in one funnel call. + self._clear_conversation_scope(session_key, reason="resume") + + # Evict any cached agent for this session so the next message rebuilds with the correct + # session_id end-to-end — mirrors /branch and /reset. Otherwise the cached AIAgent (and its + # memory provider, which cached _session_id at initialize()) keeps writing to the wrong session. + self._evict_cached_agent(session_key) + + # Get the title for confirmation + title = await self._session_db.get_session_title(target_id) or name + + # Count messages for context + history = await self.async_session_store.load_transcript(target_id) + msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 + msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" + + if source.platform == Platform.MATRIX and allow_cross_room: + return t( + "gateway.resume.matrix_cross_room_success", + title=title, + room=source.chat_name or source.chat_id, + msg_part=msg_part, + ) + if not msg_count: + return t("gateway.resume.resumed_no_count", title=title) + if msg_count == 1: + return t("gateway.resume.resumed_one", title=title, count=msg_count) + return t("gateway.resume.resumed_many", title=title, count=msg_count) + + def _resume_listing_reply(self, source, titled: list[dict], allow_all: bool) -> str: + """Numbered /resume list. A non-admin ``--all`` silently falls back to same-origin scoping; + say so instead of rendering an unexplained narrower list (sibling of the /sessions notice).""" + scope_note = ( + t("gateway.resume.all_requires_admin") + if allow_all and not self._resume_caller_is_admin(source) + else None + ) + if not titled: + if source.platform == Platform.MATRIX and not allow_all: + return t("gateway.resume.matrix_no_named_sessions") + base = t("gateway.resume.no_named_sessions") + return f"{base}\n{scope_note}" if scope_note else base + lines = [t("gateway.resume.list_header")] + for idx, s in enumerate(titled[:10], start=1): + title = s["title"] + if source.platform == Platform.MATRIX and allow_all: + origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) + if origin: + title = f"{title} — {origin.chat_name or origin.chat_id}" + preview = s.get("preview", "")[:40] + preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" + lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) + if scope_note: + lines.append(scope_note) + lines.append(t("gateway.resume.list_footer_numbered")) + return "\n".join(lines) + + async def _handle_sessions_command(self, event: MessageEvent) -> str: + """Handle /sessions — list previous sessions for gateway chats.""" + if not self._session_db: + return self._session_db_unavailable_reply() + + from hermes_cli.session_listing import ( + format_gateway_session_listing, + parse_session_listing_args, + query_session_listing, + ) + + raw_args = event.get_command_args().strip() + try: + include_all, include_unnamed, target, search_query = ( + parse_session_listing_args(raw_args) + ) + except ValueError as exc: + return t("gateway.resume.parse_error", error=exc) + + if search_query == "": + return "Usage: `/sessions search <query>`" + + if target: + resume_event = dataclasses.replace(event, text=f"/resume {target}") + return await self._handle_resume_command(resume_event) + + source = await asyncio.to_thread( + self._normalize_source_for_session_key, event.source + ) + session_key = self._session_key_for_source(source) + + # A cross-origin listing (`/sessions all`) is honored only for an admin, mirroring the + # `/resume --all` override. `all` is just a parsed user argument; ungated, any caller could + # enumerate other origins' session ids/titles/previews — the enumeration half of the IDOR. + cross_origin = include_all and self._resume_caller_is_admin(source) + # Don't silently no-op a requested widening: a non-admin `/sessions all` + # used to render the same scoped list with zero feedback, which reads + # as "my session vanished" (community report, Aug 2026). + scope_notice = None + if include_all and not cross_origin: + scope_notice = ( + "_Note: `all` (cross-chat listing) requires a configured admin; " + "showing this chat's sessions only._" + ) + current_entry = await self.async_session_store.get_or_create_session(source) + rows = await asyncio.to_thread( + query_session_listing, + getattr(self._session_db, "_db", self._session_db), + source=source.platform.value if source.platform else None, + session_key=None if cross_origin else session_key, + current_session_id=current_entry.session_id, + include_current_session=True, + include_all_sources=cross_origin, + include_unnamed=include_unnamed, + search_query=search_query, + # Search filters at SQL level, so over-fetch before the visibility + # cut: origin-invisible matches would otherwise consume the page. + limit=50 if search_query else 10, + exclude_sources=["tool"], + ) + if not cross_origin: + # Scope the listing to the caller's own origin on every adapter so + # session ids/previews from other users/rooms aren't enumerable. + rows = [ + row for row in rows + if await self._resume_row_visible(source, row, allow_all=False) + ] + rows = rows[:10] + if search_query: + title = f"Sessions matching “{search_query}”" + else: + title = "Sessions" if include_unnamed else "Named Sessions" + return format_gateway_session_listing( + rows, + include_source=cross_origin, + title=title, + notice=scope_notice, + ) + + async def _handle_branch_command(self, event: MessageEvent) -> str: + """Handle /branch [name] — fork the current session into a new independent copy so the + user can explore a different approach without losing the original. + """ + import uuid as _uuid + + if not self._session_db: + return self._session_db_unavailable_reply() + + source = event.source + session_key = self._session_key_for_source(source) + + # Load the current session and its transcript + current_entry = await self.async_session_store.get_or_create_session(source) + history = await self.async_session_store.load_transcript(current_entry.session_id) + if not history: + return t("gateway.branch.no_conversation") + + branch_name = event.get_command_args().strip() + + # Generate the new session ID + from datetime import datetime as _dt + now = _dt.now() + timestamp_str = now.strftime("%Y%m%d_%H%M%S") + short_uuid = _uuid.uuid4().hex[:6] + new_session_id = f"{timestamp_str}_{short_uuid}" + + # Determine branch title + if branch_name: + branch_title = branch_name + else: + current_title = await self._session_db.get_session_title(current_entry.session_id) + base = current_title or "branch" + branch_title = await self._session_db.get_next_title_in_lineage(base) + + parent_session_id = current_entry.session_id + + # Serialize the parent's full origin (same shape as the reset path's db_create_kwargs in + # gateway/session.py, #82633) so the branch row carries complete identity from birth. Prefer + # the live entry's origin (it may hold richer metadata than the triggering event's source). + _branch_origin = current_entry.origin or source + _branch_origin_json = None + if _branch_origin is not None: + try: + import json as _json + + _branch_origin_json = _json.dumps(_branch_origin.to_dict()) + except Exception: + _branch_origin_json = None + + # Create the new session with parent link. Persist a stable ``_branched_from`` marker in + # model_config so list_sessions_rich() keeps the branch visible in /resume and /sessions + # even after the parent is reopened and re-ended with a different end_reason. + try: + await self._session_db.create_session( + session_id=new_session_id, + source=source.platform.value if source.platform else "gateway", + model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, + model_config={"_branched_from": parent_session_id}, + parent_session_id=parent_session_id, + # Forward ALL gateway routing columns at CREATE time: otherwise they're NULL until + # switch_session() calls _record_gateway_session_peer(), and a crash in between (each + # append_message is best-effort) leaves the branch unroutable — by chat/thread lookup + # and by /resume's IDOR guard. user_id feeds the full-peer-tuple fallback lookup; + # origin_json/display_name complete the identity (same shape as session.py's reset + # path) so state.db consumers see a fully formed row with no backfill gap. + user_id=source.user_id, + session_key=session_key, + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + origin_json=_branch_origin_json, + display_name=current_entry.display_name, + ) + except Exception as e: + logger.error("Failed to create branch session: %s", e) + return t("gateway.branch.create_failed", error=e) + + # Copy conversation history to the new session in bounded-chunk transactions: one txn per + # row was the removed write-amplification pattern, and a history can be hundreds of rows. + # Best-effort like the old loop — a failed copy still yields a usable (partial) branch. + try: + await self._session_db.append_messages_batch( + new_session_id, [_branch_row(msg) for msg in history], chunk_rows=500, + ) + except Exception: + pass # Best-effort copy + + # Set title + with contextlib.suppress(Exception): + await self._session_db.set_session_title(new_session_id, branch_title) + + # Switch the session store entry to the new session + new_entry = await self.async_session_store.switch_session(session_key, new_session_id) + if not new_entry: + return t("gateway.branch.switch_failed") + self._clear_session_boundary_security_state(session_key) + + # Evict any cached agent for this session + self._evict_cached_agent(session_key) + + msg_count = len([m for m in history if m.get("role") == "user"]) + key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" + return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)