aed6720dab
run.py: - built-in adapter creation: 9-branch if/elif -> _BUILTIN_ADAPTERS table - idle slash-command routing: 35 `if canonical == ...` branches -> _gateway_idle_command_handlers() - shared helpers: _send_command_ack (4 sites), _command_origin_for_source (2), _session_entry_for_manager (goal/heartbeat), _toggle_adapter_auto_tts_set (2), _load_env_or_agent_cfg_timeout (2), _float_env reuse (2), _resolve_session_key_or_none (3), _running_agent_ids (4), _schedule_rename_from_title_thread (2), _write_runtime_status_quiet (5), _AUTO_RESET_CONTEXT_NOTES/_auto_reset_reason_text - ruff SIM102/SIM103/SIM105/SIM108/SIM118 + F401 across gateway/ (semantics re-reviewed; sqlite Row `.keys()` and side-effecting assignments kept) - two hand-reviewed comment/docstring compaction passes (AST-identical, rationale kept) slash_commands.py: - /model: typed path and picker callback shared one 200-line commit block -> _perform_model_switch + _commit_model_switch - comment/docstring compaction (AST-identical)
5805 lines
265 KiB
Python
5805 lines
265 KiB
Python
"""Gateway slash-command handlers for GatewayRunner.
|
|
|
|
The in-session slash commands (/model, /reset, /usage, ...) dispatched from ``_handle_message``,
|
|
lifted out of ``gateway/run.py`` into a mixin so every ``self._handle_*_command`` reference keeps
|
|
working via the MRO. run.py helpers (``_hermes_home``, ``_load_gateway_config``, ...) are imported
|
|
lazily inside handler bodies — a deferred ``from gateway.run import ...`` avoids the import cycle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import hashlib
|
|
import inspect
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
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, clear_model_endpoint_credentials
|
|
from utils import (
|
|
atomic_json_write,
|
|
base_url_host_matches,
|
|
is_truthy_value,
|
|
)
|
|
import contextlib
|
|
|
|
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 _clean_str(value: Any) -> str:
|
|
"""Strip and return a non-empty string value, or empty string."""
|
|
return value.strip() if isinstance(value, str) and value.strip() else ""
|
|
|
|
|
|
def _int_value(value: Any) -> int:
|
|
"""Safely coerce to int."""
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _model_switch_skew_guard() -> Optional[str]:
|
|
"""Refuse a model switch when the gateway is running stale code.
|
|
|
|
A long-lived gateway keeps boot-time modules in memory; if the checkout changed underneath it,
|
|
a first-time lazy import on a new code path can crash on a stale cached dependency. Detect the
|
|
drift and ask for a restart. Scoped to model switching only (the highest-risk trigger).
|
|
"""
|
|
from gateway.code_skew import detect_code_skew
|
|
|
|
skew = detect_code_skew()
|
|
if not skew:
|
|
return None
|
|
boot_rev, disk_rev = skew
|
|
return t(
|
|
"gateway.model.error_prefix",
|
|
error=(
|
|
f"This gateway is running code from {boot_rev} but the checkout on "
|
|
f"disk is now {disk_rev}. Switching models would risk a stale-module "
|
|
f"crash — restart the gateway to load the new code: hermes gateway restart"
|
|
),
|
|
)
|
|
|
|
|
|
def _home_thread_from_source(source) -> Optional[str]:
|
|
"""The thread id /sethome should persist on the home target, or None.
|
|
|
|
Slack thread-per-message keying stamps a top-level message's own id as ``source.thread_id`` (a
|
|
session key, not a location); persisting it would pin HOME to that ephemeral thread. A thread
|
|
id equal to the message's own id is synthetic and dropped; a real thread (id = parent's) is kept.
|
|
"""
|
|
thread_id = getattr(source, "thread_id", None)
|
|
if not thread_id:
|
|
return None
|
|
if (
|
|
getattr(source, "platform", None) == Platform.SLACK
|
|
and getattr(source, "message_id", None)
|
|
and str(thread_id) == str(source.message_id)
|
|
):
|
|
return None
|
|
return str(thread_id)
|
|
|
|
|
|
class GatewaySlashCommandsMixin:
|
|
"""In-session slash-command handlers for GatewayRunner."""
|
|
|
|
async_session_store: AsyncSessionStore
|
|
|
|
def _typed_command_prefix_for(self, platform) -> str:
|
|
"""Return the prefix users can always type to reach Hermes commands.
|
|
|
|
Adapter ``typed_command_prefix`` capability (default "/"). Slack and Matrix use "!" because
|
|
typed "/" is blocked/reserved there; their adapters rewrite "!command" to "/command".
|
|
"""
|
|
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.
|
|
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
if _cache_lock is not None:
|
|
with _cache_lock:
|
|
_cached = self._agent_cache.get(session_key)
|
|
_old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None
|
|
if _old_agent is not None:
|
|
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 regardless.
|
|
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
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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=source.platform.value if source.platform else "",
|
|
reason="new_session",
|
|
old_session_id=_old_sid,
|
|
new_session_id=new_entry.session_id if new_entry else None,
|
|
)
|
|
|
|
# Emit session:end hook (session is ending)
|
|
await self.hooks.emit("session:end", {
|
|
"platform": source.platform.value if source.platform else "",
|
|
"user_id": source.user_id,
|
|
"session_key": session_key,
|
|
})
|
|
|
|
# Emit session:reset hook
|
|
await self.hooks.emit("session:reset", {
|
|
"platform": source.platform.value if source.platform else "",
|
|
"user_id": source.user_id,
|
|
"session_key": session_key,
|
|
})
|
|
|
|
# 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()
|
|
_title_note = ""
|
|
if _title_arg and self._session_db and new_entry:
|
|
from hermes_state import SessionDB
|
|
try:
|
|
sanitized = SessionDB.sanitize_title(_title_arg)
|
|
except ValueError as e:
|
|
sanitized = None
|
|
_title_note = t("gateway.reset.title_rejected", error=str(e))
|
|
if sanitized:
|
|
try:
|
|
await self._session_db.set_session_title(new_entry.session_id, sanitized)
|
|
header = t("gateway.reset.header_titled", title=sanitized)
|
|
except ValueError as e:
|
|
_title_note = t("gateway.reset.title_error_untitled", error=str(e))
|
|
except Exception:
|
|
pass
|
|
elif not _title_note:
|
|
# sanitize_title returned empty (whitespace-only / unprintable)
|
|
_title_note = t("gateway.reset.title_empty_untitled")
|
|
header = header + _title_note
|
|
|
|
# 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=source.platform.value if source.platform else "",
|
|
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 _handle_profile_command(self, event: MessageEvent) -> str:
|
|
"""Handle /profile — show the profile serving this source and its home.
|
|
|
|
On a multiplexed gateway the process-level active profile is the multiplexer's own, so it
|
|
would read "default" in every chat. With ``multiplex_profiles`` on, report ``source.profile``
|
|
and resolve home under that profile's runtime scope (like the scoped /reset banner); when
|
|
off the stamp is ignored, mirroring ``_run_agent``.
|
|
"""
|
|
from hermes_constants import display_hermes_home
|
|
from hermes_cli.slash_exec import CommandContext, execute_command
|
|
|
|
multiplexed = getattr(
|
|
getattr(self, "config", None), "multiplex_profiles", False
|
|
)
|
|
source = getattr(event, "source", None)
|
|
|
|
profile_name = ""
|
|
display = ""
|
|
if multiplexed:
|
|
profile_name = (getattr(source, "profile", "") or "").strip()
|
|
try:
|
|
from gateway.run import _profile_runtime_scope
|
|
|
|
profile_home = self._resolve_profile_home_for_source(source)
|
|
with _profile_runtime_scope(profile_home):
|
|
display = display_hermes_home()
|
|
except Exception:
|
|
display = display_hermes_home()
|
|
|
|
# Shared executor resolves process-level fallbacks; the multiplexed
|
|
# per-source overrides (when any) ride in via options.
|
|
reply = execute_command(
|
|
"profile",
|
|
CommandContext(
|
|
surface="gateway",
|
|
options={"profile_name": profile_name, "home_display": display},
|
|
),
|
|
)
|
|
|
|
lines = [
|
|
t("gateway.profile.header", profile=reply.data["profile"]),
|
|
t("gateway.profile.home", home=reply.data["home"]),
|
|
]
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_whoami_command(self, event: MessageEvent) -> str:
|
|
"""Handle /whoami — show the user's slash command access on this scope.
|
|
|
|
Always allowed (slash_access floor). Reports platform, DM-vs-group scope, tier, and the
|
|
commands the user can actually run here.
|
|
"""
|
|
from gateway.slash_access import policy_for_source as _policy_for_source
|
|
|
|
source = event.source
|
|
policy = _policy_for_source(self.config, source)
|
|
platform = source.platform.value if source and source.platform else "?"
|
|
chat_type = (source.chat_type if source else "") or "dm"
|
|
scope = "DM" if chat_type.lower() in {"dm", "direct", "private", ""} else "group/channel"
|
|
user_id = (source.user_id if source else None) or "?"
|
|
|
|
if not policy.enabled:
|
|
return (
|
|
f"**You** — {platform} ({scope})\n"
|
|
f"User ID: `{user_id}`\n"
|
|
f"Tier: unrestricted (no admin list configured for this scope)\n"
|
|
f"Slash commands: all available"
|
|
)
|
|
|
|
if policy.is_admin(user_id):
|
|
return (
|
|
f"**You** — {platform} ({scope})\n"
|
|
f"User ID: `{user_id}`\n"
|
|
f"Tier: **admin**\n"
|
|
f"Slash commands: all available"
|
|
)
|
|
|
|
# Non-admin user. Show what's actually reachable.
|
|
floor = ["help", "whoami"] # mirrors slash_access._ALWAYS_ALLOWED_FOR_USERS
|
|
configured = sorted(policy.user_allowed_commands)
|
|
# Combine + dedupe, preserve order: floor first, then operator additions.
|
|
seen: set[str] = set()
|
|
runnable: list[str] = []
|
|
for c in floor + configured:
|
|
if c not in seen:
|
|
seen.add(c)
|
|
runnable.append(c)
|
|
runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else "(none)"
|
|
return (
|
|
f"**You** — {platform} ({scope})\n"
|
|
f"User ID: `{user_id}`\n"
|
|
f"Tier: user\n"
|
|
f"Slash commands you can run: {runnable_str}"
|
|
)
|
|
|
|
async def _handle_kanban_command(self, event: MessageEvent) -> str:
|
|
"""Handle /kanban — delegate to the shared kanban CLI.
|
|
|
|
DB work runs in a thread pool to keep the event loop responsive. Reads and mutations are
|
|
allowed while an agent runs: the board is profile-agnostic and never touches agent state.
|
|
"""
|
|
import asyncio
|
|
import re
|
|
import shlex
|
|
from hermes_cli.kanban import run_slash
|
|
|
|
text = (event.text or "").strip()
|
|
# Strip the leading "/kanban" (with or without slash), leaving args.
|
|
if text.startswith("/"):
|
|
text = text.lstrip("/")
|
|
if text.startswith("kanban"):
|
|
text = text[len("kanban"):].lstrip()
|
|
|
|
tokens = shlex.split(text) if text else []
|
|
requested_board = None
|
|
action = None
|
|
i = 0
|
|
while i < len(tokens):
|
|
tok = tokens[i]
|
|
if tok == "--board":
|
|
if i + 1 >= len(tokens):
|
|
break
|
|
requested_board = tokens[i + 1]
|
|
i += 2
|
|
continue
|
|
if tok.startswith("--board="):
|
|
requested_board = tok.split("=", 1)[1]
|
|
i += 1
|
|
continue
|
|
action = tok
|
|
break
|
|
|
|
is_create = action == "create"
|
|
|
|
try:
|
|
output = await asyncio.to_thread(run_slash, text)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return t("gateway.kanban.error_prefix", error=exc)
|
|
|
|
# Auto-subscribe on create. Parse the task id from the CLI's standard success line ("Created
|
|
# t_abcd (ready, assignee=...)"). If the user passed --json we don't subscribe; they're
|
|
# clearly scripting and can call /kanban notify-subscribe explicitly.
|
|
if is_create and output:
|
|
m = re.search(r"Created\s+(t_[0-9a-f]+)\b", output)
|
|
if m:
|
|
task_id = m.group(1)
|
|
try:
|
|
source = event.source
|
|
platform = getattr(source, "platform", None)
|
|
platform_str = (
|
|
platform.value if hasattr(platform, "value") else str(platform or "")
|
|
).lower()
|
|
chat_id = str(getattr(source, "chat_id", "") or "")
|
|
chat_type = str(getattr(source, "chat_type", "") or "") or None
|
|
thread_id = str(getattr(source, "thread_id", "") or "")
|
|
user_id = str(getattr(source, "user_id", "") or "") or None
|
|
# Also persist the stable alt id (Signal UUID, Feishu union_id): build_session_key
|
|
# keys the participant on ``user_id_alt or user_id``, so a replayed wake rebuilds
|
|
# the same session key only when the alt id survives the round-trip.
|
|
user_id_alt = str(getattr(source, "user_id_alt", "") or "") or None
|
|
delivery_metadata = self._thread_metadata_for_source(
|
|
source, self._reply_anchor_for_event(event)
|
|
) or None
|
|
if isinstance(delivery_metadata, dict):
|
|
chat_type = str(getattr(source, "chat_type", "") or "")
|
|
if chat_type:
|
|
delivery_metadata.setdefault("chat_type", chat_type)
|
|
if platform_str and chat_id:
|
|
def _sub():
|
|
from hermes_cli import kanban_db as _kb
|
|
conn = _kb.connect(board=requested_board)
|
|
try:
|
|
_kb.add_notify_sub(
|
|
conn, task_id=task_id,
|
|
platform=platform_str, chat_id=chat_id,
|
|
chat_type=chat_type,
|
|
thread_id=thread_id or None,
|
|
user_id=user_id,
|
|
user_id_alt=user_id_alt,
|
|
notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(),
|
|
# Subscribing from chat: deliver the passive
|
|
# message and wake the destination agent.
|
|
delivery_mode="notify+wake",
|
|
delivery_metadata=delivery_metadata,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
await asyncio.to_thread(_sub)
|
|
output = (
|
|
output.rstrip()
|
|
+ "\n"
|
|
+ t("gateway.kanban.subscribed_suffix", task_id=task_id)
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("kanban create auto-subscribe failed: %s", exc)
|
|
|
|
# Gateway messages have practical length caps; truncate long
|
|
# listings to keep the UX reasonable.
|
|
if len(output) > 3800:
|
|
output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix")
|
|
return output or t("gateway.kanban.no_output")
|
|
|
|
async def _handle_status_command(self, event: MessageEvent) -> str:
|
|
"""Handle /status command."""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
|
|
|
|
source = event.source
|
|
session_entry = await self.async_session_store.get_or_create_session(source)
|
|
|
|
connected_platforms = [p.value for p in self.adapters]
|
|
|
|
# Check if there's an active agent. Keep the sentinel distinct: a
|
|
# starting/pending run should not be treated as a fully usable agent for
|
|
# model/context display, but it still occupies the session slot.
|
|
session_key = session_entry.session_key
|
|
agent = self._running_agents.get(session_key)
|
|
is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
|
|
|
|
# Count pending /queue follow-ups (slot + overflow).
|
|
adapter = self.adapters.get(source.platform) if source else None
|
|
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
|
|
|
def _clean_str(value: Any) -> str:
|
|
return value.strip() if isinstance(value, str) and value.strip() else ""
|
|
|
|
def _int_value(value: Any) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
title = None
|
|
session_row: dict[str, Any] = {}
|
|
# Pull token totals from the SQLite session DB rather than the in-memory SessionStore. The
|
|
# agent's per-turn token deltas are persisted into sessions_db (run_agent.py), not into
|
|
# SessionEntry, so session_entry.total_tokens is always 0.
|
|
db_total_tokens = 0
|
|
persisted_route: dict[str, Any] = {}
|
|
if self._session_db:
|
|
try:
|
|
title = await self._session_db.get_session_title(session_entry.session_id)
|
|
except Exception:
|
|
title = None
|
|
try:
|
|
row = await self._session_db.get_session(session_entry.session_id)
|
|
if isinstance(row, dict):
|
|
session_row = row
|
|
db_total_tokens = (
|
|
_int_value(row.get("input_tokens"))
|
|
+ _int_value(row.get("output_tokens"))
|
|
+ _int_value(row.get("cache_read_tokens"))
|
|
+ _int_value(row.get("cache_write_tokens"))
|
|
+ _int_value(row.get("reasoning_tokens"))
|
|
)
|
|
except Exception:
|
|
db_total_tokens = 0
|
|
try:
|
|
route = await self._session_db.get_dominant_session_model_route(
|
|
session_entry.session_id
|
|
)
|
|
if isinstance(route, dict):
|
|
persisted_route = route
|
|
except Exception:
|
|
persisted_route = {}
|
|
|
|
# Resolve model/context for cockpit-style status. Prefer the live or cached agent because it
|
|
# carries the actual runtime route and context compressor; fall back to SessionDB metadata +
|
|
# last_prompt_tokens so /status stays useful between turns without billing/account calls.
|
|
status_agent = agent if is_running else None
|
|
if status_agent is None:
|
|
cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
cache = getattr(self, "_agent_cache", None)
|
|
if cache_lock is not None and cache is not None:
|
|
try:
|
|
with cache_lock:
|
|
cached = cache.get(session_key)
|
|
if cached:
|
|
status_agent = cached[0]
|
|
except Exception:
|
|
status_agent = None
|
|
|
|
model_name = ""
|
|
provider_name = ""
|
|
route_resolved = False
|
|
context_used = 0
|
|
context_total = 0
|
|
if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
|
|
live_model = _clean_str(getattr(status_agent, "model", ""))
|
|
live_provider = _clean_str(getattr(status_agent, "provider", ""))
|
|
if live_model and live_provider:
|
|
model_name = live_model
|
|
provider_name = live_provider
|
|
route_resolved = True
|
|
ctx = getattr(status_agent, "context_compressor", None)
|
|
if ctx is not None:
|
|
context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
|
|
context_total = _int_value(getattr(ctx, "context_length", 0))
|
|
|
|
persisted_model = _clean_str(persisted_route.get("model"))
|
|
persisted_provider = _clean_str(persisted_route.get("billing_provider"))
|
|
if not route_resolved and persisted_model and persisted_provider:
|
|
model_name = persisted_model
|
|
provider_name = persisted_provider
|
|
route_resolved = True
|
|
if not route_resolved:
|
|
model_name = _clean_str(session_row.get("model"))
|
|
provider_name = _clean_str(session_row.get("billing_provider"))
|
|
context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
|
|
|
|
user_config: dict[str, Any] = {}
|
|
if not model_name or not provider_name or not context_total:
|
|
try:
|
|
user_config = _load_gateway_config()
|
|
except Exception:
|
|
user_config = {}
|
|
if not model_name:
|
|
model_name = _resolve_gateway_model(user_config)
|
|
if not provider_name:
|
|
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
|
if isinstance(model_cfg, dict):
|
|
provider_name = _clean_str(model_cfg.get("provider"))
|
|
if not context_total:
|
|
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
|
configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
|
|
if isinstance(configured_context, int) and configured_context > 0:
|
|
context_total = configured_context
|
|
|
|
model_line = ""
|
|
if model_name:
|
|
if provider_name:
|
|
model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
|
|
else:
|
|
model_line = t("gateway.status.model", model=model_name)
|
|
|
|
context_line = ""
|
|
if context_total:
|
|
pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
|
|
context_line = t(
|
|
"gateway.status.context",
|
|
used=f"{context_used:,}",
|
|
total=f"{context_total:,}",
|
|
pct=f"{pct}",
|
|
)
|
|
elif context_used:
|
|
context_line = t("gateway.status.context_used", used=f"{context_used:,}")
|
|
|
|
lines = [
|
|
t("gateway.status.header"),
|
|
"",
|
|
t("gateway.status.session_id", session_id=session_entry.session_id),
|
|
]
|
|
if title:
|
|
lines.append(t("gateway.status.title", title=title))
|
|
lines.extend([
|
|
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
|
|
t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
|
|
])
|
|
if model_line:
|
|
lines.append(model_line)
|
|
if context_line:
|
|
lines.append(context_line)
|
|
lines.extend([
|
|
t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
|
|
t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
|
|
])
|
|
if queue_depth:
|
|
lines.append(t("gateway.status.queued", count=queue_depth))
|
|
if source.platform == Platform.MATRIX:
|
|
adapter = self.adapters.get(Platform.MATRIX)
|
|
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
|
|
thread = source.thread_id or "none"
|
|
lines.extend([
|
|
"",
|
|
t("gateway.status.matrix_scope_header"),
|
|
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
|
|
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
|
|
t("gateway.status.matrix_scope_thread", thread_id=thread),
|
|
t("gateway.status.matrix_scope_mode", scope=scope),
|
|
t(
|
|
"gateway.status.matrix_scope_key",
|
|
session_key=self._redact_matrix_session_key(session_key),
|
|
),
|
|
])
|
|
lines.extend([
|
|
"",
|
|
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
@staticmethod
|
|
def _redact_matrix_session_key(session_key: str) -> str:
|
|
"""Return a stable Matrix session-key fingerprint for shared room status."""
|
|
text = str(session_key or "")
|
|
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
|
return f"sha256:{digest}"
|
|
|
|
async def _handle_context_command(self, event: MessageEvent) -> str:
|
|
"""Handle /context — the dedicated context-window view.
|
|
|
|
/status shows a one-line ``used / total`` summary; this command is the deep view: a usage
|
|
gauge, auto-compression threshold and headroom, compression count and last savings, and
|
|
cumulative throughput — the last clearly labelled as throughput, NOT context size.
|
|
Resolution order: running agent, cached agent, SessionStore/SessionDB metadata, and a
|
|
transcript estimate only as last resort. ``/context all`` adds per-skill/toolset listings.
|
|
"""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL
|
|
|
|
source = event.source
|
|
session_key = self._session_key_for_source(source)
|
|
session_entry = await self.async_session_store.get_or_create_session(source)
|
|
expanded = event.get_command_args().strip().lower() in {"all", "full", "details"}
|
|
|
|
# Try running agent first (mid-turn), then cached agent (between turns).
|
|
agent = self._running_agents.get(session_key)
|
|
if not agent or agent is _AGENT_PENDING_SENTINEL:
|
|
cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
cache = getattr(self, "_agent_cache", None)
|
|
if cache_lock is not None and cache is not None:
|
|
try:
|
|
with cache_lock:
|
|
cached = cache.get(session_key)
|
|
if cached:
|
|
agent = cached[0]
|
|
except Exception:
|
|
agent = None
|
|
has_agent = bool(agent) and agent is not _AGENT_PENDING_SENTINEL
|
|
|
|
ctx = getattr(agent, "context_compressor", None) if has_agent else None
|
|
|
|
# Resolve current-context size + window with cascading fallbacks.
|
|
# used : compressor.last_prompt_tokens → SessionStore.last_prompt_tokens
|
|
# model : agent.model → SessionDB row model
|
|
# window: compressor.context_length → effective gateway model route
|
|
used = 0
|
|
context_length = 0
|
|
if ctx is not None:
|
|
used = getattr(ctx, "last_prompt_tokens", 0) or 0
|
|
context_length = getattr(ctx, "context_length", 0) or 0
|
|
|
|
model_name = _clean_str(getattr(agent, "model", "")) if has_agent else ""
|
|
|
|
if not used:
|
|
used = _int_value(getattr(session_entry, "last_prompt_tokens", 0))
|
|
|
|
if not model_name and self._session_db:
|
|
try:
|
|
row = await self._session_db.get_session(session_entry.session_id) or {}
|
|
if isinstance(row, dict):
|
|
model_name = _clean_str(row.get("model", ""))
|
|
except Exception:
|
|
model_name = ""
|
|
|
|
if not context_length:
|
|
try:
|
|
from gateway.run import (
|
|
_profile_runtime_scope,
|
|
_resolve_gateway_model_context,
|
|
)
|
|
|
|
def _resolve_nonresident_context():
|
|
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
|
profile_home = self._resolve_profile_home_for_source(source)
|
|
with _profile_runtime_scope(profile_home):
|
|
return _resolve_gateway_model_context(model_name or None)
|
|
return _resolve_gateway_model_context(model_name or None)
|
|
|
|
resolved = await asyncio.to_thread(_resolve_nonresident_context)
|
|
model_name = model_name or resolved.model
|
|
context_length = _int_value(resolved.context_length)
|
|
except Exception:
|
|
context_length = 0
|
|
|
|
if not context_length and model_name:
|
|
try:
|
|
from agent.model_metadata import get_model_context_length
|
|
|
|
context_length = _int_value(
|
|
await asyncio.to_thread(get_model_context_length, model_name)
|
|
)
|
|
except Exception:
|
|
context_length = 0
|
|
|
|
# Gauge path: real current-context figure
|
|
if used > 0 and context_length > 0:
|
|
pct = min(100.0, used / context_length * 100)
|
|
headroom = max(0, context_length - used)
|
|
BAR_WIDTH = 24
|
|
filled = int(round(pct / 100 * BAR_WIDTH))
|
|
bar = "█" * max(0, filled) + "░" * max(0, BAR_WIDTH - filled)
|
|
|
|
lines = [
|
|
t("gateway.context.header"),
|
|
"",
|
|
t("gateway.context.model", model=model_name or "?"),
|
|
t("gateway.context.window", total=f"{context_length:,}"),
|
|
t(
|
|
"gateway.context.in_use",
|
|
used=f"{used:,}",
|
|
total=f"{context_length:,}",
|
|
pct=f"{pct:.0f}",
|
|
),
|
|
t("gateway.context.bar", bar=bar),
|
|
t("gateway.context.headroom", headroom=f"{headroom:,}"),
|
|
]
|
|
|
|
# Full view — compression / throughput need the live agent.
|
|
if ctx is not None:
|
|
threshold = getattr(ctx, "threshold_tokens", 0) or 0
|
|
threshold_pct = (getattr(ctx, "threshold_percent", 0) or 0) * 100
|
|
lines.append("")
|
|
if threshold > 0:
|
|
if used >= threshold:
|
|
lines.append(
|
|
t(
|
|
"gateway.context.over_threshold",
|
|
threshold=f"{threshold:,}",
|
|
threshold_pct=f"{threshold_pct:.0f}",
|
|
)
|
|
)
|
|
else:
|
|
lines.append(
|
|
t(
|
|
"gateway.context.threshold",
|
|
threshold=f"{threshold:,}",
|
|
threshold_pct=f"{threshold_pct:.0f}",
|
|
to_go=f"{threshold - used:,}",
|
|
)
|
|
)
|
|
compressions = getattr(ctx, "compression_count", 0) or 0
|
|
lines.append(t("gateway.context.compressions", count=compressions))
|
|
if compressions:
|
|
savings = getattr(ctx, "_last_compression_savings_pct", None)
|
|
if savings is not None:
|
|
lines.append(
|
|
t("gateway.context.last_savings", savings=f"{savings:.0f}")
|
|
)
|
|
|
|
api_calls = getattr(agent, "session_api_calls", 0) or 0
|
|
input_tokens = getattr(agent, "session_input_tokens", 0) or 0
|
|
output_tokens = getattr(agent, "session_output_tokens", 0) or 0
|
|
reasoning_tokens = getattr(agent, "session_reasoning_tokens", 0) or 0
|
|
total_tokens = getattr(agent, "session_total_tokens", 0) or 0
|
|
lines.append("")
|
|
lines.append(
|
|
t("gateway.context.totals_header", calls=api_calls)
|
|
)
|
|
lines.append(
|
|
t(
|
|
"gateway.context.totals_line",
|
|
input=f"{input_tokens:,}",
|
|
output=f"{output_tokens:,}",
|
|
reasoning=f"{reasoning_tokens:,}",
|
|
)
|
|
)
|
|
lines.append(t("gateway.context.total_billed", total=f"{total_tokens:,}"))
|
|
lines.append(t("gateway.context.throughput_note"))
|
|
else:
|
|
lines.append("")
|
|
lines.append(t("gateway.context.detail_after_first"))
|
|
|
|
# Per-category estimated breakdown (+ optional expanded listings). Same chars/4 engine
|
|
# the desktop popover and /usage use; plain text (no glyph grid — monospace isn't
|
|
# guaranteed on messaging platforms). Fail-open: rendering errors never break /context.
|
|
if has_agent:
|
|
breakdown = await asyncio.to_thread(
|
|
self._context_breakdown_block, agent, source, expanded
|
|
)
|
|
if breakdown:
|
|
lines.append("")
|
|
lines.extend(breakdown)
|
|
|
|
return "\n".join(lines)
|
|
|
|
# Last resort: rough estimate from transcript
|
|
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
|
if history:
|
|
from agent.model_metadata import estimate_messages_tokens_rough
|
|
|
|
msgs = [
|
|
m
|
|
for m in history
|
|
if m.get("role") in {"user", "assistant"} and m.get("content")
|
|
]
|
|
approx = estimate_messages_tokens_rough(msgs)
|
|
return "\n".join(
|
|
[
|
|
t("gateway.context.header"),
|
|
"",
|
|
t(
|
|
"gateway.context.estimated",
|
|
count=f"{approx:,}",
|
|
messages=len(msgs),
|
|
),
|
|
t("gateway.context.detail_after_first"),
|
|
]
|
|
)
|
|
return t("gateway.context.no_data")
|
|
|
|
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.
|
|
shared = is_shared_multi_user_session(
|
|
current,
|
|
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
|
|
thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False),
|
|
)
|
|
if shared:
|
|
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 _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.
|
|
shared = 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),
|
|
)
|
|
if shared:
|
|
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."""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL
|
|
from tools.process_registry import format_uptime_short, process_registry
|
|
|
|
now = time.time()
|
|
current_session_key = self._session_key_for_source(event.source)
|
|
|
|
running_agents: dict = getattr(self, "_running_agents", {}) or {}
|
|
running_started: dict = getattr(self, "_running_agents_ts", {}) or {}
|
|
|
|
agent_rows: list[dict] = []
|
|
for session_key, agent in running_agents.items():
|
|
started = float(running_started.get(session_key, now))
|
|
elapsed = max(0, int(now - started))
|
|
is_pending = agent is _AGENT_PENDING_SENTINEL
|
|
agent_rows.append(
|
|
{
|
|
"session_key": session_key,
|
|
"elapsed": elapsed,
|
|
"state": t("gateway.agents.state_starting") if is_pending else t("gateway.agents.state_running"),
|
|
"session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""),
|
|
"model": "" if is_pending else str(getattr(agent, "model", "") or ""),
|
|
}
|
|
)
|
|
|
|
agent_rows.sort(key=lambda row: row["elapsed"], reverse=True)
|
|
|
|
running_processes: list[dict] = []
|
|
try:
|
|
running_processes = [
|
|
p for p in process_registry.list_sessions()
|
|
if p.get("status") == "running"
|
|
]
|
|
except Exception:
|
|
running_processes = []
|
|
|
|
background_tasks = [
|
|
t for t in (getattr(self, "_background_tasks", set()) or set())
|
|
if hasattr(t, "done") and not t.done()
|
|
]
|
|
|
|
lines = [
|
|
t("gateway.agents.header"),
|
|
"",
|
|
t("gateway.agents.active_agents", count=len(agent_rows)),
|
|
]
|
|
|
|
if agent_rows:
|
|
for idx, row in enumerate(agent_rows[:12], 1):
|
|
current = t("gateway.agents.this_chat") if row["session_key"] == current_session_key else ""
|
|
sid = f" · `{row['session_id']}`" if row["session_id"] else ""
|
|
model = f" · `{row['model']}`" if row["model"] else ""
|
|
lines.append(
|
|
f"{idx}. `{row['session_key']}` · {row['state']} · "
|
|
f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}"
|
|
)
|
|
if len(agent_rows) > 12:
|
|
lines.append(t("gateway.agents.more", count=len(agent_rows) - 12))
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
t("gateway.agents.running_processes", count=len(running_processes)),
|
|
]
|
|
)
|
|
if running_processes:
|
|
for proc in running_processes[:12]:
|
|
cmd = " ".join(str(proc.get("command", "")).split())
|
|
if len(cmd) > 90:
|
|
cmd = cmd[:87] + "..."
|
|
lines.append(
|
|
f"- `{proc.get('session_id', '?')}` · "
|
|
f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`"
|
|
)
|
|
if len(running_processes) > 12:
|
|
lines.append(t("gateway.agents.more", count=len(running_processes) - 12))
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
t("gateway.agents.async_jobs", count=len(background_tasks)),
|
|
]
|
|
)
|
|
|
|
# Background (async) delegations — delegate_task(background=true).
|
|
# Live per-child activity comes from the registry's progress sampler
|
|
# (#51690): api calls, current tool, seconds since last activity.
|
|
delegations: list[dict] = []
|
|
try:
|
|
from tools.async_delegation import list_async_delegations
|
|
delegations = [
|
|
d for d in list_async_delegations()
|
|
if d.get("status") in ("running", "stalling", "finalizing")
|
|
]
|
|
except Exception:
|
|
delegations = []
|
|
if delegations:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
t(
|
|
"gateway.agents.background_delegations",
|
|
count=len(delegations),
|
|
),
|
|
]
|
|
)
|
|
for d in delegations[:12]:
|
|
goal = " ".join(str(d.get("goal") or "").split())
|
|
if len(goal) > 70:
|
|
goal = goal[:67] + "..."
|
|
status = d.get("status", "?")
|
|
row = f"- `{d.get('delegation_id', '?')}` · {status}"
|
|
if status == "stalling":
|
|
quiet = d.get("stalled_after_quiet_seconds")
|
|
if quiet is not None:
|
|
row += f" · no progress {quiet:.0f}s"
|
|
elif d.get("seconds_since_progress", 0) >= 60:
|
|
row += f" · quiet {d['seconds_since_progress']:.0f}s"
|
|
if goal:
|
|
row += f" · {goal}"
|
|
lines.append(row)
|
|
for i, child in enumerate(d.get("children_activity") or []):
|
|
if not isinstance(child, dict):
|
|
continue
|
|
tool = child.get("current_tool")
|
|
doing = f"`{tool}`" if tool else "between turns"
|
|
part = (
|
|
f" - child {i + 1}: "
|
|
f"{child.get('api_calls', '?')} api calls · {doing}"
|
|
)
|
|
idle = child.get("seconds_since_activity")
|
|
if idle is not None:
|
|
part += f" · active {idle:.0f}s ago"
|
|
lines.append(part)
|
|
if len(delegations) > 12:
|
|
lines.append(
|
|
t("gateway.agents.more", count=len(delegations) - 12)
|
|
)
|
|
|
|
if (
|
|
not agent_rows
|
|
and not running_processes
|
|
and not background_tasks
|
|
and not delegations
|
|
):
|
|
lines.append("")
|
|
lines.append(t("gateway.agents.none"))
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
|
"""Handle /stop command - interrupt a running agent.
|
|
|
|
When an agent is truly hung (blocked thread that never checks _interrupt_requested), the
|
|
early intercept in _handle_message() handles /stop before this method is reached; this
|
|
handler fires only via normal dispatch (no running agent) or as a fallback, and force-cleans
|
|
the session lock in all cases. The session is preserved so the user can continue.
|
|
"""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP
|
|
source = event.source
|
|
session_entry = await self.async_session_store.get_or_create_session(source)
|
|
session_key = session_entry.session_key
|
|
|
|
agent = self._running_agents.get(session_key)
|
|
if agent is _AGENT_PENDING_SENTINEL:
|
|
# Force-clean the sentinel so the session is unlocked.
|
|
await self._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason=_INTERRUPT_REASON_STOP,
|
|
invalidation_reason="stop_command_pending",
|
|
)
|
|
logger.info("STOP (pending) for session %s — sentinel cleared", session_key)
|
|
return EphemeralReply(t("gateway.stop.stopped_pending"))
|
|
if agent:
|
|
# Force-clean the session lock so a truly hung agent doesn't
|
|
# keep it locked forever.
|
|
await self._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason=_INTERRUPT_REASON_STOP,
|
|
invalidation_reason="stop_command_handler",
|
|
)
|
|
return EphemeralReply(t("gateway.stop.stopped"))
|
|
|
|
# No run under the caller's own key. In a per-user thread (thread_sessions_per_user=True) a
|
|
# run another user started lives under a different key, yet authorized users must still be
|
|
# able to /stop it: fall back to sibling runs in this thread, gated on authorization.
|
|
sibling_keys = self._sibling_thread_run_keys(source, session_key)
|
|
if sibling_keys and self._is_user_authorized(source):
|
|
for sibling_key in sibling_keys:
|
|
await self._interrupt_and_clear_session(
|
|
sibling_key,
|
|
source,
|
|
interrupt_reason=_INTERRUPT_REASON_STOP,
|
|
invalidation_reason="stop_command_thread_sibling",
|
|
)
|
|
logger.info(
|
|
"STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s",
|
|
session_key,
|
|
len(sibling_keys),
|
|
", ".join(sibling_keys),
|
|
)
|
|
return EphemeralReply(t("gateway.stop.stopped"))
|
|
|
|
# No running agent anywhere for this scope. A platform status indicator can still be stuck —
|
|
# e.g. Slack's persistent assistant.threads.setStatus survives a gateway restart or a turn
|
|
# that died without a final send.
|
|
adapter = getattr(self, "adapters", {}).get(source.platform)
|
|
if adapter and hasattr(adapter, "_stop_typing_with_metadata"):
|
|
try:
|
|
await adapter._stop_typing_with_metadata(
|
|
source.chat_id,
|
|
self._thread_metadata_for_source(
|
|
source, self._reply_anchor_for_event(event)
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to clear typing on /stop with no active agent",
|
|
exc_info=True,
|
|
)
|
|
|
|
return t("gateway.stop.no_active")
|
|
|
|
async def _handle_platform_command(self, event: MessageEvent) -> str:
|
|
"""Handle ``/platform list|pause|resume [name]`` — inspect and manually control failed/paused
|
|
gateway adapters (pause stops the reconnect watcher; resume re-queues for retry).
|
|
"""
|
|
text = (getattr(event, "content", "") or "").strip()
|
|
# Strip the leading "/platform" (or "/PLATFORM") token if present
|
|
parts = text.split(maxsplit=2)
|
|
if parts and parts[0].lower().lstrip("/").startswith("platform"):
|
|
parts = parts[1:]
|
|
action = (parts[0] if parts else "list").lower()
|
|
target = parts[1].lower() if len(parts) > 1 else ""
|
|
|
|
# Resolve platform name (case-insensitive, value match)
|
|
def _resolve_platform(name: str):
|
|
if not name:
|
|
return None
|
|
for p in Platform.__members__.values():
|
|
if p.value.lower() == name:
|
|
return p
|
|
return None
|
|
|
|
if action == "list":
|
|
lines = ["**Gateway platforms**"]
|
|
connected = sorted(p.value for p in self.adapters)
|
|
if connected:
|
|
lines.append("Connected: " + ", ".join(connected))
|
|
else:
|
|
lines.append("Connected: (none)")
|
|
failed = getattr(self, "_failed_platforms", {}) or {}
|
|
if failed:
|
|
for p, info in failed.items():
|
|
if info.get("paused"):
|
|
reason = info.get("pause_reason") or "paused"
|
|
lines.append(
|
|
f" · {p.value} — PAUSED ({reason}). "
|
|
f"Resume with `/platform resume {p.value}`."
|
|
)
|
|
else:
|
|
attempts = info.get("attempts", 0)
|
|
lines.append(
|
|
f" · {p.value} — retrying (attempt {attempts})"
|
|
)
|
|
else:
|
|
lines.append("Failed/paused: (none)")
|
|
return "\n".join(lines)
|
|
|
|
if action in {"pause", "resume"}:
|
|
if not target:
|
|
return f"Usage: /platform {action} <name>"
|
|
platform = _resolve_platform(target)
|
|
if platform is None:
|
|
return f"Unknown platform: {target}"
|
|
failed = getattr(self, "_failed_platforms", {}) or {}
|
|
if action == "pause":
|
|
if platform not in failed:
|
|
return (
|
|
f"{platform.value} is not in the retry queue "
|
|
f"(it's either connected or not enabled)."
|
|
)
|
|
if failed[platform].get("paused"):
|
|
return f"{platform.value} is already paused."
|
|
self._pause_failed_platform(platform, reason="paused via /platform pause")
|
|
return (
|
|
f"✓ {platform.value} paused. "
|
|
f"Resume with `/platform resume {platform.value}` or "
|
|
f"`hermes gateway restart` to reset."
|
|
)
|
|
# action == "resume"
|
|
if platform not in failed:
|
|
return (
|
|
f"{platform.value} is not in the retry queue — "
|
|
f"nothing to resume."
|
|
)
|
|
if not failed[platform].get("paused"):
|
|
return (
|
|
f"{platform.value} is already retrying — "
|
|
f"no resume needed."
|
|
)
|
|
self._resume_paused_platform(platform)
|
|
return f"✓ {platform.value} resumed — retrying on next watcher tick."
|
|
|
|
return (
|
|
"Usage: /platform <list|pause|resume> [name]\n"
|
|
" /platform list — show platform status\n"
|
|
" /platform pause <name> — stop retrying a failing platform\n"
|
|
" /platform resume <name> — re-queue a paused platform"
|
|
)
|
|
|
|
async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
|
"""Handle /restart command - drain active work, then restart the gateway."""
|
|
from gateway.run import _hermes_home
|
|
# Idempotency check: if the previous gateway process recorded this same /restart (platform +
|
|
# update_id) and we see it *again*, it's a redelivery from PTB's graceful-shutdown get_updates
|
|
# ACK failing on the way out. Ignoring it prevents a loop where every fresh gateway re-restarts.
|
|
if self._is_stale_restart_redelivery(event):
|
|
logger.info(
|
|
"Ignoring redelivered /restart (platform=%s, update_id=%s) — "
|
|
"already processed by a previous gateway instance.",
|
|
event.source.platform.value if event.source and event.source.platform else "?",
|
|
event.platform_update_id,
|
|
)
|
|
return ""
|
|
|
|
if self._restart_requested or self._draining:
|
|
count = self._running_agent_count()
|
|
if count:
|
|
return t("gateway.draining", count=count)
|
|
return EphemeralReply(t("gateway.restart.in_progress"))
|
|
|
|
# Save the requester's routing info so the new gateway process can
|
|
# notify them once it comes back online.
|
|
try:
|
|
notify_data = {
|
|
"platform": event.source.platform.value if event.source.platform else None,
|
|
"chat_id": event.source.chat_id,
|
|
"chat_type": event.source.chat_type,
|
|
}
|
|
if event.source.delivered_via_upstream_relay is True:
|
|
notify_data["delivered_via_upstream_relay"] = True
|
|
if event.source.user_id:
|
|
notify_data["user_id"] = event.source.user_id
|
|
if event.source.scope_id:
|
|
notify_data["scope_id"] = event.source.scope_id
|
|
if event.source.thread_id:
|
|
notify_data["thread_id"] = event.source.thread_id
|
|
if event.message_id:
|
|
notify_data["message_id"] = event.message_id
|
|
if event.source is not None:
|
|
try:
|
|
self._restart_command_source = dataclasses.replace(
|
|
event.source,
|
|
message_id=str(event.message_id)
|
|
if event.message_id is not None
|
|
else event.source.message_id,
|
|
)
|
|
except Exception:
|
|
self._restart_command_source = event.source
|
|
await asyncio.to_thread(
|
|
atomic_json_write,
|
|
_hermes_home / ".restart_notify.json",
|
|
notify_data,
|
|
indent=None,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Failed to write restart notify file: %s", e)
|
|
|
|
# Record the triggering platform + update_id in a dedicated dedup marker. Unlike
|
|
# .restart_notify.json (unlinked once the new gateway sends its notification) this persists
|
|
# so a delayed Telegram redelivery is still detectable. Overwritten on every /restart.
|
|
try:
|
|
dedup_data = {
|
|
"platform": event.source.platform.value if event.source.platform else None,
|
|
"requested_at": time.time(),
|
|
}
|
|
if event.platform_update_id is not None:
|
|
dedup_data["update_id"] = event.platform_update_id
|
|
await asyncio.to_thread(
|
|
atomic_json_write,
|
|
_hermes_home / ".restart_last_processed.json",
|
|
dedup_data,
|
|
indent=None,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Failed to write restart dedup marker: %s", e)
|
|
|
|
active_agents = self._running_agent_count()
|
|
# Under a service manager (systemd/launchd) or Docker/Podman, exit 75 so the supervisor /
|
|
# restart policy restarts us — detached setsid+bash fails there (systemd KillMode=mixed kills
|
|
# the cgroup; tini exits with the gateway). The explicit marker covers ``sudo env -i`` wrappers.
|
|
from gateway.restart import (
|
|
is_container_restart_context,
|
|
is_gateway_supervisor_process,
|
|
)
|
|
|
|
_under_service = is_gateway_supervisor_process()
|
|
_in_container = is_container_restart_context()
|
|
if _under_service or _in_container:
|
|
self.request_restart(detached=False, via_service=True)
|
|
else:
|
|
self.request_restart(detached=True, via_service=False)
|
|
if active_agents:
|
|
return t("gateway.draining", count=active_agents)
|
|
return EphemeralReply(t("gateway.restart.restarting"))
|
|
|
|
async def _handle_version_command(self, event: MessageEvent) -> str:
|
|
"""Handle /version — show the running Hermes Agent version."""
|
|
from hermes_cli.slash_exec import CommandContext, execute_command
|
|
|
|
return execute_command("version", CommandContext(surface="gateway")).text
|
|
|
|
async def _handle_help_command(self, event: MessageEvent) -> str:
|
|
"""Handle /help command - list available commands."""
|
|
from gateway.run import _telegramize_command_mentions
|
|
from hermes_cli.slash_exec import CommandContext, execute_command
|
|
|
|
reply = execute_command("help", CommandContext(surface="gateway"))
|
|
return _telegramize_command_mentions(
|
|
reply.text,
|
|
getattr(getattr(event, "source", None), "platform", None),
|
|
)
|
|
|
|
async def _handle_commands_command(self, event: MessageEvent) -> str:
|
|
from gateway.run import _telegramize_command_mentions
|
|
from hermes_cli.slash_exec import CommandContext, execute_command
|
|
from gateway.config import Platform
|
|
|
|
# Page size is a surface parameter (Telegram messages are shorter).
|
|
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
|
|
reply = execute_command(
|
|
"commands",
|
|
CommandContext(
|
|
surface="gateway",
|
|
args=event.get_command_args(),
|
|
options={"page_size": page_size},
|
|
),
|
|
)
|
|
return _telegramize_command_mentions(
|
|
reply.text,
|
|
getattr(getattr(event, "source", None), "platform", None),
|
|
)
|
|
|
|
async def _perform_model_switch(
|
|
self,
|
|
switch_model,
|
|
*,
|
|
raw_input: str,
|
|
explicit_provider,
|
|
session_key: str,
|
|
source,
|
|
current_model,
|
|
current_provider,
|
|
current_base_url,
|
|
current_api_key,
|
|
persist_global: bool,
|
|
user_provs,
|
|
custom_provs,
|
|
):
|
|
"""Resolve a /model switch off-loop. Returns ``(result, None)`` or ``(None, error_text)``."""
|
|
from gateway.run import _load_gateway_config
|
|
|
|
skew_error = _model_switch_skew_guard()
|
|
if skew_error:
|
|
return None, skew_error
|
|
# Offload the switch off the event loop — switch_model() can fall through to a synchronous
|
|
# models.dev HTTP fetch (requests.get, 15s timeout) on a cold/expired cache, which freezes
|
|
# the gateway otherwise.
|
|
result = await asyncio.to_thread(
|
|
switch_model,
|
|
raw_input=raw_input,
|
|
current_provider=current_provider,
|
|
current_model=current_model,
|
|
current_base_url=current_base_url,
|
|
current_api_key=current_api_key,
|
|
is_global=persist_global,
|
|
explicit_provider=explicit_provider,
|
|
user_providers=user_provs,
|
|
custom_providers=custom_provs,
|
|
)
|
|
if not result.success:
|
|
return None, t("gateway.model.error_prefix", error=result.error_message)
|
|
try:
|
|
from hermes_cli.context_switch_guard import enrich_model_switch_warnings_for_gateway
|
|
|
|
# Offload: merge_preflight_compression_warning() calls the sync
|
|
# resolve_display_context_length() provider probe ladder — must not run on the loop.
|
|
await asyncio.to_thread(
|
|
enrich_model_switch_warnings_for_gateway,
|
|
result,
|
|
self,
|
|
session_key=session_key,
|
|
source=source,
|
|
custom_providers=custom_provs,
|
|
load_gateway_config=_load_gateway_config,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("preflight-compression switch warning failed: %s", exc)
|
|
return result, None
|
|
|
|
async def _commit_model_switch(
|
|
self,
|
|
result,
|
|
*,
|
|
session_key: str,
|
|
source,
|
|
current_model,
|
|
current_base_url,
|
|
current_api_key,
|
|
custom_provs,
|
|
persist_global: bool,
|
|
config_path,
|
|
one_turn: bool = False,
|
|
restore_snapshot=None,
|
|
picker: bool = False,
|
|
) -> str:
|
|
"""Apply a resolved switch (cached agent, session, config) and build the confirmation.
|
|
|
|
Shared by the typed ``/model <name>`` path and the picker callback (``picker=True``).
|
|
"""
|
|
from gateway.run import _load_gateway_config
|
|
from hermes_cli.model_switch import format_model_for_display, resolve_display_context_length_async
|
|
|
|
# If there's a cached agent, update it in-place
|
|
cached_entry = None
|
|
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
_cache = getattr(self, "_agent_cache", None)
|
|
if _cache_lock and _cache is not None:
|
|
with _cache_lock:
|
|
cached_entry = _cache.get(session_key)
|
|
if cached_entry and cached_entry[0] is not None:
|
|
try:
|
|
cached_entry[0].switch_model(
|
|
new_model=result.new_model,
|
|
new_provider=result.target_provider,
|
|
api_key=result.api_key,
|
|
base_url=result.base_url,
|
|
api_mode=result.api_mode,
|
|
capabilities=getattr(result, "runtime_capabilities", None),
|
|
)
|
|
except Exception as exc:
|
|
# In-place swap rolled back to the OLD working model/client and re-raised. Abort the
|
|
# commit (DB persist, session override, cache eviction, config write) so a failed switch
|
|
# is a no-op — otherwise the next message rebuilds a broken agent from the override.
|
|
logger.warning(
|
|
"%s model switch failed for cached agent: %s", "Picker" if picker else "In-place", exc
|
|
)
|
|
return t(
|
|
"gateway.model.error_prefix",
|
|
error=f"Model switch to {result.new_model} failed ({exc}); staying on {current_model}.",
|
|
)
|
|
|
|
# Persist the new model to the session DB so the dashboard shows the updated model.
|
|
_sess_db = getattr(self, "_session_db", None)
|
|
if _sess_db is not None:
|
|
try:
|
|
_sess_entry = await self.async_session_store.get_or_create_session(source)
|
|
# Typed path: if this session was auto-reset, consume the flag so the next regular
|
|
# message's cleanup does not wipe the model override just stored below.
|
|
if not picker and getattr(_sess_entry, "was_auto_reset", False):
|
|
_sess_entry.was_auto_reset = False
|
|
await _sess_db.update_session_model(
|
|
_sess_entry.session_id, result.new_model,
|
|
provider=result.target_provider,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("Failed to persist model switch to DB: %s", exc)
|
|
|
|
# Store a note to prepend to the next user message so the model knows about the switch
|
|
# (avoids system messages mid-history). Display form strips opaque Palantir RID
|
|
# prefixes; the override map below keeps the full ID for the wire.
|
|
if not hasattr(self, "_pending_model_notes"):
|
|
self._pending_model_notes = {}
|
|
self._pending_model_notes[session_key] = (
|
|
f"[Note: model was just switched from {format_model_for_display(current_model)} to "
|
|
f"{format_model_for_display(result.new_model)} "
|
|
f"via {result.provider_label or result.target_provider}. "
|
|
f"{'This override applies to the next turn only. ' if one_turn else ''}"
|
|
f"Adjust your self-identification accordingly.]"
|
|
)
|
|
|
|
# Store session override so next agent creation uses the new model
|
|
self._session_model_overrides[session_key] = {
|
|
"model": result.new_model,
|
|
"provider": result.target_provider,
|
|
"api_key": result.api_key,
|
|
"base_url": result.base_url,
|
|
"api_mode": result.api_mode,
|
|
"request_overrides": dict(result.request_overrides or {}),
|
|
"capabilities": dict(result.runtime_capabilities or {}),
|
|
}
|
|
if one_turn:
|
|
if not hasattr(self, "_pending_one_turn_model_restores"):
|
|
self._pending_one_turn_model_restores = {}
|
|
self._pending_one_turn_model_restores[session_key] = (
|
|
restore_snapshot or {"had_override": False, "override": None}
|
|
)
|
|
elif not picker and hasattr(self, "_pending_one_turn_model_restores"):
|
|
self._pending_one_turn_model_restores.pop(session_key, None)
|
|
|
|
# Write-through the non-secret parts (model/provider/base_url) so the override survives a
|
|
# restart; api_key/api_mode are never persisted (re-resolved on rehydration). /model --once is
|
|
# EXCLUDED: a one-turn override must not outlive a restart; the pre-once value stays persisted.
|
|
if not one_turn:
|
|
try:
|
|
await self.async_session_store.set_model_override(
|
|
session_key, self._session_model_overrides[session_key]
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to persist session model override", exc_info=True)
|
|
|
|
# Evict cached agent so the next turn creates a fresh agent from the
|
|
# override rather than relying on cache signature mismatch detection.
|
|
self._evict_cached_agent(session_key)
|
|
|
|
# Persist to config (default) unless --session opted out
|
|
if persist_global:
|
|
try:
|
|
# Write-back round-trip: raw read is correct (merged
|
|
# defaults must not be persisted back to the user's file).
|
|
from hermes_cli.config import read_user_config_raw, save_config
|
|
cfg = read_user_config_raw(config_path)
|
|
# Coerce scalar/None ``model:`` into a dict before mutation — otherwise
|
|
# ``cfg.setdefault("model", {})`` returns the existing scalar and the next
|
|
# assignment raises ``TypeError: 'str' object does not support item assignment``.
|
|
raw_model = cfg.get("model")
|
|
if isinstance(raw_model, dict):
|
|
model_cfg = raw_model
|
|
elif isinstance(raw_model, str) and raw_model.strip():
|
|
model_cfg = {"default": raw_model.strip()}
|
|
cfg["model"] = model_cfg
|
|
else:
|
|
model_cfg = {}
|
|
cfg["model"] = model_cfg
|
|
try:
|
|
from hermes_cli.route_identity import should_clear_context_pin_async
|
|
|
|
if await should_clear_context_pin_async(
|
|
model_cfg.get("default") or model_cfg.get("model"),
|
|
result.new_model,
|
|
model_cfg.get("base_url"),
|
|
result.base_url,
|
|
model_cfg.get("provider"),
|
|
result.target_provider,
|
|
):
|
|
model_cfg.pop("context_length", None)
|
|
except Exception:
|
|
model_cfg.pop("context_length", None)
|
|
model_cfg["default"] = result.new_model
|
|
model_cfg["provider"] = result.target_provider
|
|
# Named providers re-resolve base_url/api_mode fresh, so leftovers are cleared
|
|
# unconditionally; custom providers have no registry entry to re-derive from, so
|
|
# they need an explicit set-or-clear (a lone ``if base_url:`` leaves stale values).
|
|
_is_custom_target = str(result.target_provider or "").strip().lower() == "custom"
|
|
if result.base_url:
|
|
model_cfg["base_url"] = result.base_url
|
|
elif _is_custom_target:
|
|
model_cfg.pop("base_url", None)
|
|
if _is_custom_target:
|
|
if result.api_mode:
|
|
model_cfg["api_mode"] = result.api_mode
|
|
else:
|
|
model_cfg.pop("api_mode", None)
|
|
else:
|
|
clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
|
|
save_config(cfg)
|
|
except Exception as e:
|
|
logger.warning("Failed to persist model switch: %s", e)
|
|
|
|
# Build confirmation message with full metadata. Display form shortens opaque Palantir
|
|
# IDs (ri.language-model-service..*) to their trailing slug.
|
|
provider_label = result.provider_label or result.target_provider
|
|
lines = [t("gateway.model.switched", model=format_model_for_display(result.new_model))]
|
|
lines.append(t("gateway.model.provider_label", provider=provider_label))
|
|
|
|
# Context: always resolve via the provider-aware chain so Codex OAuth,
|
|
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
|
|
mi = result.model_info
|
|
_sw_config_ctx = None
|
|
_sw_model_cfg = {}
|
|
try:
|
|
_sw_model_cfg = _load_gateway_config().get("model", {})
|
|
if isinstance(_sw_model_cfg, dict):
|
|
_sw_raw = _sw_model_cfg.get("context_length")
|
|
if _sw_raw is not None:
|
|
_sw_config_ctx = int(_sw_raw)
|
|
except Exception:
|
|
pass
|
|
if not isinstance(_sw_model_cfg, dict):
|
|
_sw_model_cfg = {}
|
|
ctx = await resolve_display_context_length_async(
|
|
result.new_model,
|
|
result.target_provider,
|
|
base_url=result.base_url or current_base_url or "",
|
|
api_key=result.api_key or current_api_key or "",
|
|
model_info=mi,
|
|
custom_providers=custom_provs,
|
|
config_context_length=_sw_config_ctx,
|
|
configured_model=_sw_model_cfg.get("default") or _sw_model_cfg.get("model"),
|
|
configured_provider=_sw_model_cfg.get("provider"),
|
|
configured_base_url=_sw_model_cfg.get("base_url"),
|
|
)
|
|
if ctx:
|
|
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
|
|
if mi:
|
|
if mi.max_output:
|
|
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
|
|
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
|
|
|
|
if not picker:
|
|
cache_enabled = (
|
|
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
|
or result.api_mode == "anthropic_messages"
|
|
)
|
|
if cache_enabled:
|
|
lines.append(t("gateway.model.prompt_caching_enabled"))
|
|
|
|
if result.warning_message:
|
|
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
|
|
|
|
if persist_global:
|
|
lines.append(t("gateway.model.saved_global"))
|
|
elif one_turn:
|
|
lines.append(" (next turn only — restores after one response)")
|
|
else:
|
|
lines.append(t("gateway.model.session_only_hint"))
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
|
|
"""Handle /model command — switch model."""
|
|
from gateway.run import _hermes_home, _load_gateway_config
|
|
from hermes_cli.model_switch import (
|
|
switch_model as _switch_model, parse_model_switch_args,
|
|
resolve_persist_behavior,
|
|
list_authenticated_providers,
|
|
list_picker_providers,
|
|
)
|
|
from hermes_cli.providers import get_label
|
|
|
|
raw_args = event.get_command_args().strip()
|
|
source = event.source
|
|
_command_profile_home = None
|
|
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
|
_command_profile_home = getattr(
|
|
self, "_resolve_profile_home_for_source"
|
|
)(source)
|
|
|
|
# Parse --provider, --global, --session, --once, and --refresh flags
|
|
# via the shared single-owner parser (hermes_cli.model_switch).
|
|
request = parse_model_switch_args(raw_args)
|
|
model_input = request.target
|
|
explicit_provider = request.explicit_provider
|
|
is_global_flag = request.is_global
|
|
force_refresh = request.force_refresh
|
|
is_session = request.is_session
|
|
one_turn = request.is_once
|
|
if request.errors:
|
|
# Gateway decoration: "❌ " prefix over the canonical error copy.
|
|
return f"❌ {request.error_messages()[0]}"
|
|
persist_global = resolve_persist_behavior(
|
|
is_global_flag,
|
|
is_session,
|
|
is_once=one_turn,
|
|
explicit_provider=explicit_provider,
|
|
)
|
|
|
|
# --refresh: bust the disk cache so the picker shows live data.
|
|
if force_refresh:
|
|
try:
|
|
from hermes_cli.models import clear_provider_models_cache
|
|
clear_provider_models_cache()
|
|
except Exception:
|
|
pass
|
|
|
|
# Read current model/provider from config
|
|
current_model = ""
|
|
current_provider = "openrouter"
|
|
current_base_url = ""
|
|
current_api_key = ""
|
|
user_provs = None
|
|
custom_provs = None
|
|
excluded_provs = []
|
|
config_path = (_command_profile_home or _hermes_home) / "config.yaml"
|
|
try:
|
|
cfg = _load_gateway_config(config_path=config_path)
|
|
if cfg:
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
current_model = model_cfg.get("default", "")
|
|
current_provider = model_cfg.get("provider", current_provider)
|
|
current_base_url = model_cfg.get("base_url", "")
|
|
user_provs = cfg.get("providers")
|
|
try:
|
|
from hermes_cli.config import get_compatible_custom_providers
|
|
custom_provs = get_compatible_custom_providers(cfg)
|
|
except Exception:
|
|
custom_provs = cfg.get("custom_providers")
|
|
_excl = cfg.get("model_catalog", {}).get("excluded_providers")
|
|
if isinstance(_excl, list):
|
|
excluded_provs = _excl
|
|
except Exception:
|
|
pass
|
|
|
|
# Check for session override. Normalize the source the same way a normal message turn does
|
|
# (Telegram DM topic recovery) before deriving the override key, so the override is stored
|
|
# under the key the next message turn reads.
|
|
source = await asyncio.to_thread(self._normalize_source_for_session_key, source)
|
|
session_key = self._session_key_for_source(source)
|
|
override = self._session_model_overrides.get(session_key, {})
|
|
restore_snapshot = (
|
|
self._snapshot_session_model_override(session_key) if one_turn else None
|
|
)
|
|
if override:
|
|
current_model = override.get("model", current_model)
|
|
current_provider = override.get("provider", current_provider)
|
|
current_base_url = override.get("base_url", current_base_url)
|
|
current_api_key = override.get("api_key", current_api_key)
|
|
|
|
# No args: show interactive picker (Telegram/Discord) or text list
|
|
if not model_input and not explicit_provider:
|
|
# Try interactive picker if the platform supports it
|
|
adapter = getattr(self, "_adapter_for_source")(source)
|
|
has_picker = (
|
|
adapter is not None
|
|
and getattr(type(adapter), "send_model_picker", None) is not None
|
|
)
|
|
|
|
if has_picker:
|
|
try:
|
|
# Offload blocking provider-listing (can fall through to a
|
|
# synchronous urllib HTTP fetch on a stale cache) off the
|
|
# event loop so the gateway doesn't freeze. See #41289.
|
|
providers = await asyncio.to_thread(
|
|
list_picker_providers,
|
|
current_provider=current_provider,
|
|
current_base_url=current_base_url,
|
|
current_model=current_model,
|
|
user_providers=user_provs,
|
|
custom_providers=custom_provs,
|
|
max_models=50,
|
|
include_moa=True,
|
|
excluded_providers=excluded_provs,
|
|
)
|
|
except Exception:
|
|
providers = []
|
|
|
|
if providers:
|
|
# Build a callback closure for when the user picks a model.
|
|
# Captures self + locals needed for the switch logic.
|
|
_self = self
|
|
_session_key = session_key
|
|
_cur_model = current_model
|
|
_cur_provider = current_provider
|
|
_cur_base_url = current_base_url
|
|
_cur_api_key = current_api_key
|
|
_picker_profile_home = _command_profile_home
|
|
|
|
async def _on_model_selected_scoped(
|
|
_chat_id: str, model_id: str, provider_slug: str
|
|
) -> str:
|
|
"""Perform the model switch and return confirmation text."""
|
|
result, error = await _self._perform_model_switch(
|
|
_switch_model,
|
|
raw_input=model_id,
|
|
explicit_provider=provider_slug,
|
|
session_key=_session_key,
|
|
source=event.source,
|
|
current_model=_cur_model,
|
|
current_provider=_cur_provider,
|
|
current_base_url=_cur_base_url,
|
|
current_api_key=_cur_api_key,
|
|
persist_global=persist_global,
|
|
user_provs=user_provs,
|
|
custom_provs=custom_provs,
|
|
)
|
|
if error is not None:
|
|
return error
|
|
return await _self._commit_model_switch(
|
|
result,
|
|
session_key=_session_key,
|
|
source=event.source,
|
|
current_model=_cur_model,
|
|
current_base_url=_cur_base_url,
|
|
current_api_key=_cur_api_key,
|
|
custom_provs=custom_provs,
|
|
persist_global=persist_global,
|
|
config_path=config_path,
|
|
picker=True,
|
|
)
|
|
|
|
async def _on_model_selected(
|
|
_chat_id: str, model_id: str, provider_slug: str
|
|
) -> str:
|
|
if _picker_profile_home is None:
|
|
return await _on_model_selected_scoped(
|
|
_chat_id, model_id, provider_slug
|
|
)
|
|
from gateway.run import _profile_runtime_scope
|
|
|
|
with _profile_runtime_scope(_picker_profile_home):
|
|
return await _on_model_selected_scoped(
|
|
_chat_id, model_id, provider_slug
|
|
)
|
|
|
|
metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
|
|
result = await adapter.send_model_picker(
|
|
chat_id=source.chat_id,
|
|
providers=providers,
|
|
current_model=current_model,
|
|
current_provider=current_provider,
|
|
session_key=session_key,
|
|
on_model_selected=_on_model_selected,
|
|
metadata=metadata,
|
|
)
|
|
if result.success:
|
|
return None # Picker sent — adapter handles the response
|
|
|
|
# Fallback: text list (for platforms without picker or if picker failed)
|
|
provider_label = get_label(current_provider)
|
|
lines = [t("gateway.model.current_label", model=current_model or "unknown", provider=provider_label), ""]
|
|
|
|
try:
|
|
# Offload blocking provider-listing off the event loop so the
|
|
# gateway doesn't freeze on a stale-cache HTTP fetch. See #41289.
|
|
providers = await asyncio.to_thread(
|
|
list_authenticated_providers,
|
|
current_provider=current_provider,
|
|
current_base_url=current_base_url,
|
|
current_model=current_model,
|
|
user_providers=user_provs,
|
|
custom_providers=custom_provs,
|
|
max_models=5,
|
|
excluded_providers=excluded_provs,
|
|
)
|
|
for p in providers:
|
|
tag = t("gateway.model.current_tag") if p["is_current"] else ""
|
|
lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:")
|
|
if p["models"]:
|
|
model_strs = ", ".join(f"`{m}`" for m in p["models"])
|
|
extra = t("gateway.model.more_models_suffix", count=p["total_models"] - len(p["models"])) if p["total_models"] > len(p["models"]) else ""
|
|
lines.append(f" {model_strs}{extra}")
|
|
elif p.get("api_url"):
|
|
lines.append(f" `{p['api_url']}`")
|
|
lines.append("")
|
|
except Exception:
|
|
pass
|
|
|
|
lines.append(t("gateway.model.usage_switch_model"))
|
|
lines.append(t("gateway.model.usage_switch_provider"))
|
|
lines.append(t("gateway.model.usage_persist"))
|
|
return "\n".join(lines)
|
|
|
|
# Perform the switch
|
|
result, error = await self._perform_model_switch(
|
|
_switch_model,
|
|
raw_input=model_input,
|
|
explicit_provider=explicit_provider,
|
|
session_key=session_key,
|
|
source=source,
|
|
current_model=current_model,
|
|
current_provider=current_provider,
|
|
current_base_url=current_base_url,
|
|
current_api_key=current_api_key,
|
|
persist_global=persist_global,
|
|
user_provs=user_provs,
|
|
custom_provs=custom_provs,
|
|
)
|
|
if error is not None:
|
|
return error
|
|
|
|
async def _finish_switch() -> str:
|
|
"""Apply the resolved switch (agent, session, config) and build the reply."""
|
|
return await self._commit_model_switch(
|
|
result,
|
|
session_key=session_key,
|
|
source=source,
|
|
current_model=current_model,
|
|
current_base_url=current_base_url,
|
|
current_api_key=current_api_key,
|
|
custom_provs=custom_provs,
|
|
persist_global=persist_global,
|
|
config_path=config_path,
|
|
one_turn=one_turn,
|
|
restore_snapshot=restore_snapshot,
|
|
)
|
|
|
|
# Selection-guard confirmation for the typed /model <name> path (pickers confirm via their own
|
|
# UI). Runs the unified registry (cost + data-policy guards); pricing lookups may hit
|
|
# models.dev or a /models endpoint on a cache miss, so run it off the event loop.
|
|
_cost_warning = None
|
|
try:
|
|
from hermes_cli.model_selection_guards import combined_selection_warning
|
|
|
|
_cost_warning = await asyncio.to_thread(
|
|
combined_selection_warning,
|
|
result.new_model,
|
|
provider=result.target_provider,
|
|
base_url=result.base_url or current_base_url or "",
|
|
api_key=result.api_key or current_api_key or "",
|
|
model_info=result.model_info,
|
|
)
|
|
except Exception:
|
|
_cost_warning = None
|
|
if _cost_warning is not None:
|
|
async def _on_cost_confirm(choice: str) -> str:
|
|
if choice == "cancel":
|
|
return (
|
|
f"🟡 Model switch cancelled. Current model unchanged "
|
|
f"({current_model or 'unknown'})."
|
|
)
|
|
# "once" and "always" both proceed — there is no persistent
|
|
# opt-out for selection guards (each guarded switch should be
|
|
# an explicit decision).
|
|
return await _finish_switch()
|
|
|
|
_p = self._typed_command_prefix_for(event.source.platform)
|
|
return await self._request_slash_confirm(
|
|
event=event,
|
|
command="model",
|
|
title=_cost_warning.title,
|
|
message=(
|
|
f"⚠️ **{_cost_warning.title}**\n\n{_cost_warning.message}\n\n"
|
|
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
|
|
"the current model._"
|
|
),
|
|
handler=_on_cost_confirm,
|
|
)
|
|
|
|
return await _finish_switch()
|
|
|
|
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
|
|
"""Handle /codex-runtime command in the gateway.
|
|
|
|
On change the cached agent is evicted so the next message builds a fresh AIAgent with the
|
|
new api_mode (avoids prompt-cache invalidation mid-session).
|
|
"""
|
|
from hermes_cli import codex_runtime_switch as crs
|
|
|
|
raw_args = event.get_command_args().strip() if event else ""
|
|
new_value, errors = crs.parse_args(raw_args)
|
|
if errors:
|
|
return "❌ " + "\n❌ ".join(errors)
|
|
|
|
# Load + persist via the same helpers used for /model and /yolo
|
|
try:
|
|
from hermes_cli.config import load_config, save_config
|
|
except Exception as exc:
|
|
return f"❌ Could not load config: {exc}"
|
|
cfg = load_config()
|
|
|
|
result = crs.apply(
|
|
cfg,
|
|
new_value,
|
|
persist_callback=(save_config if new_value is not None else None),
|
|
)
|
|
|
|
# On a real change, evict the cached agent so the new runtime takes
|
|
# effect on the next message rather than waiting for cache TTL.
|
|
if result.success and new_value is not None and result.requires_new_session:
|
|
try:
|
|
session_key = self._session_key_for_source(event.source)
|
|
self._evict_cached_agent(session_key)
|
|
except Exception:
|
|
logger.debug("could not evict cached agent after codex-runtime change",
|
|
exc_info=True)
|
|
|
|
prefix = "✓" if result.success else "✗"
|
|
return f"{prefix} {result.message}"
|
|
|
|
async def _handle_personality_command(self, event: MessageEvent) -> str:
|
|
"""Handle /personality command - list or set a personality.
|
|
|
|
All resolution/persistence goes through hermes_cli.personality, the single owner of state.
|
|
"""
|
|
from gateway.run import _load_gateway_config
|
|
from hermes_cli.personality import (
|
|
active_personality_name,
|
|
available_personalities,
|
|
describe_personality,
|
|
persist_personality,
|
|
resolve_personality,
|
|
)
|
|
|
|
args = event.get_command_args().strip()
|
|
|
|
try:
|
|
config = _load_gateway_config()
|
|
except Exception:
|
|
config = {}
|
|
personalities = available_personalities(config)
|
|
|
|
if not args:
|
|
current = active_personality_name(config)
|
|
lines = [t("gateway.personality.header")]
|
|
lines.append(t("gateway.personality.none_option"))
|
|
for name, prompt in personalities.items():
|
|
marker = " ✓" if name == current else ""
|
|
lines.append(
|
|
t(
|
|
"gateway.personality.item",
|
|
name=f"{name}{marker}",
|
|
preview=describe_personality(prompt),
|
|
)
|
|
)
|
|
lines.append(t("gateway.personality.usage"))
|
|
return "\n".join(lines)
|
|
|
|
try:
|
|
name, _new_prompt = resolve_personality(args, config)
|
|
except ValueError:
|
|
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
|
|
return t("gateway.personality.unknown", name=args.lower(), available=available)
|
|
|
|
# Persist the selection only — hermes_cli.personality never writes agent.system_prompt (user-
|
|
# owned overlay). persist_personality writes get_hermes_home()/config.yaml (the routed profile
|
|
# under multiplex) and the next turn re-resolves the prompt from it: no process-global state.
|
|
if not persist_personality(name):
|
|
return t("gateway.personality.save_failed", error="config write failed")
|
|
|
|
if not name:
|
|
return t("gateway.personality.cleared")
|
|
return t("gateway.personality.set_to", name=name)
|
|
|
|
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.
|
|
|
|
Subcommands: status / pause / resume / clear. Setting a new goal queues the goal text as the
|
|
next turn so the agent starts immediately; the post-turn continuation hook takes over after.
|
|
"""
|
|
args = (event.get_command_args() or "").strip()
|
|
lower = args.lower()
|
|
|
|
mgr, session_entry = await self._get_goal_manager_for_event(event)
|
|
if mgr is None:
|
|
return t("gateway.goal.unavailable")
|
|
|
|
if not args or lower == "status":
|
|
return mgr.status_line()
|
|
|
|
# /goal show → print the active goal's completion contract
|
|
if lower == "show":
|
|
return f"{mgr.status_line()}\n{mgr.render_contract()}"
|
|
|
|
if lower == "pause":
|
|
state = mgr.pause(reason="user-paused")
|
|
if state is None:
|
|
return t("gateway.goal.no_goal_set")
|
|
try:
|
|
adapter = self.adapters.get(event.source.platform) if event.source else None
|
|
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if adapter and _quick_key:
|
|
self._clear_goal_pending_continuations(_quick_key, adapter)
|
|
except Exception as exc:
|
|
logger.debug("goal pause: pending continuation cleanup failed: %s", exc)
|
|
return t("gateway.goal.paused", goal=state.goal)
|
|
|
|
if lower == "resume":
|
|
state = mgr.resume()
|
|
if state is None:
|
|
return t("gateway.goal.no_resume")
|
|
# Resume must restart work, not just flip persisted state: enqueue the canonical
|
|
# continuation through the adapter FIFO — the same path the post-turn judge uses — so
|
|
# the next turn fires as soon as this reply is delivered.
|
|
prompt = mgr.next_continuation_prompt()
|
|
try:
|
|
adapter = self.adapters.get(event.source.platform) if event.source else None
|
|
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if prompt and adapter and _quick_key:
|
|
cont_event = MessageEvent(
|
|
text=prompt,
|
|
message_type=MessageType.TEXT,
|
|
source=event.source,
|
|
message_id=None,
|
|
channel_prompt=None,
|
|
)
|
|
self._enqueue_fifo(_quick_key, cont_event, adapter)
|
|
except Exception as exc:
|
|
logger.debug("goal resume: continuation enqueue failed: %s", exc)
|
|
return t("gateway.goal.resumed", goal=state.goal)
|
|
|
|
if lower in {"clear", "stop", "done"}:
|
|
had = mgr.has_goal()
|
|
mgr.clear()
|
|
try:
|
|
adapter = self.adapters.get(event.source.platform) if event.source else None
|
|
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if adapter and _quick_key:
|
|
self._clear_goal_pending_continuations(_quick_key, adapter)
|
|
except Exception as exc:
|
|
logger.debug("goal clear: pending continuation cleanup failed: %s", exc)
|
|
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
|
|
|
|
# /goal wait <pid> [reason] — park the loop on a background process.
|
|
if lower == "wait" or lower.startswith("wait "):
|
|
wait_arg = args[len("wait"):].strip()
|
|
if not wait_arg:
|
|
return "Usage: /goal wait <pid> [reason]"
|
|
wtokens = wait_arg.split(None, 1)
|
|
try:
|
|
pid = int(wtokens[0])
|
|
except ValueError:
|
|
return "/goal wait: <pid> must be an integer process id."
|
|
reason = wtokens[1].strip() if len(wtokens) > 1 else ""
|
|
try:
|
|
mgr.wait_on(pid, reason=reason)
|
|
except (RuntimeError, ValueError) as exc:
|
|
return f"/goal wait: {exc}"
|
|
rtxt = f" ({reason})" if reason else ""
|
|
return f"⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits."
|
|
|
|
# /goal unwait — clear the wait barrier.
|
|
if lower == "unwait":
|
|
if mgr.stop_waiting():
|
|
return "▶ Wait barrier cleared — goal loop resumes."
|
|
return "No wait barrier set."
|
|
|
|
# /goal gate ... — manage deterministic quality gates.
|
|
if lower == "gate" or lower.startswith("gate "):
|
|
gate_arg = args[len("gate"):].strip()
|
|
gate_lower = gate_arg.lower()
|
|
if not gate_arg or gate_lower == "list":
|
|
return mgr.render_gates()
|
|
if gate_lower.startswith("add "):
|
|
# SECURITY: a gate is persisted and later executed with
|
|
# shell=True at every goal turn boundary (run_gate), with no
|
|
# approval prompt. Letting an allowed but non-admin gateway
|
|
# sender choose that string is authenticated RCE under the
|
|
# Hermes process account — and with no admin list configured
|
|
# (the backward-compatible default) every allowed sender is
|
|
# treated as unrestricted. Gate ONLY this shell-creating
|
|
# operation behind a real, explicitly-configured admin (the
|
|
# same fail-closed check that guards cross-origin /resume);
|
|
# list/remove/clear stay open so a non-admin can still recover.
|
|
if not self._resume_caller_is_admin(event.source):
|
|
return (
|
|
"⛔ /goal gate add requires an explicitly configured "
|
|
"gateway admin (allow_admin_from for DMs, "
|
|
"group_allow_admin_from for groups)."
|
|
)
|
|
command = gate_arg[len("add"):].strip()
|
|
try:
|
|
gate = mgr.add_gate(command)
|
|
except (RuntimeError, ValueError) as exc:
|
|
return f"/goal gate add: {exc}"
|
|
return (
|
|
f"⚿ Gate added: $ {gate.command} "
|
|
f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). "
|
|
f"It must pass before the goal can complete."
|
|
)
|
|
if gate_lower.startswith("remove ") or gate_lower.startswith("rm "):
|
|
idx_text = gate_arg.split(None, 1)[1].strip()
|
|
try:
|
|
removed = mgr.remove_gate(int(idx_text))
|
|
except (RuntimeError, ValueError, IndexError) as exc:
|
|
return f"/goal gate remove: {exc}"
|
|
return f"✓ Gate removed: $ {removed}"
|
|
if gate_lower == "clear":
|
|
try:
|
|
prev = mgr.clear_gates()
|
|
except RuntimeError as exc:
|
|
return f"/goal gate clear: {exc}"
|
|
return f"✓ Cleared {prev} gate{'s' if prev != 1 else ''}."
|
|
return "Usage: /goal gate [list | add <command> | remove <N> | clear]"
|
|
|
|
# /goal draft <objective> → draft a structured completion contract,
|
|
# then set it. The aux LLM call is sync; run it off the event loop.
|
|
draft_contract_obj = None
|
|
if lower.startswith("draft"):
|
|
objective = args[len("draft"):].strip()
|
|
if not objective:
|
|
return "Usage: /goal draft <objective in plain language>"
|
|
try:
|
|
from hermes_cli.goals import draft_contract
|
|
|
|
# _run_in_executor_with_context, not a bare hop: drafting a contract calls the
|
|
# auxiliary LLM, whose provider/credential resolution reads the profile secret scope
|
|
# — a contextvar that a default-executor hop drops, leaving it unscoped.
|
|
draft_contract_obj = await self._run_in_executor_with_context(
|
|
draft_contract, objective
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("goal draft failed: %s", exc)
|
|
draft_contract_obj = None
|
|
args = objective # the goal text is the objective
|
|
contract = draft_contract_obj
|
|
else:
|
|
# Inline `field: value` lines parse into a completion contract;
|
|
# the remaining prose is the goal headline. Plain free-form goals
|
|
# (no such lines) behave exactly as before.
|
|
from hermes_cli.goals import parse_contract
|
|
|
|
headline, parsed = parse_contract(args)
|
|
args = headline or args
|
|
contract = parsed if not parsed.is_empty() else None
|
|
|
|
# Otherwise — treat the remaining text as the new goal.
|
|
try:
|
|
state = mgr.set(args, contract=contract)
|
|
except ValueError as exc:
|
|
return t("gateway.goal.invalid", error=str(exc))
|
|
|
|
# Queue the goal text as an immediate first turn so the agent
|
|
# starts making progress. The post-turn hook takes over after.
|
|
adapter = self.adapters.get(event.source.platform) if event.source else None
|
|
_quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if adapter and _quick_key:
|
|
try:
|
|
kickoff_event = MessageEvent(
|
|
text=state.goal,
|
|
message_type=MessageType.TEXT,
|
|
source=event.source,
|
|
message_id=event.message_id,
|
|
channel_prompt=event.channel_prompt,
|
|
)
|
|
self._enqueue_fifo(_quick_key, kickoff_event, adapter)
|
|
except Exception as exc:
|
|
logger.debug("goal kickoff enqueue failed: %s", exc)
|
|
|
|
base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
|
|
if state.has_contract():
|
|
return f"{base}\nCompletion contract:\n{state.contract.render_block()}"
|
|
if lower.startswith("draft"):
|
|
# Drafting was requested but the aux model couldn't produce one.
|
|
return f"{base}\n(Couldn't draft a contract — running as a free-form goal.)"
|
|
return base
|
|
|
|
async def _handle_heartbeat_command(self, event: "MessageEvent") -> str:
|
|
"""Handle /heartbeat for gateway platforms (mirror of CLI handler).
|
|
|
|
Manages the session's one recurring re-entry prompt. The gateway-wide poller injects due
|
|
heartbeats through the adapter FIFO as ordinary user turns, so alternation and caching hold.
|
|
"""
|
|
from hermes_cli.heartbeat import parse_interval, format_interval, MIN_INTERVAL_SECONDS
|
|
|
|
args = (event.get_command_args() or "").strip()
|
|
lower = args.lower()
|
|
|
|
mgr, session_entry = await self._get_heartbeat_manager_for_event(event)
|
|
if mgr is None:
|
|
return "Heartbeats unavailable (no session)."
|
|
|
|
quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
|
|
if not args or lower == "status":
|
|
return mgr.status_line()
|
|
|
|
if lower == "pause":
|
|
state = mgr.pause()
|
|
return f"⏸ Heartbeat paused: {state.prompt}" if state else "No heartbeat set."
|
|
|
|
if lower == "resume":
|
|
state = mgr.resume()
|
|
if state is None:
|
|
return "No heartbeat to resume."
|
|
if quick_key and event.source is not None:
|
|
self._register_heartbeat_watch(quick_key, event.source, mgr.session_id)
|
|
return f"▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}"
|
|
|
|
if lower in {"clear", "stop", "off"}:
|
|
had = mgr.clear()
|
|
if quick_key:
|
|
self._unregister_heartbeat_watch(quick_key)
|
|
return "✓ Heartbeat cleared." if had else "No heartbeat set."
|
|
|
|
# Set: `/heartbeat every 10m <prompt>` (also accepts `10m <prompt>`).
|
|
tokens = args.split(None, 2)
|
|
interval = None
|
|
prompt = ""
|
|
if tokens and tokens[0].lower() == "every" and len(tokens) >= 2:
|
|
interval = parse_interval(f"every {tokens[1]}")
|
|
prompt = tokens[2] if len(tokens) > 2 else ""
|
|
elif tokens:
|
|
interval = parse_interval(tokens[0])
|
|
prompt = args[len(tokens[0]):].strip() if interval and interval > 0 else ""
|
|
|
|
if interval is None:
|
|
return (
|
|
"Usage: /heartbeat every <interval> <prompt> (e.g. /heartbeat every 10m Check CI)\n"
|
|
"Also: /heartbeat status | pause | resume | clear"
|
|
)
|
|
if interval < 0:
|
|
return f"Interval too small — minimum is {MIN_INTERVAL_SECONDS}s."
|
|
if not prompt.strip():
|
|
return "Usage: /heartbeat every <interval> <prompt> — the prompt is required."
|
|
|
|
try:
|
|
state = mgr.set(prompt, interval)
|
|
except ValueError as exc:
|
|
return f"Invalid heartbeat: {exc}"
|
|
if quick_key and event.source is not None:
|
|
self._register_heartbeat_watch(quick_key, event.source, mgr.session_id)
|
|
return (
|
|
f"♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}\n"
|
|
"Fires as a normal turn whenever this session is idle and the interval has "
|
|
"elapsed. Lives while the gateway runs — use `hermes cron` for durable schedules."
|
|
)
|
|
|
|
async def _handle_refine_command(self, event: "MessageEvent") -> str:
|
|
"""Handle /refine — run the memory/skill review fork on demand.
|
|
|
|
Runs in a daemon thread against a snapshot of the cached AIAgent's conversation; the live
|
|
session and prompt cache are untouched. Requires at least one completed turn.
|
|
"""
|
|
args = (event.get_command_args() or "").strip()
|
|
quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if not quick_key:
|
|
return "Refine unavailable (no session)."
|
|
if quick_key in self._running_agents:
|
|
return "Agent is running — wait for the turn to finish, then /refine."
|
|
|
|
agent = None
|
|
cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
if cache_lock is not None:
|
|
with cache_lock:
|
|
cached = self._agent_cache.get(quick_key)
|
|
agent = cached[0] if isinstance(cached, tuple) else cached if cached else None
|
|
if agent is None:
|
|
return "Nothing to refine yet — send a message first."
|
|
|
|
snapshot = list(getattr(agent, "_session_messages", None) or [])
|
|
if not snapshot:
|
|
return "Nothing to refine yet — the conversation is empty."
|
|
|
|
review_skills = "skill_manage" in getattr(agent, "valid_tool_names", set())
|
|
try:
|
|
agent._spawn_background_review(
|
|
messages_snapshot=snapshot,
|
|
review_memory=True,
|
|
review_skills=review_skills,
|
|
focus=args or None,
|
|
)
|
|
except Exception as exc:
|
|
return f"/refine failed to start: {exc}"
|
|
tail = f" (focus: {args})" if args else ""
|
|
return (
|
|
f"⚗ Reviewing this conversation in the background{tail} — "
|
|
f"any memory/skill updates will be reported when done."
|
|
)
|
|
|
|
async def _handle_review_command(self, event: "MessageEvent") -> str:
|
|
"""Handle /review — spawn an independent reviewer subagent.
|
|
|
|
The approval session-key contextvar is only bound during agent turns, so bind it explicitly
|
|
here or the completion event carries no gateway route and never re-enters this chat.
|
|
"""
|
|
args = (event.get_command_args() or "").strip()
|
|
quick_key = self._session_key_for_source(event.source) if event.source else None
|
|
if not quick_key:
|
|
return "Review unavailable (no session)."
|
|
if quick_key in self._running_agents:
|
|
return "Agent is running — wait for the turn to finish, then /review."
|
|
|
|
agent = None
|
|
cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
if cache_lock is not None:
|
|
with cache_lock:
|
|
cached = self._agent_cache.get(quick_key)
|
|
agent = cached[0] if isinstance(cached, tuple) else cached if cached else None
|
|
if agent is None:
|
|
return "Nothing to review yet — send a message first."
|
|
|
|
snapshot = list(getattr(agent, "_session_messages", None) or [])
|
|
|
|
from tools.approval import (
|
|
reset_current_session_key,
|
|
set_current_session_key,
|
|
)
|
|
|
|
def _dispatch():
|
|
token = set_current_session_key(quick_key)
|
|
try:
|
|
from agent.review_engine import start_review
|
|
|
|
return start_review(agent, snapshot, args)
|
|
finally:
|
|
reset_current_session_key(token)
|
|
|
|
try:
|
|
# _run_in_executor_with_context, not a bare hop: the reviewer
|
|
# subagent is spawned from the worker and inherits its context,
|
|
# so a bare hop would run it under the launch home / no secret scope.
|
|
result = await self._run_in_executor_with_context(_dispatch)
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
except Exception as exc:
|
|
return f"/review failed to start: {exc}"
|
|
|
|
from agent.review_engine import format_dispatch_note
|
|
|
|
return format_dispatch_note(result, args)
|
|
|
|
async def _handle_subgoal_command(self, event: "MessageEvent") -> str:
|
|
"""Handle /subgoal for gateway platforms (mirror of CLI handler).
|
|
|
|
Subgoals are extra criteria appended to the active goal mid-loop. They modify state read
|
|
at the next turn boundary, so this is safe to invoke while the agent is running.
|
|
"""
|
|
args = (event.get_command_args() or "").strip()
|
|
mgr, _session_entry = await self._get_goal_manager_for_event(event)
|
|
if mgr is None:
|
|
return t("gateway.goal.unavailable")
|
|
if not mgr.has_goal():
|
|
return "No active goal. Set one with /goal <text>."
|
|
|
|
# No args → list current subgoals.
|
|
if not args:
|
|
return f"{mgr.status_line()}\n{mgr.render_subgoals()}"
|
|
|
|
tokens = args.split(None, 1)
|
|
verb = tokens[0].lower()
|
|
rest = tokens[1].strip() if len(tokens) > 1 else ""
|
|
|
|
if verb == "remove":
|
|
if not rest:
|
|
return "Usage: /subgoal remove <n>"
|
|
try:
|
|
idx = int(rest.split()[0])
|
|
except ValueError:
|
|
return "/subgoal remove: <n> must be an integer (1-based index)."
|
|
try:
|
|
removed = mgr.remove_subgoal(idx)
|
|
except (IndexError, RuntimeError) as exc:
|
|
return f"/subgoal remove: {exc}"
|
|
return f"✓ Removed subgoal {idx}: {removed}"
|
|
|
|
if verb == "clear":
|
|
try:
|
|
prev = mgr.clear_subgoals()
|
|
except RuntimeError as exc:
|
|
return f"/subgoal clear: {exc}"
|
|
if prev:
|
|
return f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}."
|
|
return "No subgoals to clear."
|
|
|
|
try:
|
|
text = mgr.add_subgoal(args)
|
|
except (ValueError, RuntimeError) as exc:
|
|
return f"/subgoal: {exc}"
|
|
idx = len(mgr.state.subgoals) if mgr.state else 0
|
|
return f"✓ Added subgoal {idx}: {text}"
|
|
|
|
async def _get_loop_manager_for_event(self, event: "MessageEvent"):
|
|
"""Return a LoopManager bound to the session for this gateway event.
|
|
|
|
Returns ``(manager, session_entry)``, or ``(None, None)`` when the loops module or session
|
|
can't be loaded. Mirrors ``_get_goal_manager_for_event``.
|
|
"""
|
|
try:
|
|
from hermes_cli.loops import LoopManager
|
|
except Exception as exc:
|
|
logger.debug("loop manager unavailable: %s", exc)
|
|
return None, None
|
|
# Warm the SessionDB cache off-loop. A cold cache drops the first
|
|
# /loop write while the reply claims the loop was set (same class
|
|
# as the /goal false-ack fix).
|
|
await self._warm_goals_session_db("loop manager")
|
|
try:
|
|
session_entry = await self.async_session_store.get_or_create_session(event.source)
|
|
except Exception:
|
|
return None, None
|
|
sid = getattr(session_entry, "session_id", None) or ""
|
|
if not sid:
|
|
return None, None
|
|
return LoopManager(session_id=sid), session_entry
|
|
|
|
async def _handle_loop_command(self, event: "MessageEvent") -> str:
|
|
"""Handle /loop for gateway platforms — recurring in-session wakeups.
|
|
|
|
Mirrors the CLI handler via ``dispatch_loop_command``. New loops capture the event's routing
|
|
(platform/chat/thread) so the idle loop-wakeup watcher can inject ticks here after a restart.
|
|
"""
|
|
try:
|
|
from hermes_cli.loops import dispatch_loop_command, goal_blocks_loop_tick
|
|
except Exception as exc:
|
|
logger.debug("loops module unavailable: %s", exc)
|
|
return "Loops unavailable."
|
|
|
|
mgr, _session_entry = await self._get_loop_manager_for_event(event)
|
|
if mgr is None:
|
|
return "Loops unavailable (no active session)."
|
|
|
|
route: dict = {}
|
|
try:
|
|
src = event.source
|
|
if src is not None:
|
|
platform = getattr(src, "platform", "")
|
|
route = {
|
|
"platform": platform.value if hasattr(platform, "value") else str(platform or ""),
|
|
"chat_id": str(getattr(src, "chat_id", "") or ""),
|
|
"chat_type": str(getattr(src, "chat_type", "") or ""),
|
|
"thread_id": str(getattr(src, "thread_id", "") or ""),
|
|
"user_id": str(getattr(src, "user_id", "") or ""),
|
|
"user_name": str(getattr(src, "user_name", "") or ""),
|
|
}
|
|
route = {k: v for k, v in route.items() if v}
|
|
except Exception:
|
|
route = {}
|
|
|
|
args = (event.get_command_args() or "").strip()
|
|
result = dispatch_loop_command(mgr, args, route=route)
|
|
output = result.get("output") or ""
|
|
if result.get("created"):
|
|
try:
|
|
if goal_blocks_loop_tick(mgr.session_id):
|
|
output += (
|
|
"\nNote: an active /goal is driving this session — loop "
|
|
"wakeups defer until the goal finishes, pauses, or parks."
|
|
)
|
|
except Exception:
|
|
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."""
|
|
from gateway.run import _home_target_env_var, _home_thread_env_var
|
|
source = event.source
|
|
platform_name = source.platform.value if source.platform else "unknown"
|
|
chat_id = source.chat_id
|
|
chat_name = source.chat_name or chat_id
|
|
if source.platform is None:
|
|
return t("gateway.set_home.save_failed", error="Missing logical platform")
|
|
|
|
via_relay = getattr(source, "delivered_via_upstream_relay", False) is True
|
|
if via_relay:
|
|
adapter_for_source = getattr(self, "_adapter_for_source", None)
|
|
relay_adapter = adapter_for_source(source) if callable(adapter_for_source) else None
|
|
fronts_platform = getattr(relay_adapter, "fronts_platform", None)
|
|
if (
|
|
source.platform in {None, Platform.LOCAL, Platform.RELAY}
|
|
or not getattr(source, "user_id", None)
|
|
or not callable(fronts_platform)
|
|
or not fronts_platform(source.platform)
|
|
):
|
|
return t(
|
|
"gateway.set_home.save_failed",
|
|
error="Relay does not authenticate this logical home target",
|
|
)
|
|
|
|
thread_id = _home_thread_from_source(source)
|
|
home = HomeChannel(
|
|
platform=source.platform,
|
|
chat_id=str(chat_id),
|
|
name=chat_name,
|
|
thread_id=str(thread_id) if thread_id else None,
|
|
user_id=(
|
|
str(source.user_id)
|
|
if getattr(source, "user_id", None)
|
|
else None
|
|
),
|
|
scope_id=(
|
|
str(source.scope_id)
|
|
if getattr(source, "scope_id", None)
|
|
else None
|
|
),
|
|
)
|
|
|
|
# config.yaml is canonical because it can persist the authenticated
|
|
# logical-target provenance required by Relay after a restart.
|
|
try:
|
|
persist_home_channel(home, enabled_if_new=not via_relay)
|
|
except Exception as e:
|
|
return t("gateway.set_home.save_failed", error=e)
|
|
|
|
# Preserve legacy home env vars for existing cron/setup consumers.
|
|
env_key = _home_target_env_var(platform_name)
|
|
thread_env_key = _home_thread_env_var(platform_name)
|
|
try:
|
|
from hermes_cli.config import save_env_value
|
|
save_env_value(env_key, str(chat_id))
|
|
save_env_value(thread_env_key, str(thread_id or ""))
|
|
except Exception as e:
|
|
logger.warning("Home config saved but legacy env persistence failed: %s", e)
|
|
|
|
# Keep the running gateway config in sync too. The pre-restart
|
|
# notification path reads self.config before the process reloads config.
|
|
platform_config = getattr(self, "config").platforms.setdefault(
|
|
source.platform,
|
|
PlatformConfig(enabled=not via_relay),
|
|
)
|
|
platform_config.home_channel = home
|
|
|
|
return t("gateway.set_home.success", name=chat_name, chat_id=chat_id)
|
|
|
|
async def _handle_voice_command(self, event: MessageEvent) -> str:
|
|
"""Handle /voice [on|off|tts|channel|leave|status] command."""
|
|
args = event.get_command_args().strip().lower()
|
|
chat_id = event.source.chat_id
|
|
# Voice state belongs to the (bot, chat) pair: resolve the adapter that
|
|
# received the command and key the mode by its owning profile so two
|
|
# multiplexed bots in one chat keep independent /voice state (#75198).
|
|
voice_key = self._voice_key_for_source(event.source)
|
|
|
|
adapter = self._adapter_for_source(event.source)
|
|
|
|
if args in {"on", "enable"}:
|
|
self._voice_mode[voice_key] = "voice_only"
|
|
self._save_voice_modes()
|
|
if adapter:
|
|
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
|
return t("gateway.voice.enabled_voice_only")
|
|
elif args in {"off", "disable"}:
|
|
self._voice_mode[voice_key] = "off"
|
|
self._save_voice_modes()
|
|
if adapter:
|
|
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
|
return t("gateway.voice.disabled_text")
|
|
elif args == "tts":
|
|
self._voice_mode[voice_key] = "all"
|
|
self._save_voice_modes()
|
|
if adapter:
|
|
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
|
return t("gateway.voice.tts_enabled")
|
|
elif args in {"channel", "join"}:
|
|
return await self._handle_voice_channel_join(event)
|
|
elif args == "leave":
|
|
return await self._handle_voice_channel_leave(event)
|
|
elif args == "status":
|
|
mode = self._voice_mode.get(voice_key, "off")
|
|
labels = {
|
|
"off": t("gateway.voice.label_off"),
|
|
"voice_only": t("gateway.voice.label_voice_only"),
|
|
"all": t("gateway.voice.label_all"),
|
|
}
|
|
# Append voice channel info if connected
|
|
guild_id = self._get_guild_id(event)
|
|
if guild_id and hasattr(adapter, "get_voice_channel_info"):
|
|
info = adapter.get_voice_channel_info(guild_id)
|
|
if info:
|
|
lines = [
|
|
t("gateway.voice.status_mode", label=labels.get(mode, mode)),
|
|
t("gateway.voice.status_channel", channel=info['channel_name']),
|
|
t("gateway.voice.status_participants", count=info['member_count']),
|
|
]
|
|
for m in info["members"]:
|
|
status = t("gateway.voice.speaking") if m.get("is_speaking") else ""
|
|
lines.append(t("gateway.voice.status_member", name=m['display_name'], status=status))
|
|
return "\n".join(lines)
|
|
return t("gateway.voice.status_mode", label=labels.get(mode, mode))
|
|
else:
|
|
# Toggle: off → on, on/all → off
|
|
current = self._voice_mode.get(voice_key, "off")
|
|
if current == "off":
|
|
self._voice_mode[voice_key] = "voice_only"
|
|
self._save_voice_modes()
|
|
if adapter:
|
|
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
|
toggle_line = t("gateway.voice.enabled_short")
|
|
else:
|
|
self._voice_mode[voice_key] = "off"
|
|
self._save_voice_modes()
|
|
if adapter:
|
|
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
|
toggle_line = t("gateway.voice.disabled_short")
|
|
# Bare /voice still toggles, but append an explainer so users discover the
|
|
# on/off/tts/status subcommands (and, on Discord, live voice-channel join/leave). The
|
|
# toggle result is shown first via the {toggle} placeholder.
|
|
supports_voice_channels = adapter is not None and hasattr(
|
|
adapter, "join_voice_channel"
|
|
)
|
|
channels = (
|
|
t("gateway.voice.help_channels") if supports_voice_channels else ""
|
|
)
|
|
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
|
|
|
|
async def _handle_rollback_command(self, event: MessageEvent) -> str:
|
|
"""Handle /rollback command — list or restore filesystem checkpoints."""
|
|
from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config
|
|
from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
|
|
|
|
cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config())
|
|
|
|
if not cp_kwargs["checkpoints_enabled"]:
|
|
return t("gateway.rollback.not_enabled")
|
|
|
|
mgr = CheckpointManager(
|
|
enabled=True,
|
|
max_snapshots=cp_kwargs["checkpoint_max_snapshots"],
|
|
max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"],
|
|
max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"],
|
|
)
|
|
|
|
from tools.terminal_scope import terminal_env as _tenv
|
|
|
|
cwd = _tenv("TERMINAL_CWD", str(Path.home()))
|
|
arg = event.get_command_args().strip()
|
|
|
|
# --all / --force: classic full restore, overwriting user edits too.
|
|
restore_all = False
|
|
arg_parts = []
|
|
for tok in arg.split():
|
|
if tok.lower() in ("--all", "--force"):
|
|
restore_all = True
|
|
else:
|
|
arg_parts.append(tok)
|
|
arg = " ".join(arg_parts)
|
|
|
|
if not arg:
|
|
checkpoints = mgr.list_checkpoints(cwd)
|
|
return format_checkpoint_list(checkpoints, cwd)
|
|
|
|
# Restore by number or hash
|
|
checkpoints = mgr.list_checkpoints(cwd)
|
|
if not checkpoints:
|
|
return t("gateway.rollback.none_found", cwd=cwd)
|
|
|
|
target_hash = None
|
|
try:
|
|
idx = int(arg) - 1
|
|
if 0 <= idx < len(checkpoints):
|
|
target_hash = checkpoints[idx]["hash"]
|
|
else:
|
|
return t("gateway.rollback.invalid_number", max=len(checkpoints))
|
|
except ValueError:
|
|
target_hash = arg
|
|
|
|
result = mgr.restore(cwd, target_hash, safe=not restore_all)
|
|
if result["success"]:
|
|
msg = t(
|
|
"gateway.rollback.restored",
|
|
hash=result["restored_to"],
|
|
reason=result["reason"],
|
|
)
|
|
skipped = result.get("skipped_user_edits") or []
|
|
if skipped:
|
|
shown = ", ".join(skipped[:5])
|
|
more = f" (+{len(skipped) - 5})" if len(skipped) > 5 else ""
|
|
msg += "\n" + t(
|
|
"gateway.rollback.kept_user_edits",
|
|
files=shown + more,
|
|
)
|
|
oversize = result.get("skipped_oversize") or []
|
|
if oversize:
|
|
shown = ", ".join(oversize[:5])
|
|
more = f" (+{len(oversize) - 5})" if len(oversize) > 5 else ""
|
|
msg += "\n" + t(
|
|
"gateway.rollback.kept_oversize",
|
|
files=shown + more,
|
|
)
|
|
failed = result.get("failed_deletes") or []
|
|
if failed:
|
|
shown = ", ".join(failed[:5])
|
|
more = f" (+{len(failed) - 5})" if len(failed) > 5 else ""
|
|
msg += "\n" + t(
|
|
"gateway.rollback.failed_deletes",
|
|
files=shown + more,
|
|
)
|
|
return msg
|
|
return t("gateway.rollback.restore_failed", error=result["error"])
|
|
|
|
async def _handle_diff_command(self, event: MessageEvent) -> str:
|
|
"""Handle /diff — show git changes in the working directory.
|
|
|
|
Diff body is truncated hard here (chat is not a pager); platform senders clamp further.
|
|
"""
|
|
args = event.get_command_args().strip()
|
|
|
|
stat_only = False
|
|
mode = "working"
|
|
for arg in args.split():
|
|
low = arg.lower()
|
|
if low in ("--stat", "stat"):
|
|
stat_only = True
|
|
elif low in ("staged", "--staged", "cached", "--cached"):
|
|
mode = "staged"
|
|
elif low in ("all", "--all", "head"):
|
|
mode = "all"
|
|
elif low == "session":
|
|
mode = "session"
|
|
|
|
from tools.terminal_scope import terminal_env as _tenv
|
|
|
|
cwd = _tenv("TERMINAL_CWD", str(Path.home()))
|
|
|
|
if mode == "session":
|
|
return await self._gateway_session_diff(cwd, stat_only)
|
|
|
|
from tools.working_diff import collect_working_diff
|
|
|
|
result = await asyncio.to_thread(collect_working_diff, cwd, mode)
|
|
if not result.get("success"):
|
|
return t("gateway.diff.failed",
|
|
error=result.get("error", "Could not generate diff"))
|
|
|
|
stat = result.get("stat", "")
|
|
diff = result.get("diff", "")
|
|
untracked = result.get("untracked", [])
|
|
if result.get("empty") or (not stat and not diff and not untracked):
|
|
return t("gateway.diff.no_changes")
|
|
|
|
out: list[str] = []
|
|
if stat:
|
|
out.append(f"```\n{stat}\n```")
|
|
if untracked:
|
|
shown = "\n".join(f"+ {rel}" for rel in untracked[:15])
|
|
more = f"\n... and {len(untracked) - 15} more" if len(untracked) > 15 else ""
|
|
out.append(f"**Untracked:**\n```\n{shown}{more}\n```")
|
|
if not stat_only and diff:
|
|
out.append(self._fenced_truncated_diff(diff))
|
|
return "\n\n".join(out)
|
|
|
|
async def _gateway_session_diff(self, cwd: str, stat_only: bool) -> str:
|
|
"""Cumulative checkpoint-baseline diff for /diff session (gateway)."""
|
|
from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config
|
|
from tools.checkpoint_manager import CheckpointManager
|
|
|
|
cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config())
|
|
if not cp_kwargs["checkpoints_enabled"]:
|
|
return t("gateway.diff.not_enabled")
|
|
|
|
mgr = CheckpointManager(
|
|
enabled=True,
|
|
max_snapshots=cp_kwargs["checkpoint_max_snapshots"],
|
|
max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"],
|
|
max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"],
|
|
)
|
|
|
|
result = await asyncio.to_thread(mgr.session_diff, cwd)
|
|
if not result.get("success"):
|
|
return t("gateway.diff.failed",
|
|
error=result.get("error", "Could not generate diff"))
|
|
|
|
stat = result.get("stat", "")
|
|
diff = result.get("diff", "")
|
|
if result.get("empty") or (not stat and not diff):
|
|
return t("gateway.diff.no_changes")
|
|
|
|
out: list[str] = []
|
|
if stat:
|
|
out.append(f"```\n{stat}\n```")
|
|
if not stat_only and diff:
|
|
out.append(self._fenced_truncated_diff(diff))
|
|
return "\n\n".join(out)
|
|
|
|
@staticmethod
|
|
def _fenced_truncated_diff(diff: str, max_lines: int = 60,
|
|
max_chars: int = 3000) -> str:
|
|
"""Fence a diff body, truncating to messaging-friendly size."""
|
|
diff_lines = diff.splitlines()
|
|
truncated = False
|
|
if len(diff_lines) > max_lines:
|
|
diff = "\n".join(diff_lines[:max_lines])
|
|
truncated = True
|
|
if len(diff) > max_chars:
|
|
diff = diff[:max_chars]
|
|
truncated = True
|
|
note = ""
|
|
if truncated:
|
|
note = (
|
|
f"\n... (truncated — {len(diff_lines)} lines total; "
|
|
"use /diff --stat for a summary)"
|
|
)
|
|
return f"```diff\n{diff}{note}\n```"
|
|
|
|
async def _handle_background_command(self, event: MessageEvent) -> str:
|
|
"""Handle /bg <prompt> — run a prompt in a background thread with its own session; the
|
|
result is sent to the same chat without touching the active session's history.
|
|
"""
|
|
prompt = event.get_command_args().strip()
|
|
if not prompt:
|
|
return t("gateway.background.usage")
|
|
|
|
source = event.source
|
|
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}"
|
|
|
|
event_message_id = self._reply_anchor_for_event(event)
|
|
|
|
# Forward image/audio attachments so the background agent can see them.
|
|
media_urls = list(event.media_urls) if event.media_urls else []
|
|
media_types = list(event.media_types) if event.media_types else []
|
|
|
|
# Fire-and-forget the background task
|
|
_task = asyncio.create_task(
|
|
self._run_background_task(
|
|
prompt,
|
|
source,
|
|
task_id,
|
|
event_message_id=event_message_id,
|
|
media_urls=media_urls,
|
|
media_types=media_types,
|
|
)
|
|
)
|
|
self._background_tasks.add(_task)
|
|
_task.add_done_callback(self._background_tasks.discard)
|
|
|
|
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
|
return t("gateway.background.started", preview=preview, task_id=task_id)
|
|
|
|
async def _handle_btw_command(self, event: MessageEvent) -> str:
|
|
"""Handle /btw <question> — answer a side question via a one-shot auxiliary LLM call on a
|
|
transcript snapshot; live history is never touched (alternation + prompt cache intact,
|
|
current turn keeps running). Unlike /bg, which spawns a fresh contextless session.
|
|
"""
|
|
question = event.get_command_args().strip()
|
|
if not question:
|
|
return t("gateway.btw.usage")
|
|
|
|
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:
|
|
return t("gateway.btw.no_history")
|
|
|
|
try:
|
|
model, runtime_kwargs = self._resolve_session_agent_runtime(
|
|
source=source,
|
|
)
|
|
except Exception:
|
|
model, runtime_kwargs = None, {}
|
|
if not runtime_kwargs.get("api_key"):
|
|
return t("gateway.btw.no_provider")
|
|
|
|
main_runtime = {
|
|
"model": model,
|
|
"provider": runtime_kwargs.get("provider"),
|
|
"base_url": runtime_kwargs.get("base_url"),
|
|
"api_key": runtime_kwargs.get("api_key"),
|
|
"api_mode": runtime_kwargs.get("api_mode"),
|
|
}
|
|
history_snapshot = list(history)
|
|
# Prefer the cache-parity fork when a live cached AIAgent exists: it replays the snapshot
|
|
# against the warm provider prefix cache, giving FULL context at cache-read prices. With no
|
|
# cached agent the cache is cold anyway — answer_side_question's digest fallback handles it.
|
|
parent_agent = None
|
|
try:
|
|
session_key = self._session_key_for_source(source)
|
|
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
if _cache_lock is not None:
|
|
with _cache_lock:
|
|
_cached = self._agent_cache.get(session_key)
|
|
parent_agent = (
|
|
_cached[0] if isinstance(_cached, tuple) else _cached
|
|
) or None
|
|
except Exception:
|
|
parent_agent = None
|
|
event_message_id = self._reply_anchor_for_event(event)
|
|
_thread_metadata = self._thread_metadata_for_source(source, event_message_id)
|
|
adapter = self._adapter_for_source(source)
|
|
preview = question[:60] + ("..." if len(question) > 60 else "")
|
|
|
|
async def _run_side_question() -> None:
|
|
from agent.side_question import answer_side_question
|
|
try:
|
|
answer = await asyncio.to_thread(
|
|
answer_side_question,
|
|
question,
|
|
history_snapshot,
|
|
parent_agent=parent_agent,
|
|
main_runtime=main_runtime,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("/btw side question failed: %s", e)
|
|
if adapter is not None:
|
|
await adapter.send(
|
|
source.chat_id,
|
|
t("gateway.btw.failed", preview=preview, error=str(e)),
|
|
metadata=_thread_metadata,
|
|
)
|
|
return
|
|
if adapter is not None:
|
|
await adapter.send(
|
|
source.chat_id,
|
|
t("gateway.btw.answer", preview=preview, answer=answer or ""),
|
|
metadata=_thread_metadata,
|
|
)
|
|
|
|
_task = asyncio.create_task(_run_side_question())
|
|
self._background_tasks.add(_task)
|
|
_task.add_done_callback(self._background_tasks.discard)
|
|
|
|
return t("gateway.btw.started", preview=preview)
|
|
|
|
def _save_gateway_config_key(self, key_path: str, value) -> bool:
|
|
"""Save a dot-separated key to config.yaml (shared by /reasoning, /fast
|
|
and their interactive pickers)."""
|
|
from gateway.run import _gateway_config_home
|
|
from hermes_cli.config import read_user_config_raw
|
|
config_path = _gateway_config_home() / "config.yaml"
|
|
try:
|
|
# Write-back round-trip: raw read is correct (merged defaults must
|
|
# not be persisted back to the user's file).
|
|
user_config = read_user_config_raw(config_path)
|
|
keys = key_path.split(".")
|
|
current = user_config
|
|
for k in keys[:-1]:
|
|
if k not in current or not isinstance(current[k], dict):
|
|
current[k] = {}
|
|
current = current[k]
|
|
current[keys[-1]] = value
|
|
atomic_config_write(config_path, user_config)
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Failed to save config key %s: %s", key_path, e)
|
|
return False
|
|
|
|
def _apply_reasoning_selection(
|
|
self,
|
|
session_key: str,
|
|
platform_key: str,
|
|
value: str,
|
|
persist_global: bool = False,
|
|
) -> str:
|
|
"""Apply a /reasoning argument (typed or picked) and return the reply.
|
|
|
|
Single path shared by `/reasoning <arg>` and the choice picker so both match the parser.
|
|
"""
|
|
from hermes_constants import parse_reasoning_effort
|
|
|
|
value = (value or "").strip().lower()
|
|
|
|
# Display toggle (per-platform)
|
|
if value in {"show", "on"}:
|
|
self._show_reasoning = True
|
|
self._save_gateway_config_key(
|
|
f"display.platforms.{platform_key}.show_reasoning", True
|
|
)
|
|
return t("gateway.reasoning.display_set_on", platform=platform_key)
|
|
if value in {"hide", "off"}:
|
|
self._show_reasoning = False
|
|
self._save_gateway_config_key(
|
|
f"display.platforms.{platform_key}.show_reasoning", False
|
|
)
|
|
return t("gateway.reasoning.display_set_off", platform=platform_key)
|
|
|
|
if value == "reset":
|
|
if persist_global:
|
|
return t("gateway.reasoning.reset_global_unsupported")
|
|
self._set_session_reasoning_override(session_key, None)
|
|
self._reasoning_config = self._load_reasoning_config()
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.reasoning.reset_done")
|
|
|
|
parsed = parse_reasoning_effort(value)
|
|
if parsed is None:
|
|
return t("gateway.reasoning.unknown_arg", arg=value)
|
|
|
|
self._reasoning_config = parsed
|
|
if persist_global:
|
|
if self._save_gateway_config_key("agent.reasoning_effort", value):
|
|
self._set_session_reasoning_override(session_key, None)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.reasoning.set_global", effort=value)
|
|
self._set_session_reasoning_override(session_key, parsed)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.reasoning.set_global_save_failed", effort=value)
|
|
|
|
self._set_session_reasoning_override(session_key, parsed)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.reasoning.set_session", effort=value)
|
|
|
|
def _reasoning_picker_choices(self, current_effort: str) -> list:
|
|
"""Build the choice list for the interactive /reasoning picker."""
|
|
from hermes_constants import VALID_REASONING_EFFORTS
|
|
|
|
choices = [
|
|
{
|
|
"value": "none",
|
|
"label": t("gateway.reasoning.choice_none"),
|
|
"is_current": current_effort == "none",
|
|
}
|
|
]
|
|
for level in VALID_REASONING_EFFORTS:
|
|
choices.append(
|
|
{
|
|
"value": level,
|
|
"label": level,
|
|
"is_current": level == current_effort,
|
|
}
|
|
)
|
|
choices.extend(
|
|
[
|
|
{"value": "reset", "label": t("gateway.reasoning.choice_reset"), "is_current": False},
|
|
{"value": "show", "label": t("gateway.reasoning.choice_show"), "is_current": False},
|
|
{"value": "hide", "label": t("gateway.reasoning.choice_hide"), "is_current": False},
|
|
]
|
|
)
|
|
return choices
|
|
|
|
async def _try_send_choice_picker(
|
|
self,
|
|
event: MessageEvent,
|
|
session_key: str,
|
|
title: str,
|
|
choices: list,
|
|
on_choice_selected,
|
|
) -> bool:
|
|
"""Send an interactive choice picker when the platform supports it.
|
|
|
|
Mirrors the `/model` gate: capability is detected on the adapter *type*
|
|
(``send_choice_picker``); a failed send returns False (text fallback) instead of erroring.
|
|
"""
|
|
adapter = getattr(self, "_adapter_for_source")(event.source)
|
|
has_picker = (
|
|
adapter is not None
|
|
and getattr(type(adapter), "send_choice_picker", None) is not None
|
|
)
|
|
if not has_picker:
|
|
return False
|
|
try:
|
|
metadata = self._thread_metadata_for_source(
|
|
event.source, self._reply_anchor_for_event(event)
|
|
)
|
|
result = await adapter.send_choice_picker(
|
|
chat_id=event.source.chat_id,
|
|
title=title,
|
|
choices=choices,
|
|
session_key=session_key,
|
|
on_choice_selected=on_choice_selected,
|
|
metadata=metadata,
|
|
)
|
|
return bool(getattr(result, "success", False))
|
|
except Exception as e:
|
|
logger.warning("send_choice_picker failed, falling back to text: %s", e)
|
|
return False
|
|
|
|
async def _handle_reasoning_command(self, event: MessageEvent) -> Optional[str]:
|
|
"""Handle /reasoning command — manage reasoning effort and display toggle."""
|
|
from gateway.run import _platform_config_key
|
|
|
|
raw_args = event.get_command_args().strip()
|
|
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
|
# Normalize the source (Telegram DM topic recovery) before deriving
|
|
# the override key so storage matches the key the next message turn
|
|
# reads — same fix as /model (#30479).
|
|
_reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
|
|
session_key = self._session_key_for_source(_reasoning_source)
|
|
self._show_reasoning = self._load_show_reasoning()
|
|
# Use the session's effective model (session /model override wins over
|
|
# config default) so per-model reasoning_overrides display correctly.
|
|
_session_model = str(
|
|
((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or ""
|
|
)
|
|
self._reasoning_config = self._resolve_session_reasoning_config(
|
|
source=event.source,
|
|
session_key=session_key,
|
|
model=_session_model,
|
|
)
|
|
|
|
if not raw_args:
|
|
# Show current state
|
|
rc = self._reasoning_config
|
|
if rc is None:
|
|
level = t("gateway.reasoning.level_default")
|
|
current_effort = "medium"
|
|
elif rc.get("enabled") is False:
|
|
level = t("gateway.reasoning.level_disabled")
|
|
current_effort = "none"
|
|
else:
|
|
level = rc.get("effort", "medium")
|
|
current_effort = level
|
|
display_state = (
|
|
t("gateway.reasoning.display_on")
|
|
if self._show_reasoning
|
|
else t("gateway.reasoning.display_off")
|
|
)
|
|
has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {})
|
|
scope = (
|
|
t("gateway.reasoning.scope_session")
|
|
if has_session_override
|
|
else t("gateway.reasoning.scope_global")
|
|
)
|
|
|
|
# Interactive picker on platforms that support it (parity with the
|
|
# /model picker). Falls through to the text status card otherwise.
|
|
_picker_platform_key = _platform_config_key(event.source.platform)
|
|
|
|
async def _on_reasoning_choice(_chat_id: str, value: str) -> str:
|
|
return self._apply_reasoning_selection(
|
|
session_key, _picker_platform_key, value
|
|
)
|
|
|
|
picker_sent = await self._try_send_choice_picker(
|
|
event,
|
|
session_key,
|
|
title=t(
|
|
"gateway.reasoning.picker_title",
|
|
level=level,
|
|
scope=scope,
|
|
display=display_state,
|
|
),
|
|
choices=self._reasoning_picker_choices(current_effort),
|
|
on_choice_selected=_on_reasoning_choice,
|
|
)
|
|
if picker_sent:
|
|
return None # Picker sent — adapter handles the response
|
|
|
|
return t(
|
|
"gateway.reasoning.status",
|
|
level=level,
|
|
scope=scope,
|
|
display=display_state,
|
|
)
|
|
|
|
# Typed argument path — same applier the picker uses.
|
|
platform_key = _platform_config_key(event.source.platform)
|
|
return self._apply_reasoning_selection(
|
|
session_key, platform_key, args, persist_global=persist_global
|
|
)
|
|
|
|
async def _handle_memory_command(self, event: MessageEvent) -> str:
|
|
"""Handle /memory — review pending memory writes + toggle the approval gate.
|
|
|
|
Entries are small enough to review inline, so the full flow works on every platform.
|
|
"""
|
|
from gateway.run import _gateway_config_home
|
|
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
|
from tools import write_approval as wa
|
|
from tools.memory_tool import load_on_disk_store
|
|
|
|
raw_args = event.get_command_args().strip()
|
|
args = raw_args.split() if raw_args else []
|
|
session_key = self._session_key_for_source(event.source)
|
|
config_path = _gateway_config_home() / "config.yaml"
|
|
|
|
def _set_approval(enabled: bool):
|
|
# Write-back round-trip: raw read is correct (merged defaults must
|
|
# not be persisted back to the user's file).
|
|
from hermes_cli.config import read_user_config_raw
|
|
user_config = read_user_config_raw(config_path)
|
|
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
|
|
atomic_config_write(config_path, user_config)
|
|
# New setting must take effect next message → drop cached agent.
|
|
self._evict_cached_agent(session_key)
|
|
|
|
# Apply approved writes against a fresh on-disk store (the gateway has
|
|
# no long-lived agent; the store persists to the same MEMORY/USER.md).
|
|
# load_on_disk_store() honors the user's configured char limits.
|
|
store = load_on_disk_store()
|
|
|
|
out = handle_pending_subcommand(
|
|
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
|
|
)
|
|
if out is None:
|
|
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
|
|
"reject <id>, approval <on|off>.")
|
|
return out
|
|
|
|
async def _handle_skills_command(self, event: MessageEvent) -> str:
|
|
"""Handle /skills on the gateway — pending skill-write review only (hub stays CLI-only).
|
|
|
|
Gated by ``skills.write_approval`` but still answers when staged writes exist after the
|
|
gate is off (never stranded). ``diff`` is truncated for chat.
|
|
"""
|
|
from gateway.run import _gateway_config_home
|
|
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
|
from tools import write_approval as wa
|
|
|
|
raw_args = event.get_command_args().strip()
|
|
args = raw_args.split() if raw_args else []
|
|
session_key = self._session_key_for_source(event.source)
|
|
config_path = _gateway_config_home() / "config.yaml"
|
|
|
|
gate_on = wa.write_approval_enabled(wa.SKILLS)
|
|
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
|
|
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
|
|
return ("Skill write approval is off (skills.write_approval). "
|
|
"Enable it with /skills approval on, then review staged "
|
|
"writes here with /skills pending.")
|
|
|
|
def _set_approval(enabled: bool):
|
|
# Write-back round-trip: raw read is correct (merged defaults must
|
|
# not be persisted back to the user's file).
|
|
from hermes_cli.config import read_user_config_raw
|
|
user_config = read_user_config_raw(config_path)
|
|
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
|
|
atomic_config_write(config_path, user_config)
|
|
# New setting must take effect next message → drop cached agent.
|
|
self._evict_cached_agent(session_key)
|
|
|
|
out = handle_pending_subcommand(
|
|
wa.SKILLS, args, set_mode_fn=_set_approval,
|
|
)
|
|
if out is None:
|
|
return ("Unknown /skills subcommand on this platform. Use: pending, "
|
|
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
|
|
"(Search/install are CLI-only.)")
|
|
|
|
# Chat bubbles can't hold a full skill diff — truncate and point at the pending JSON file
|
|
# (NOT `hermes skills diff <name>`, which diffs a bundled skill against its stock version).
|
|
if args and args[0].lower() == "diff" and len(out) > 3000:
|
|
pending_id = args[1] if len(args) > 1 else "<id>"
|
|
out = (out[:3000]
|
|
+ "\n… (truncated — full diff in "
|
|
f"~/.hermes/pending/skills/{pending_id}.json)")
|
|
return out
|
|
|
|
async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
|
|
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.
|
|
|
|
Session-scoped by default; ``--global`` persists agent.service_tier (parity with /model).
|
|
"""
|
|
from gateway.run import _load_gateway_config, _resolve_gateway_model
|
|
from hermes_cli.models import model_supports_fast_mode
|
|
|
|
raw_args = event.get_command_args().strip().lower()
|
|
# Reuse the /reasoning arg parser: strips --global (any position),
|
|
# normalizes unicode dashes.
|
|
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
|
session_key = self._session_key_for_source(event.source)
|
|
self._service_tier = self._resolve_session_service_tier(
|
|
session_key=session_key
|
|
)
|
|
|
|
user_config = _load_gateway_config()
|
|
model = _resolve_gateway_model(user_config)
|
|
if not model_supports_fast_mode(model):
|
|
return t("gateway.fast.not_supported")
|
|
|
|
def _apply_fast_selection(value: str, persist: bool = False) -> str:
|
|
"""Apply a /fast argument (typed or picked) and return the reply."""
|
|
if value in {"fast", "on"}:
|
|
tier = "priority"
|
|
saved_value = "fast"
|
|
label = t("gateway.fast.label_fast")
|
|
elif value in {"normal", "off"}:
|
|
tier = None
|
|
saved_value = "normal"
|
|
label = t("gateway.fast.label_normal")
|
|
elif value in {"auto", "cold"}:
|
|
tier = saved_value = value
|
|
label = value.upper()
|
|
else:
|
|
return t("gateway.fast.unknown_arg", arg=value)
|
|
self._service_tier = tier
|
|
if persist:
|
|
if self._save_gateway_config_key("agent.service_tier", saved_value):
|
|
# Global write supersedes any session override.
|
|
self._set_session_service_tier_override(
|
|
session_key, None, clear=True
|
|
)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.fast.saved", label=label)
|
|
# Config write failed — fall back to a session override so the
|
|
# user's choice still applies (mirrors /reasoning --global).
|
|
self._set_session_service_tier_override(session_key, tier)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.fast.session_only", label=label)
|
|
self._set_session_service_tier_override(session_key, tier)
|
|
self._evict_cached_agent(session_key)
|
|
return t("gateway.fast.session_only", label=label)
|
|
|
|
if not args or args == "status":
|
|
is_fast = self._service_tier == "priority"
|
|
mode = "fast" if is_fast else (self._service_tier or "normal")
|
|
status = {"fast": t("gateway.fast.status_fast"), "normal": t("gateway.fast.status_normal")}.get(mode, mode)
|
|
|
|
async def _on_fast_choice(_chat_id: str, value: str) -> str:
|
|
return _apply_fast_selection(value, persist=persist_global)
|
|
|
|
picker_sent = await self._try_send_choice_picker(
|
|
event,
|
|
session_key,
|
|
title=t("gateway.fast.picker_title", mode=status),
|
|
choices=[
|
|
{
|
|
"value": "fast",
|
|
"label": t("gateway.fast.choice_fast"),
|
|
"is_current": is_fast,
|
|
},
|
|
{
|
|
"value": "normal",
|
|
"label": t("gateway.fast.choice_normal"),
|
|
"is_current": mode == "normal",
|
|
},
|
|
{
|
|
"value": "auto",
|
|
"label": t("gateway.fast.choice_auto"),
|
|
"is_current": mode == "auto",
|
|
},
|
|
{
|
|
"value": "cold",
|
|
"label": t("gateway.fast.choice_cold"),
|
|
"is_current": mode == "cold",
|
|
},
|
|
],
|
|
on_choice_selected=_on_fast_choice,
|
|
)
|
|
if picker_sent:
|
|
return None # Picker sent — adapter handles the response
|
|
|
|
return t("gateway.fast.status", mode=status)
|
|
|
|
return _apply_fast_selection(args, persist=persist_global)
|
|
|
|
async def _handle_approvals_command(self, event: MessageEvent) -> str:
|
|
"""Show or persist the profile-wide dangerous-command approval mode."""
|
|
from gateway.slash_access import policy_for_source
|
|
from hermes_cli.approval_mode import run_approval_mode_command
|
|
|
|
requested = event.get_command_args().strip() or None
|
|
# This mutates profile-wide security policy. The central slash gate can
|
|
# allow selected commands to non-admin users, so enforce admin again at
|
|
# this side-effect boundary. Unconfigured policies remain unrestricted.
|
|
policy = policy_for_source(self.config, event.source)
|
|
if requested and not policy.is_admin(event.source.user_id):
|
|
return "Only gateway admins can change the persistent approval mode."
|
|
result = run_approval_mode_command(requested)
|
|
# Approval checks load config dynamically; do not evict the cached agent
|
|
# or alter its system prompt/tool schema (prompt-cache prefix is sacred).
|
|
return result.message
|
|
|
|
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
|
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
|
|
from tools.approval import (
|
|
disable_session_yolo,
|
|
enable_session_yolo,
|
|
is_session_yolo_enabled,
|
|
)
|
|
|
|
session_key = self._session_key_for_source(event.source)
|
|
current = is_session_yolo_enabled(session_key)
|
|
if current:
|
|
disable_session_yolo(session_key)
|
|
return EphemeralReply(t("gateway.yolo.disabled"))
|
|
else:
|
|
enable_session_yolo(session_key)
|
|
return EphemeralReply(t("gateway.yolo.enabled"))
|
|
|
|
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
|
"""Handle /verbose command — cycle tool progress display mode.
|
|
|
|
Gated by ``display.tool_progress_command`` (default off). Cycles off → new → all → verbose
|
|
per *current platform*, saved to ``display.platforms.<platform>.tool_progress``.
|
|
"""
|
|
from gateway.run import _gateway_config_home, _load_gateway_config, _platform_config_key
|
|
|
|
config_path = _gateway_config_home() / "config.yaml"
|
|
platform_key = _platform_config_key(event.source.platform)
|
|
|
|
# --- check config gate ------------------------------------------------
|
|
try:
|
|
user_config = _load_gateway_config()
|
|
gate_enabled = is_truthy_value(
|
|
cfg_get(user_config, "display", "tool_progress_command"),
|
|
default=False,
|
|
)
|
|
except Exception:
|
|
gate_enabled = False
|
|
|
|
if not gate_enabled:
|
|
return t("gateway.verbose.not_enabled")
|
|
|
|
# --- cycle mode (per-platform) ----------------------------------------
|
|
cycle = ["off", "new", "all", "verbose", "log"]
|
|
descriptions = {
|
|
"off": t("gateway.verbose.mode_off"),
|
|
"new": t("gateway.verbose.mode_new"),
|
|
"all": t("gateway.verbose.mode_all"),
|
|
"verbose": t("gateway.verbose.mode_verbose"),
|
|
"log": t("gateway.verbose.mode_log"),
|
|
}
|
|
|
|
# Read current effective mode for this platform via the resolver
|
|
from gateway.display_config import resolve_display_setting
|
|
current = resolve_display_setting(user_config, platform_key, "tool_progress", "all")
|
|
if current not in cycle:
|
|
current = "all"
|
|
idx = (cycle.index(current) + 1) % len(cycle)
|
|
new_mode = cycle[idx]
|
|
|
|
# Save to display.platforms.<platform>.tool_progress
|
|
try:
|
|
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
|
|
user_config["display"] = {}
|
|
display = user_config["display"]
|
|
if "platforms" not in display or not isinstance(display.get("platforms"), dict):
|
|
display["platforms"] = {}
|
|
if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
|
|
display["platforms"][platform_key] = {}
|
|
display["platforms"][platform_key]["tool_progress"] = new_mode
|
|
atomic_config_write(config_path, user_config)
|
|
return (
|
|
f"{descriptions[new_mode]}\n"
|
|
+ t("gateway.verbose.saved_suffix", platform=platform_key)
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to save tool_progress mode: %s", e)
|
|
return f"{descriptions[new_mode]}\n" + t("gateway.verbose.save_failed", error=e)
|
|
|
|
async def _handle_busy_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
|
"""Handle /busy — control what happens when messaging while Hermes is working."""
|
|
arg = event.get_command_args().strip().lower()
|
|
if not arg or arg == "status":
|
|
mode = self._effective_busy_input_mode(event.source)
|
|
if mode == "queue":
|
|
behavior = "queues for next turn"
|
|
elif mode == "steer":
|
|
behavior = "steers into current run (after next tool call)"
|
|
else:
|
|
behavior = "interrupts current run"
|
|
return EphemeralReply(
|
|
f"**Busy input mode: `{mode}`" + "\n"
|
|
f"Messages while busy: _{behavior}_" + "\n"
|
|
f"Change with `/busy queue`, `/busy steer`, or `/busy interrupt`."
|
|
)
|
|
|
|
if arg not in {"queue", "interrupt", "steer"}:
|
|
return EphemeralReply(
|
|
f"Unknown mode `{arg}`. Use `/busy queue`, `/busy steer`, or `/busy interrupt`."
|
|
)
|
|
|
|
# Persist before mutate
|
|
from cli import save_config_value
|
|
if save_config_value("display.busy_input_mode", arg):
|
|
profile_name = self._busy_profile_name_for_source(event.source)
|
|
if profile_name:
|
|
from gateway.run import _load_gateway_runtime_config
|
|
|
|
self._snapshot_profile_busy_modes(
|
|
profile_name,
|
|
_load_gateway_runtime_config(),
|
|
)
|
|
else:
|
|
self._busy_input_mode = arg
|
|
# busy_input_mode is also the source of truth for the text mode — re-derive it so the
|
|
# adapter refresh below doesn't keep a stale value and keep interrupting.
|
|
self._busy_text_mode = self._load_busy_text_mode()
|
|
|
|
adapter = self._adapter_for_source(event.source)
|
|
if adapter is not None:
|
|
adapter._busy_text_mode = self._effective_busy_text_mode(event.source)
|
|
|
|
if arg == "queue":
|
|
behavior = "Messages will be queued for the next turn while Hermes is busy."
|
|
elif arg == "steer":
|
|
behavior = "Messages will be steered into the current run (after the next tool call)."
|
|
else:
|
|
behavior = "Messages will interrupt the current run while Hermes is busy."
|
|
return EphemeralReply(
|
|
f"Busy input mode set to **`{arg}`** (saved)." + "\n"
|
|
f"_{behavior}_"
|
|
)
|
|
else:
|
|
return EphemeralReply(
|
|
f"Busy input mode could not be saved to config. Mode unchanged."
|
|
)
|
|
|
|
async def _handle_footer_command(self, event: MessageEvent) -> str:
|
|
"""Handle /footer command — toggle the runtime-metadata footer."""
|
|
from gateway.run import _gateway_config_home, _load_gateway_config, _platform_config_key, _resolve_gateway_model
|
|
from gateway.runtime_footer import resolve_footer_config
|
|
|
|
config_path = _gateway_config_home() / "config.yaml"
|
|
platform_key = _platform_config_key(event.source.platform)
|
|
|
|
# --- parse argument -------------------------------------------------
|
|
arg = ""
|
|
try:
|
|
text = (getattr(event, "message", None) or "").strip()
|
|
if text.startswith("/"):
|
|
parts = text.split(None, 1)
|
|
if len(parts) > 1:
|
|
arg = parts[1].strip().lower()
|
|
except Exception:
|
|
arg = ""
|
|
|
|
# --- load config ----------------------------------------------------
|
|
try:
|
|
user_config: dict = _load_gateway_config()
|
|
except Exception as e:
|
|
return t("gateway.config_read_failed", error=e)
|
|
|
|
effective = resolve_footer_config(user_config, platform_key)
|
|
|
|
if arg in {"status", "?"}:
|
|
state = t("gateway.footer.state_on") if effective["enabled"] else t("gateway.footer.state_off")
|
|
fields = ", ".join(effective.get("fields") or [])
|
|
return t(
|
|
"gateway.footer.status",
|
|
state=state,
|
|
fields=fields,
|
|
platform=platform_key,
|
|
)
|
|
|
|
if arg in {"on", "enable", "true", "1"}:
|
|
new_state = True
|
|
elif arg in {"off", "disable", "false", "0"}:
|
|
new_state = False
|
|
elif arg == "":
|
|
new_state = not effective["enabled"]
|
|
else:
|
|
return t("gateway.footer.usage")
|
|
|
|
# --- write global flag ---------------------------------------------
|
|
try:
|
|
if not isinstance(user_config.get("display"), dict):
|
|
user_config["display"] = {}
|
|
display = user_config["display"]
|
|
if not isinstance(display.get("runtime_footer"), dict):
|
|
display["runtime_footer"] = {}
|
|
display["runtime_footer"]["enabled"] = new_state
|
|
atomic_config_write(config_path, user_config)
|
|
except Exception as e:
|
|
logger.warning("Failed to save runtime_footer.enabled: %s", e)
|
|
return t("gateway.config_save_failed", error=e)
|
|
|
|
state = t("gateway.footer.state_on") if new_state else t("gateway.footer.state_off")
|
|
example = ""
|
|
if new_state:
|
|
# Show a preview using current agent state if available.
|
|
from gateway.runtime_footer import format_runtime_footer
|
|
preview = format_runtime_footer(
|
|
model=_resolve_gateway_model(user_config) or None,
|
|
context_tokens=0,
|
|
context_length=None,
|
|
fields=effective.get("fields") or ["model", "context_pct", "cwd"],
|
|
)
|
|
if preview:
|
|
example = 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.
|
|
"""
|
|
agent = None
|
|
lock = getattr(self, "_agent_cache_lock", None)
|
|
cache = getattr(self, "_agent_cache", None)
|
|
if cache is not None:
|
|
if lock:
|
|
with lock:
|
|
entry = cache.get(session_key)
|
|
else:
|
|
entry = cache.get(session_key)
|
|
agent = entry[0] if isinstance(entry, tuple) and entry else entry
|
|
from gateway.run import _AGENT_PENDING_SENTINEL
|
|
|
|
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 (#44794).
|
|
_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")
|
|
]
|
|
approx_tokens = estimate_request_tokens_rough(_pv_msgs)
|
|
report = summarize_compress_preview(
|
|
_pv_msgs, partial, keep_last, focus_topic, approx_tokens
|
|
)
|
|
lines = [f"🗜️ {line}" for line in report["lines"]]
|
|
if _aggressive:
|
|
lines.append(_agg_note)
|
|
return "\n".join(lines)
|
|
|
|
try:
|
|
from run_agent import AIAgent
|
|
from agent.manual_compression_feedback import summarize_manual_compression
|
|
from agent.model_metadata import estimate_request_tokens_rough
|
|
|
|
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.
|
|
from gateway.run import (
|
|
_GATEWAY_HYGIENE_PLATFORM,
|
|
_platform_config_key,
|
|
_seed_hygiene_system_prompt,
|
|
)
|
|
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
|
|
|
|
# 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_entry.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_entry.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_entry.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
|
|
try:
|
|
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
|
|
|
|
# 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)
|
|
|
|
# _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).
|
|
new_session_id = tmp_agent.session_id
|
|
rotated = new_session_id != session_entry.session_id
|
|
_in_place = bool(getattr(tmp_agent, "_last_compaction_in_place", False))
|
|
|
|
# Persist the compressed transcript BEFORE repointing the live session: repoint first
|
|
# + failed DB write would leave the entry on an empty session while reporting success.
|
|
# Write first, treat failure as 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.
|
|
if rotated:
|
|
if not await self.async_session_store.rewrite_transcript(
|
|
new_session_id, compressed
|
|
):
|
|
raise RuntimeError(
|
|
f"failed to persist compressed transcript for "
|
|
f"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 _in_place:
|
|
# archive_and_compact() already persisted the compacted
|
|
# transcript inside _compress_context — nothing to do.
|
|
pass
|
|
else:
|
|
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
|
|
)
|
|
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,
|
|
)
|
|
# Surface summary-generation failure on the manual path (_last_compress_aborted =
|
|
# no usable summary, messages unchanged). force=True above bypasses any cooldown.
|
|
_summary_aborted = bool(getattr(compressor, "_last_compress_aborted", False))
|
|
_summary_err = getattr(compressor, "_last_summary_error", None)
|
|
# Force-redact provider exception text at this UI boundary
|
|
# even when global redaction is disabled.
|
|
if _summary_err:
|
|
from agent.redact import redact_sensitive_text
|
|
_summary_err = redact_sensitive_text(_summary_err, force=True)
|
|
# Separately: did the user's CONFIGURED aux model fail
|
|
# and we recovered via main? Surface that as an info
|
|
# note so they can fix their config.
|
|
_aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
|
|
_aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
|
|
finally:
|
|
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"
|
|
)
|
|
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"])
|
|
if _summary_aborted:
|
|
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=(_aux_fail_err or "unknown error"),
|
|
)
|
|
)
|
|
return "\n".join(lines)
|
|
except Exception as e:
|
|
logger.warning("Manual compress failed: %s", e)
|
|
return t("gateway.compress.failed", error=e)
|
|
|
|
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:
|
|
from hermes_state import format_session_db_unavailable
|
|
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
|
|
|
# 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:
|
|
from hermes_state import format_session_db_unavailable
|
|
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
|
|
|
# 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:
|
|
from hermes_state import format_session_db_unavailable
|
|
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
|
|
|
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]:
|
|
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,
|
|
)
|
|
return [s for s in sessions if s.get("title")][:10]
|
|
|
|
if not name:
|
|
# List recent titled sessions for this user/platform
|
|
try:
|
|
titled = await _list_titled_sessions()
|
|
titled = [
|
|
s for s in titled
|
|
if await self._resume_row_visible(source, s, allow_all)
|
|
]
|
|
# A non-admin `--all` silently falls back to same-origin
|
|
# scoping; say so instead of rendering an unexplained
|
|
# narrower list (sibling of the /sessions `all` 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)
|
|
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()
|
|
titled = [
|
|
s for s in titled
|
|
if await self._resume_row_visible(source, s, allow_all)
|
|
]
|
|
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)
|
|
|
|
async def _handle_sessions_command(self, event: MessageEvent) -> str:
|
|
"""Handle /sessions — list previous sessions for gateway chats."""
|
|
if not self._session_db:
|
|
from hermes_state import format_session_db_unavailable
|
|
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
|
|
|
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:
|
|
from hermes_state import format_session_db_unavailable
|
|
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
|
|
|
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,
|
|
[
|
|
{
|
|
"role": msg.get("role", "user"),
|
|
"content": msg.get("content"),
|
|
"tool_name": msg.get("tool_name") or msg.get("name"),
|
|
"tool_calls": msg.get("tool_calls"),
|
|
"tool_call_id": msg.get("tool_call_id"),
|
|
"finish_reason": msg.get("finish_reason"),
|
|
"reasoning": msg.get("reasoning"),
|
|
"reasoning_content": msg.get("reasoning_content"),
|
|
"reasoning_details": msg.get("reasoning_details"),
|
|
"codex_reasoning_items": msg.get("codex_reasoning_items"),
|
|
"codex_message_items": msg.get("codex_message_items"),
|
|
# Keep the api_content sidecar so the branch's first turn
|
|
# replays the parent's exact wire bytes (warm provider
|
|
# prompt cache) instead of a full cold prefill.
|
|
"api_content": extract_api_content_sidecar(msg),
|
|
"timestamp": msg.get("timestamp"),
|
|
}
|
|
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.
|
|
|
|
Does NOT charge, confirm, or track payment — that happens in the browser; the next /topup
|
|
shows the new balance. Fetched off the event loop; fail-open.
|
|
"""
|
|
from agent.account_usage import build_credits_view
|
|
|
|
try:
|
|
view = await asyncio.to_thread(build_credits_view, markdown=True)
|
|
except Exception:
|
|
view = None
|
|
|
|
if view is None or not view.logged_in:
|
|
return t("gateway.credits.not_logged_in")
|
|
|
|
lines: list[str] = ["💳 **Nous balance**"]
|
|
for line in view.balance_lines:
|
|
if line.lstrip().startswith("📈"):
|
|
continue # drop the helper's header; we print our own
|
|
lines.append(line)
|
|
if view.identity_line:
|
|
lines.append("")
|
|
lines.append(view.identity_line)
|
|
if view.topup_url:
|
|
lines.append("")
|
|
lines.append(f"Manage billing on the portal: {view.topup_url}")
|
|
lines.append("Top up and manage billing in the browser — your balance updates here after.")
|
|
return "\n".join(lines)
|
|
|
|
def _context_breakdown_block(self, agent, source, expanded: bool) -> list[str]:
|
|
"""Render the /context per-category block (plain text, no grid).
|
|
|
|
Estimated (chars/4), same engine as /usage. Runs in a thread; returns [] and never raises.
|
|
"""
|
|
try:
|
|
from agent.context_breakdown import (
|
|
compute_context_details,
|
|
compute_session_context_breakdown,
|
|
render_context_breakdown_lines,
|
|
)
|
|
|
|
history: list[dict] = []
|
|
try:
|
|
entry = self.session_store.get_or_create_session(source)
|
|
history = self.session_store.load_transcript(entry.session_id) or []
|
|
except Exception:
|
|
history = []
|
|
|
|
payload = compute_session_context_breakdown(agent, history)
|
|
if not (payload.get("categories") or []):
|
|
return []
|
|
|
|
details = None
|
|
if expanded:
|
|
try:
|
|
details = compute_context_details(agent)
|
|
except Exception:
|
|
details = {"skills": [], "toolsets": []}
|
|
|
|
return render_context_breakdown_lines(payload, details=details, grid=False)
|
|
except Exception:
|
|
return []
|
|
|
|
def _context_breakdown_lines(self, agent, source) -> list[str]:
|
|
"""Render the per-category context breakdown for /usage.
|
|
|
|
Estimated (chars/4). Returns [] and never raises so /usage stays robust.
|
|
"""
|
|
try:
|
|
from agent.context_breakdown import compute_session_context_breakdown
|
|
|
|
history: list[dict] = []
|
|
try:
|
|
entry = self.session_store.get_or_create_session(source)
|
|
history = self.session_store.load_transcript(entry.session_id) or []
|
|
except Exception:
|
|
history = []
|
|
|
|
payload = compute_session_context_breakdown(agent, history)
|
|
categories = payload.get("categories") or []
|
|
if not categories:
|
|
return []
|
|
|
|
total = payload.get("estimated_total") or 0
|
|
out = [t("gateway.usage.breakdown_header")]
|
|
for cat in categories:
|
|
tokens = int(cat.get("tokens") or 0)
|
|
if tokens <= 0:
|
|
continue
|
|
cat_id = str(cat.get("id") or "")
|
|
label = t(f"gateway.usage.breakdown_cat_{cat_id}")
|
|
# Missing key → t() echoes the key back; fall back to the
|
|
# English label the engine already provides.
|
|
if label.endswith(f"breakdown_cat_{cat_id}"):
|
|
label = str(cat.get("label") or cat_id)
|
|
pct = round(tokens / total * 100) if total else 0
|
|
out.append(
|
|
t("gateway.usage.breakdown_line", label=label, count=f"{tokens:,}", pct=pct)
|
|
)
|
|
return out if len(out) > 1 else []
|
|
except Exception:
|
|
return []
|
|
|
|
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
|
"""Handle /usage command -- show token usage for the current session.
|
|
|
|
Checks both _running_agents (mid-turn) and _agent_cache (between turns) so details are
|
|
available whenever the user asks.
|
|
"""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL
|
|
source = event.source
|
|
session_key = self._session_key_for_source(source)
|
|
|
|
# `/usage reset [--force]` — redeem one banked Codex rate-limit reset
|
|
# credit. Parsed before the display path so it never mixes with the
|
|
# stats rendering below.
|
|
raw_args = event.get_command_args().strip()
|
|
args = [a.lower() for a in raw_args.split()] if raw_args else []
|
|
wants_reset = bool(args) and args[0] == "reset"
|
|
if args and not wants_reset:
|
|
return t("gateway.usage.unknown_subcommand", args=raw_args)
|
|
|
|
# Try running agent first (mid-turn), then cached agent (between turns)
|
|
agent = self._running_agents.get(session_key)
|
|
if not agent or agent is _AGENT_PENDING_SENTINEL:
|
|
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
|
_cache = getattr(self, "_agent_cache", None)
|
|
if _cache_lock and _cache is not None:
|
|
with _cache_lock:
|
|
cached = _cache.get(session_key)
|
|
if cached:
|
|
agent = cached[0]
|
|
|
|
# Resolve provider/base_url/api_key for the account-usage fetch. Prefer the live agent; fall
|
|
# back to persisted billing data on the SessionDB row so `/usage` still returns account info
|
|
# between turns when no agent is resident.
|
|
provider = getattr(agent, "provider", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
|
|
base_url = getattr(agent, "base_url", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
|
|
api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
|
|
if not provider and getattr(self, "_session_db", None) is not None:
|
|
try:
|
|
_entry_for_billing = await self.async_session_store.get_or_create_session(source)
|
|
persisted = await self._session_db.get_session(_entry_for_billing.session_id) or {}
|
|
route = await self._session_db.get_dominant_session_model_route(
|
|
_entry_for_billing.session_id
|
|
)
|
|
persisted_route = route if isinstance(route, dict) else {}
|
|
except Exception:
|
|
persisted = {}
|
|
persisted_route = {}
|
|
if persisted_route.get("billing_provider"):
|
|
provider = persisted_route["billing_provider"]
|
|
base_url = persisted_route.get("billing_base_url")
|
|
else:
|
|
provider = persisted.get("billing_provider")
|
|
base_url = persisted.get("billing_base_url")
|
|
|
|
if wants_reset:
|
|
normalized_provider = str(provider or "").strip().lower()
|
|
if normalized_provider != "openai-codex":
|
|
return t("gateway.usage.reset_wrong_provider")
|
|
force = "--force" in args[1:]
|
|
from agent.account_usage import redeem_codex_reset_credit
|
|
|
|
result = await asyncio.to_thread(
|
|
redeem_codex_reset_credit,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
force=force,
|
|
)
|
|
return result.message
|
|
|
|
# Fetch account usage off the event loop so slow provider APIs don't
|
|
# block the gateway. Failures are non-fatal -- account_lines stays [].
|
|
account_lines: list[str] = []
|
|
credits_lines: list[str] = []
|
|
if provider:
|
|
try:
|
|
account_snapshot = await asyncio.to_thread(
|
|
fetch_account_usage,
|
|
provider,
|
|
base_url=base_url,
|
|
api_key=api_key,
|
|
)
|
|
except Exception:
|
|
account_snapshot = None
|
|
if account_snapshot:
|
|
account_lines = render_account_usage_lines(account_snapshot, markdown=True)
|
|
|
|
# ── Nous credits magnitudes + monthly-grant % gauge ─────────────
|
|
# Shared with CLI/TUI via nous_credits_lines(); run off the event loop. Gates on "a Nous
|
|
# account is logged in" — NOT the inference provider, NOT under `if provider:` — so a Nous
|
|
# user inferring elsewhere still sees a balance. No recovery trigger; fail-open.
|
|
try:
|
|
from agent.account_usage import nous_credits_lines
|
|
|
|
credits_lines = await asyncio.to_thread(nous_credits_lines, markdown=True)
|
|
except Exception:
|
|
credits_lines = [] # fail-open: never break /usage
|
|
|
|
if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:
|
|
lines = []
|
|
|
|
# Rate limits (when available from provider headers)
|
|
rl_state = agent.get_rate_limit_state()
|
|
if rl_state and rl_state.has_data:
|
|
from agent.rate_limit_tracker import format_rate_limit_compact
|
|
lines.append(t("gateway.usage.rate_limits", state=format_rate_limit_compact(rl_state)))
|
|
lines.append("")
|
|
|
|
# Session token usage — detailed breakdown matching CLI
|
|
input_tokens = getattr(agent, "session_input_tokens", 0) or 0
|
|
output_tokens = getattr(agent, "session_output_tokens", 0) or 0
|
|
|
|
lines.append(t("gateway.usage.header_session"))
|
|
lines.append(t("gateway.usage.label_model", model=agent.model))
|
|
lines.append(t("gateway.usage.label_input_tokens", count=f"{input_tokens:,}"))
|
|
lines.append(t("gateway.usage.label_output_tokens", count=f"{output_tokens:,}"))
|
|
lines.append(t("gateway.usage.label_total", count=f"{agent.session_total_tokens:,}"))
|
|
lines.append(t("gateway.usage.label_api_calls", count=agent.session_api_calls))
|
|
|
|
# Context window and compressions
|
|
ctx = agent.context_compressor
|
|
_lpt = ctx.last_prompt_tokens if ctx.last_prompt_tokens > 0 else 0
|
|
if _lpt:
|
|
pct = min(100, _lpt / ctx.context_length * 100) if ctx.context_length else 0
|
|
lines.append(t("gateway.usage.label_context", used=f"{_lpt:,}", total=f"{ctx.context_length:,}", pct=f"{pct:.0f}"))
|
|
if ctx.compression_count:
|
|
lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count))
|
|
|
|
# Per-category context breakdown (estimated — chars/4 heuristic). Same engine the
|
|
# desktop popover uses. The system prompt / tools / skills / memory slices read off the
|
|
# live agent; the conversation slice is estimated from the session transcript.
|
|
breakdown_lines = await asyncio.to_thread(
|
|
self._context_breakdown_lines, agent, source
|
|
)
|
|
if breakdown_lines:
|
|
lines.append("")
|
|
lines.extend(breakdown_lines)
|
|
|
|
if account_lines:
|
|
lines.append("")
|
|
lines.extend(account_lines)
|
|
if credits_lines:
|
|
lines.append("")
|
|
lines.extend(credits_lines)
|
|
|
|
return "\n".join(lines)
|
|
|
|
# No agent at all -- check session history for a rough count
|
|
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 history:
|
|
from agent.model_metadata import estimate_messages_tokens_rough
|
|
msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")]
|
|
approx = estimate_messages_tokens_rough(msgs)
|
|
lines = [
|
|
t("gateway.usage.header_session_info"),
|
|
t("gateway.usage.label_messages", count=len(msgs)),
|
|
t("gateway.usage.label_estimated_context", count=f"{approx:,}"),
|
|
t("gateway.usage.detailed_after_first"),
|
|
]
|
|
if account_lines:
|
|
lines.append("")
|
|
lines.extend(account_lines)
|
|
if credits_lines:
|
|
lines.append("")
|
|
lines.extend(credits_lines)
|
|
return "\n".join(lines)
|
|
if account_lines or credits_lines:
|
|
# account-only, credits-only, or both — joined with a blank divider.
|
|
parts = list(account_lines)
|
|
if credits_lines:
|
|
if parts:
|
|
parts.append("")
|
|
parts.extend(credits_lines)
|
|
return "\n".join(parts)
|
|
return t("gateway.usage.no_data")
|
|
|
|
async def _handle_insights_command(self, event: MessageEvent) -> str:
|
|
"""Handle /insights command -- show usage insights and analytics."""
|
|
args = event.get_command_args().strip()
|
|
|
|
# Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
|
|
args = re.sub(r'[\u2012\u2013\u2014\u2015](days|source)', r'--\1', args)
|
|
|
|
days = 30
|
|
source = None
|
|
|
|
# Parse simple args: /insights 7 or /insights --days 7
|
|
if args:
|
|
parts = args.split()
|
|
i = 0
|
|
while i < len(parts):
|
|
if parts[i] == "--days" and i + 1 < len(parts):
|
|
try:
|
|
days = int(parts[i + 1])
|
|
except ValueError:
|
|
return t("gateway.insights.invalid_days", value=parts[i + 1])
|
|
i += 2
|
|
elif parts[i] == "--source" and i + 1 < len(parts):
|
|
source = parts[i + 1]
|
|
i += 2
|
|
elif parts[i].isdigit():
|
|
days = int(parts[i])
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
|
|
try:
|
|
from hermes_state import get_shared_session_db
|
|
from agent.insights import InsightsEngine
|
|
|
|
def _run_insights():
|
|
db = get_shared_session_db()
|
|
try:
|
|
engine = InsightsEngine(db)
|
|
report = engine.generate(days=days, source=source)
|
|
result = engine.format_gateway(report)
|
|
return result
|
|
finally:
|
|
from hermes_state import release_or_close
|
|
release_or_close(db)
|
|
|
|
# Not a bare hop: ``SessionDB()`` resolves ``get_hermes_home()`` at call time, which is
|
|
# a contextvar set by ``_profile_runtime_scope``; a default-executor hop starts with an
|
|
# EMPTY context and would read the DEFAULT profile's state.db.
|
|
return await self._run_in_executor_with_context(_run_insights)
|
|
except Exception as e:
|
|
logger.error("Insights command error: %s", e, exc_info=True)
|
|
return t("gateway.insights.error", error=e)
|
|
|
|
async def _handle_reload_mcp_command(self, event: MessageEvent) -> Optional[str]:
|
|
"""Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent.
|
|
|
|
Reloading invalidates the provider prompt cache (tool schemas live in the system prompt),
|
|
so it routes through slash-confirm; "Always Approve" persists
|
|
``approvals.mcp_reload_confirm: false``.
|
|
"""
|
|
source = event.source
|
|
session_key = self._session_key_for_source(source)
|
|
|
|
# Read the gate fresh from disk so a prior "always" click takes
|
|
# effect on the next invocation without restarting the gateway.
|
|
user_config = self._read_user_config()
|
|
approvals = user_config.get("approvals") if isinstance(user_config, dict) else None
|
|
confirm_required = True
|
|
if isinstance(approvals, dict):
|
|
confirm_required = bool(approvals.get("mcp_reload_confirm", True))
|
|
|
|
if not confirm_required:
|
|
return await self._execute_mcp_reload(event)
|
|
|
|
# Route through slash-confirm. The primitive sends the prompt and stores the resume handler;
|
|
# the button/text response triggers ``_resolve_slash_confirm`` which invokes the handler
|
|
# with the chosen outcome.
|
|
async def _on_confirm(choice: str) -> Optional[str]:
|
|
if choice == "cancel":
|
|
return t("gateway.reload_mcp.cancelled")
|
|
if choice == "always":
|
|
# Persist the opt-out and run the reload.
|
|
try:
|
|
from cli import save_config_value
|
|
save_config_value("approvals.mcp_reload_confirm", False)
|
|
logger.info(
|
|
"User opted out of /reload-mcp confirmation (session=%s)",
|
|
session_key,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Failed to persist mcp_reload_confirm=false: %s", exc)
|
|
# once / always → run the reload
|
|
result = await self._execute_mcp_reload(event)
|
|
if choice == "always":
|
|
return f"{result}\n\n" + t("gateway.reload_mcp.always_followup")
|
|
return result
|
|
|
|
prompt_message = t("gateway.reload_mcp.confirm_prompt")
|
|
return await self._request_slash_confirm(
|
|
event=event,
|
|
command="reload-mcp",
|
|
title="/reload-mcp",
|
|
message=prompt_message,
|
|
handler=_on_confirm,
|
|
)
|
|
|
|
async def _handle_reload_skills_command(self, event: MessageEvent) -> str:
|
|
"""Handle /reload-skills — rescan skills dir, queue a note for next turn.
|
|
|
|
Skills are invoked at runtime, not baked into the system prompt, so this does NOT clear the
|
|
prompt cache. Added/removed skills go into ``_pending_skills_reload_notes[session_key]``,
|
|
prepended to the NEXT user message — nothing out-of-band, so alternation is preserved.
|
|
"""
|
|
try:
|
|
from agent.skill_commands import reload_skills
|
|
|
|
# _run_in_executor_with_context, not a bare hop: the rescan walks
|
|
# get_hermes_home()/skills, a contextvar override under multiplex.
|
|
result = await self._run_in_executor_with_context(reload_skills)
|
|
added = result.get("added", []) # [{"name", "description"}, ...]
|
|
removed = result.get("removed", []) # [{"name", "description"}, ...]
|
|
total = result.get("total", 0)
|
|
|
|
# Let adapters refresh platform-side state that cached the skill list at startup (today:
|
|
# Discord /skill autocomplete — otherwise new skills stay invisible and deleted ones
|
|
# error). Adapters without refresh_skill_group are skipped; the in-process reload suffices.
|
|
for adapter in list(self.adapters.values()):
|
|
refresh = getattr(adapter, "refresh_skill_group", None)
|
|
if not callable(refresh):
|
|
continue
|
|
try:
|
|
maybe = refresh()
|
|
if inspect.isawaitable(maybe):
|
|
await maybe
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Adapter %s refresh_skill_group raised: %s",
|
|
getattr(adapter, "name", adapter), exc,
|
|
)
|
|
|
|
lines = [t("gateway.reload_skills.header")]
|
|
if not added and not removed:
|
|
lines.append(t("gateway.reload_skills.no_new"))
|
|
lines.append(t("gateway.reload_skills.total", count=total))
|
|
return "\n".join(lines)
|
|
|
|
def _fmt_line(item: dict) -> str:
|
|
nm = item.get("name", "")
|
|
desc = item.get("description", "")
|
|
if desc:
|
|
return t("gateway.reload_skills.item_with_desc", name=nm, desc=desc)
|
|
return t("gateway.reload_skills.item_no_desc", name=nm)
|
|
|
|
if added:
|
|
lines.append(t("gateway.reload_skills.added_header"))
|
|
for item in added:
|
|
lines.append(_fmt_line(item))
|
|
if removed:
|
|
lines.append(t("gateway.reload_skills.removed_header"))
|
|
for item in removed:
|
|
lines.append(_fmt_line(item))
|
|
lines.append(t("gateway.reload_skills.total", count=total))
|
|
|
|
# Queue the one-shot note for the next user turn in this session. Format matches how the
|
|
# system prompt renders pre-existing skills (`` - name: description``) so the model
|
|
# reads the diff in the same shape as its original skill catalog.
|
|
sections = ["[USER INITIATED SKILLS RELOAD:"]
|
|
if added:
|
|
sections.append("")
|
|
sections.append("Added Skills:")
|
|
for item in added:
|
|
sections.append(_fmt_line(item))
|
|
if removed:
|
|
sections.append("")
|
|
sections.append("Removed Skills:")
|
|
for item in removed:
|
|
sections.append(_fmt_line(item))
|
|
sections.append("")
|
|
sections.append("Use skills_list to see the updated catalog.]")
|
|
note = "\n".join(sections)
|
|
|
|
session_key = self._session_key_for_source(event.source)
|
|
if not hasattr(self, "_pending_skills_reload_notes"):
|
|
self._pending_skills_reload_notes = {}
|
|
if session_key:
|
|
self._pending_skills_reload_notes[session_key] = note
|
|
|
|
return "\n".join(lines)
|
|
|
|
except Exception as e:
|
|
logger.warning("Skills reload failed: %s", e)
|
|
return t("gateway.reload_skills.failed", error=e)
|
|
|
|
async def _handle_bundles_command(self, event: MessageEvent) -> str:
|
|
"""Handle /bundles — list installed skill bundles (mirrors the CLI handler).
|
|
|
|
Bundles are loaded by invoking their own ``/<slug>`` command, not by this one.
|
|
"""
|
|
from hermes_cli.slash_exec import CommandContext, execute_command
|
|
|
|
reply = execute_command("bundles", CommandContext(surface="gateway"))
|
|
if "error" in reply.data:
|
|
logger.warning("Bundles command unavailable: %s", reply.data["error"])
|
|
return reply.text
|
|
|
|
bundles = reply.data["bundles"]
|
|
if not bundles:
|
|
return (
|
|
"No skill bundles installed.\n"
|
|
"Create one on the host with:\n"
|
|
" `hermes bundles create <name> --skill <s1> --skill <s2>`\n"
|
|
f"Directory: `{reply.data['dir']}`"
|
|
)
|
|
|
|
lines = [f"**Skill Bundles** ({len(bundles)} installed):", ""]
|
|
for info in bundles:
|
|
skill_count = len(info.get("skills", []))
|
|
desc = info.get("description") or f"Load {skill_count} skills"
|
|
lines.append(
|
|
f"• `/{info['slug']}` — {desc} _({skill_count} skills)_"
|
|
)
|
|
for s in info.get("skills", []):
|
|
lines.append(f" · {s}")
|
|
lines.append("")
|
|
lines.append("Invoke a bundle with `/<slug>` to load all its skills.")
|
|
return "\n".join(lines)
|
|
|
|
async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
|
|
"""Handle /approve command — unblock waiting agent thread(s).
|
|
|
|
Agent threads block inside tools/approval.py; signalling the event resumes them so the
|
|
command executes inline — same flow as the CLI's synchronous approval.
|
|
"""
|
|
source = event.source
|
|
session_key = self._session_key_for_source(source)
|
|
|
|
from tools.approval import (
|
|
resolve_gateway_approval, has_blocking_approval,
|
|
)
|
|
|
|
if not has_blocking_approval(session_key):
|
|
if session_key in self._pending_approvals:
|
|
self._pending_approvals.pop(session_key)
|
|
return t("gateway.approval_expired")
|
|
return t("gateway.approve.no_pending")
|
|
|
|
# Parse args: support "all", "all session", "all always", "session", "always"
|
|
args = event.get_command_args().strip().lower().split()
|
|
resolve_all = "all" in args
|
|
remaining = [a for a in args if a != "all"]
|
|
|
|
if any(a in {"always", "permanent", "permanently"} for a in remaining):
|
|
choice = "always"
|
|
elif any(a in {"session", "ses"} for a in remaining):
|
|
choice = "session"
|
|
else:
|
|
choice = "once"
|
|
|
|
count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all)
|
|
if not count:
|
|
return t("gateway.approve.no_pending")
|
|
|
|
# Resume typing indicator — agent is about to continue processing.
|
|
_adapter = self.adapters.get(source.platform)
|
|
if _adapter:
|
|
_adapter.resume_typing_for_chat(source.chat_id)
|
|
|
|
logger.info("User approved %d dangerous command(s) via /approve (%s)", count, choice)
|
|
plural = "plural" if count > 1 else "singular"
|
|
confirmation_text = t(f"gateway.approve.{choice}_{plural}", count=count)
|
|
# Native-streaming adapters (WeCom msgtype:"stream") need the confirmation sent directly
|
|
# with control-lane metadata (reliable proactive send, not the finalized reply stream).
|
|
# Everyone else returns text for normal delivery. (`is not True`: mocks auto-create attrs.)
|
|
if getattr(_adapter, "SUPPORTS_NATIVE_STREAMING", False) is not True:
|
|
return confirmation_text
|
|
if _adapter:
|
|
try:
|
|
await _adapter.send(
|
|
source.chat_id,
|
|
confirmation_text,
|
|
reply_to=event.message_id,
|
|
metadata={
|
|
"is_approval_prompt": True,
|
|
"force_proactive_send": True,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to send /approve confirmation to %s: %s",
|
|
source.chat_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
|
|
return None
|
|
|
|
async def _handle_deny_command(self, event: MessageEvent) -> str:
|
|
"""Handle /deny command — reject pending dangerous command(s).
|
|
|
|
Signals blocked thread(s) with a 'deny' result so they get a definitive BLOCKED message,
|
|
as in the CLI. ``/deny`` denies the oldest; ``/deny all`` denies everything.
|
|
"""
|
|
source = event.source
|
|
session_key = self._session_key_for_source(source)
|
|
|
|
from tools.approval import (
|
|
resolve_gateway_approval, has_blocking_approval,
|
|
)
|
|
|
|
if not has_blocking_approval(session_key):
|
|
if session_key in self._pending_approvals:
|
|
self._pending_approvals.pop(session_key)
|
|
return t("gateway.deny.stale")
|
|
return t("gateway.deny.no_pending")
|
|
|
|
# Parse args: a leading "all" token denies every pending command;
|
|
# anything after it (or the whole arg string when "all" is absent) is
|
|
# captured verbatim as the optional deny reason relayed to the agent.
|
|
raw_args = event.get_command_args().strip()
|
|
tokens = raw_args.split()
|
|
resolve_all = bool(tokens) and tokens[0].lower() == "all"
|
|
reason = raw_args[len(tokens[0]):].strip() if resolve_all else raw_args
|
|
# Cap to a sane one-liner; the agent only needs a short hint.
|
|
if reason:
|
|
reason = reason[:280].strip()
|
|
|
|
count = resolve_gateway_approval(
|
|
session_key, "deny", resolve_all=resolve_all,
|
|
reason=reason or None,
|
|
)
|
|
if not count:
|
|
return t("gateway.deny.no_pending")
|
|
|
|
# Resume typing indicator — agent continues (with BLOCKED result).
|
|
_adapter = self.adapters.get(source.platform)
|
|
if _adapter:
|
|
_adapter.resume_typing_for_chat(source.chat_id)
|
|
|
|
logger.info(
|
|
"User denied %d dangerous command(s) via /deny%s",
|
|
count, " (with reason)" if reason else "",
|
|
)
|
|
if reason:
|
|
if count > 1:
|
|
confirmation_text = t("gateway.deny.denied_reason_plural", count=count, reason=reason)
|
|
else:
|
|
confirmation_text = t("gateway.deny.denied_reason_singular", reason=reason)
|
|
elif count > 1:
|
|
confirmation_text = t("gateway.deny.denied_plural", count=count)
|
|
else:
|
|
confirmation_text = t("gateway.deny.denied_singular")
|
|
|
|
# Same native-streaming carve-out as /approve above: only WeCom-style native-stream adapters
|
|
# take the direct control-lane send; everyone else returns the text for normal gateway
|
|
# delivery. (`is not True` — mock adapters auto-create truthy attributes.)
|
|
if getattr(_adapter, "SUPPORTS_NATIVE_STREAMING", False) is not True:
|
|
return confirmation_text
|
|
if _adapter:
|
|
try:
|
|
await _adapter.send(
|
|
source.chat_id,
|
|
confirmation_text,
|
|
reply_to=event.message_id,
|
|
metadata={
|
|
"is_approval_prompt": True,
|
|
"force_proactive_send": True,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to send /deny confirmation to %s: %s",
|
|
source.chat_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
|
|
return None
|
|
|
|
async def _handle_debug_command(self, event: MessageEvent) -> str:
|
|
"""Handle /debug — upload debug report (summary only) and return paste URLs.
|
|
|
|
Uploads ONLY the summary (system info + log tails), never full logs, to protect privacy;
|
|
use ``hermes debug share`` from the CLI for full uploads.
|
|
"""
|
|
from hermes_cli.debug import (
|
|
_capture_dump, collect_debug_report,
|
|
upload_to_pastebin, _schedule_auto_delete,
|
|
_GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes,
|
|
)
|
|
|
|
# Run blocking I/O (dump capture, log reads, uploads) in a thread.
|
|
def _collect_and_upload():
|
|
_best_effort_sweep_expired_pastes()
|
|
dump_text = _capture_dump()
|
|
report = collect_debug_report(log_lines=200, dump_text=dump_text)
|
|
|
|
urls = {}
|
|
try:
|
|
urls["Report"] = upload_to_pastebin(report)
|
|
except Exception as exc:
|
|
return t("gateway.debug.upload_failed", error=exc)
|
|
|
|
# Schedule auto-deletion after 6 hours
|
|
_schedule_auto_delete(list(urls.values()))
|
|
|
|
lines = [_GATEWAY_PRIVACY_NOTICE, "", t("gateway.debug.header"), ""]
|
|
label_width = max(len(k) for k in urls)
|
|
for label, url in urls.items():
|
|
lines.append(f"`{label:<{label_width}}` {url}")
|
|
|
|
lines.append("")
|
|
lines.append(t("gateway.debug.auto_delete"))
|
|
lines.append(t("gateway.debug.full_logs_hint"))
|
|
lines.append(t("gateway.debug.share_hint"))
|
|
return "\n".join(lines)
|
|
|
|
# _run_in_executor_with_context, not a bare hop: this collects the profile's logs/config off
|
|
# ``get_hermes_home()`` and uploads them to a public paste. Losing the contextvar override
|
|
# would publish the DEFAULT profile's diagnostics from another profile's chat.
|
|
return await self._run_in_executor_with_context(_collect_and_upload)
|
|
|
|
async def _handle_update_command(self, event: MessageEvent) -> str:
|
|
"""Handle /update command — update Hermes Agent to the latest version.
|
|
|
|
Spawns ``hermes update`` detached (``setsid``) so it survives the gateway restart it may
|
|
trigger; marker files let this or the next gateway process notify the user on completion.
|
|
"""
|
|
from gateway.run import _hermes_home, _resolve_hermes_bin
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime
|
|
from hermes_cli.config import is_managed, format_managed_message
|
|
|
|
# Block non-messaging platforms (API server, webhooks, ACP)
|
|
platform = event.source.platform
|
|
_allowed = self._UPDATE_ALLOWED_PLATFORMS
|
|
# Plugin platforms with allow_update_command=True are also allowed
|
|
if platform not in _allowed:
|
|
try:
|
|
from gateway.platform_registry import platform_registry
|
|
entry = platform_registry.get(platform.value)
|
|
if not entry or not entry.allow_update_command:
|
|
return t("gateway.update.platform_not_messaging")
|
|
except Exception:
|
|
return t("gateway.update.platform_not_messaging")
|
|
|
|
if is_managed():
|
|
return f"✗ {format_managed_message('update Hermes Agent')}"
|
|
|
|
project_root = Path(__file__).parent.parent.resolve()
|
|
git_dir = project_root / '.git'
|
|
|
|
if not git_dir.exists():
|
|
return t("gateway.update.not_git_repo")
|
|
|
|
hermes_cmd = _resolve_hermes_bin()
|
|
if not hermes_cmd:
|
|
return t("gateway.update.hermes_cmd_not_found")
|
|
|
|
pending_path = _hermes_home / ".update_pending.json"
|
|
output_path = _hermes_home / ".update_output.txt"
|
|
exit_code_path = _hermes_home / ".update_exit_code"
|
|
session_key = self._session_key_for_source(event.source)
|
|
pending = {
|
|
"platform": event.source.platform.value,
|
|
"chat_id": event.source.chat_id,
|
|
"chat_type": event.source.chat_type,
|
|
"user_id": event.source.user_id,
|
|
"session_key": session_key,
|
|
"timestamp": datetime.now().isoformat(),
|
|
}
|
|
if event.source.thread_id:
|
|
pending["thread_id"] = event.source.thread_id
|
|
if event.message_id:
|
|
pending["message_id"] = event.message_id
|
|
_tmp_pending = pending_path.with_suffix(".tmp")
|
|
_tmp_pending.write_text(json.dumps(pending), encoding="utf-8")
|
|
_tmp_pending.replace(pending_path)
|
|
exit_code_path.unlink(missing_ok=True)
|
|
|
|
# Spawn `hermes update --gateway` detached (setsid: portable, works where systemd-run --user
|
|
# lacks a D-Bus session) so it survives gateway restart. --gateway enables file-based IPC
|
|
# for interactive prompts so the gateway forwards them instead of skipping. PYTHONUNBUFFERED
|
|
# lets the gateway stream output in near-real-time.
|
|
# Windows: no setsid chain — an inline Python helper via sys.executable runs the command,
|
|
# redirects both outputs to the same files, and writes the exit code.
|
|
try:
|
|
if sys.platform == "win32":
|
|
import textwrap
|
|
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
|
|
|
# Invoke the updater as a module under this interpreter rather than through
|
|
# hermes_cmd (venv\Scripts\hermes.exe): the shim launcher holds its own file open
|
|
# for the whole run, and the update has to replace it.
|
|
helper = textwrap.dedent(
|
|
"""
|
|
import os, subprocess, sys
|
|
output_path = sys.argv[1]
|
|
exit_code_path = sys.argv[2]
|
|
cmd = sys.argv[3:]
|
|
env = dict(os.environ)
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
with open(output_path, "wb") as f:
|
|
proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env)
|
|
rc = proc.wait(timeout=3600)
|
|
with open(exit_code_path, "w", encoding="utf-8") as f:
|
|
f.write(str(rc))
|
|
"""
|
|
).strip()
|
|
subprocess.Popen(
|
|
[
|
|
sys.executable, "-c", helper,
|
|
str(output_path), str(exit_code_path),
|
|
sys.executable, "-m", "hermes_cli.main",
|
|
"update", "--gateway",
|
|
],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
**windows_detach_popen_kwargs(),
|
|
)
|
|
else:
|
|
hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
|
|
update_cmd = (
|
|
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway"
|
|
f" > {shlex.quote(str(output_path))} 2>&1; "
|
|
# Avoid `status=$?`: `status` is read-only in zsh and this template is reused in
|
|
# macOS/zsh operator wrappers, so keep it zsh-safe even though bash runs it here.
|
|
f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}"
|
|
)
|
|
setsid_bin = shutil.which("setsid")
|
|
if setsid_bin:
|
|
# Preferred: setsid creates a new session, fully detached
|
|
subprocess.Popen(
|
|
[setsid_bin, "bash", "-c", update_cmd],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
else:
|
|
# Fallback: start_new_session=True calls os.setsid() in child
|
|
subprocess.Popen(
|
|
["bash", "-c", update_cmd],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except Exception as e:
|
|
pending_path.unlink(missing_ok=True)
|
|
exit_code_path.unlink(missing_ok=True)
|
|
return t("gateway.update.start_failed", error=e)
|
|
|
|
self._schedule_update_notification_watch()
|
|
return t("gateway.update.starting")
|