From d6615d8ec7d5c5a3d7b63e3c2c4bfddbd6fddd4b Mon Sep 17 00:00:00 2001 From: EmelyanenkoK Date: Sat, 2 May 2026 18:04:57 +0300 Subject: [PATCH 01/28] feat: add Telegram DM topic-mode sessions --- ...ram-dm-user-managed-multisession-topics.md | 473 +++++++++++++ gateway/run.py | 265 +++++++- hermes_cli/commands.py | 2 + hermes_state.py | 259 ++++++++ tests/gateway/test_telegram_topic_mode.py | 625 ++++++++++++++++++ tests/hermes_cli/test_commands.py | 6 + tests/test_hermes_state.py | 237 +++++++ ui-tui/package-lock.json | 41 +- 8 files changed, 1890 insertions(+), 18 deletions(-) create mode 100644 docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md create mode 100644 tests/gateway/test_telegram_topic_mode.py diff --git a/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md b/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md new file mode 100644 index 00000000000..43c0e5da788 --- /dev/null +++ b/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md @@ -0,0 +1,473 @@ +# Telegram DM User-Managed Multi-Session Topics Implementation Plan + +> **For Hermes:** Use test-driven-development for implementation. Use subagent-driven-development only after this plan is split into small reviewed tasks. + +**Goal:** Add an opt-in Telegram DM multi-session mode where Telegram user-created private-chat topics become independent Hermes session lanes, while the root DM becomes a system lobby. + +**Architecture:** Rely on Telegram's native private-chat topic UI. Users create new topics with the `+` button; Hermes maps each `message_thread_id` to a separate session lane. Hermes does not create topics for normal `/new` flow and does not try to manage topic lifecycle beyond activation/status, root-lobby behavior, and restoring legacy sessions into a user-created topic. + +**Tech Stack:** Hermes gateway, Telegram Bot API 9.4+, python-telegram-bot adapter, SQLite SessionDB / side tables, pytest. + +--- + +## 1. Product decisions + +### Accepted + +- PR-quality implementation: migrations, tests, docs, backwards compatibility. +- Use SQLite persistence, not JSON sidecars. +- Live status suffixes in topic titles are out of MVP. +- Topic title sync/editing is out of MVP except future-compatible storage if cheap. +- User creates Telegram topics manually through the Telegram bot interface. +- `/new` does **not** create Telegram topics. +- Root/main DM becomes a system lobby after activation. +- Existing Telegram behavior remains unchanged until the feature is activated/enabled. +- Migration of old sessions is supported through `/topic` listing and `/topic ` restore inside a user-created topic. + +### Telegram API assumptions verified from Bot API docs + +- `getMe` returns bot `User` fields: + - `has_topics_enabled`: forum/topic mode enabled in private chats. + - `allows_users_to_create_topics`: users may create/delete topics in private chats. +- `createForumTopic` works for private chats with a user, but MVP does not rely on it for normal flow. +- `Message.message_thread_id` identifies a topic in private chats. +- `sendMessage` supports `message_thread_id` for private-chat topics. +- `pinChatMessage` is allowed in private chats. + +--- + +## 2. Target UX + +### 2.1 Activation from root/main DM + +User sends: + +```text +/topic +``` + +Hermes: + +1. calls Telegram `getMe`; +2. verifies `has_topics_enabled` and `allows_users_to_create_topics`; +3. enables multi-session topic mode for this Telegram DM user/chat; +4. sends an onboarding message; +5. pins the onboarding message if configured; +6. shows old/unlinked sessions that can be restored into topics. + +Suggested onboarding text: + +```text +Multi-session mode is enabled. + +Create new Hermes chats with the + button in this bot interface. Each Telegram topic is an independent Hermes session, so you can work on different tasks in parallel. + +This main chat is reserved for system commands, status, and session management. + +To restore an old session: +1. Use /topic here to see unlinked sessions. +2. Create a new topic with the + button. +3. Send /topic inside that topic. +``` + +### 2.2 Root/main DM after activation + +Root DM is a system lobby. + +Allowed/system commands include at least: + +- `/topic` +- `/status` +- `/sessions` if available +- `/usage` +- `/help` +- `/platforms` + +Normal user prompts in root DM do not enter the agent loop. Reply: + +```text +This main chat is reserved for system commands. + +To chat with Hermes, create a new topic using the + button in this bot interface. Each topic works as an independent Hermes session. +``` + +`/new` in root DM does not create a session/topic. Reply: + +```text +To start a new parallel Hermes chat, create a new topic with the + button in this bot interface. + +Each topic is an independent Hermes session. Use /new inside a topic only if you want to replace that topic's current session. +``` + +### 2.3 First message in a user-created topic + +When a user creates a Telegram topic and sends the first message there: + +1. Hermes receives a Telegram DM message with `message_thread_id`. +2. Hermes derives the existing thread-aware `session_key` from `(platform=telegram, chat_type=dm, chat_id, thread_id)`. +3. If no binding exists, Hermes creates a fresh Hermes session for this topic lane and persists the binding. +4. The message runs through the normal agent loop for that lane. + +### 2.4 `/new` inside a non-main topic + +`/new` remains supported but replaces the session attached to the current topic lane. + +Hermes should warn: + +```text +Started a new Hermes session in this topic. + +Tip: for parallel work, create a new topic with the + button instead of using /new here. /new replaces the session attached to the current topic. +``` + +### 2.5 `/topic` in root/main DM after activation + +Shows: + +- mode enabled/disabled; +- last capability check result; +- whether intro message is pinned if known; +- count of known topic bindings; +- list of old/unlinked sessions. + +Example: + +```text +Telegram multi-session topics are enabled. + +Create new Hermes chats with the + button in this bot interface. + +Unlinked previous sessions: +1. 2026-05-01 Research notes — id: abc123 +2. 2026-04-30 Deploy debugging — id: def456 +3. Untitled session — id: ghi789 + +To restore one: +1. Create a new topic with the + button. +2. Open that topic. +3. Send /topic +``` + +### 2.6 `/topic` inside a non-main topic + +Without args, show the current topic binding: + +```text +This topic is linked to: +Session: Research notes +ID: abc123 + +Use /new to replace this topic with a fresh session. +For parallel work, create another topic with the + button. +``` + +### 2.7 `/topic ` inside a non-main topic + +Restore an old/unlinked session into the current user-created topic. + +Behavior: + +1. reject if not in Telegram DM topic; +2. verify session belongs to the same Telegram user/chat or is a safe legacy root DM session for this user; +3. reject if session is already linked to another active topic in MVP; +4. `SessionStore.switch_session(current_topic_session_key, target_session_id)`; +5. upsert binding with `managed_mode = restored`; +6. send two messages into the topic: + - session restored confirmation; + - last Hermes assistant message if available. + +Example: + +```text +Session restored: Research notes + +Last Hermes message: +... +``` + +--- + +## 3. Persistence model + +Use SQLite, but topic-mode schema changes are **explicit opt-in migrations**, not automatic startup reconciliation. + +Important rollback-safety rule: + +- upgrading Hermes and starting the gateway must not create Telegram topic-mode tables or columns; +- old/default Telegram behavior must keep working on the existing `state.db`; +- the first `/topic` activation path calls an idempotent explicit migration, then enables topic mode for that chat; +- if activation fails before the migration is needed, the database remains in the pre-topic-mode shape. + +### 3.1 No eager `sessions` table mutation for MVP + +Do **not** add `chat_id`, `chat_type`, `thread_id`, or `session_key` columns to `sessions` as part of ordinary `SessionDB()` startup. The existing declarative `_reconcile_columns()` mechanism would add them eagerly on every process start, which violates the managed-migration requirement. + +For MVP, keep origin/session-lane data in topic-specific side tables created only by the explicit `/topic` migration. Legacy unlinked sessions can be discovered conservatively from existing data (`source = telegram`, `user_id = current Telegram user`) plus absence from topic bindings. + +If future PRs need richer origin metadata for all gateway sessions, introduce it behind a separate explicit migration/command or a compatibility-reviewed schema bump. + +### 3.2 Explicit `/topic` migration API + +Add an idempotent method such as: + +```python +def apply_telegram_topic_migration(self) -> None: ... +``` + +It creates only topic-mode side tables/indexes and records: + +```text +state_meta.telegram_dm_topic_schema_version = 1 +``` + +This method is called from `/topic` activation/status paths before reading or writing topic-mode state. It is not called from generic `SessionDB.__init__`, gateway startup, CLI startup, or auto-maintenance. + +### 3.3 `telegram_dm_topic_mode` + +Stores per-user/chat activation state. Created only by `apply_telegram_topic_migration()`. + +Suggested fields: + +- `chat_id` primary key +- `user_id` +- `enabled` +- `activated_at` +- `updated_at` +- `has_topics_enabled` +- `allows_users_to_create_topics` +- `capability_checked_at` +- `intro_message_id` +- `pinned_message_id` + +### 3.4 `telegram_dm_topic_bindings` + +Stores Telegram topic/thread to Hermes session binding. Created only by `apply_telegram_topic_migration()`. + +Suggested fields: + +- `chat_id` +- `thread_id` +- `user_id` +- `session_key` +- `session_id` +- `managed_mode` + - `auto` + - `restored` + - `new_replaced` +- `linked_at` +- `updated_at` + +Recommended constraints: + +- primary key `(chat_id, thread_id)`; +- unique index on `session_id` for MVP to prevent one session linked to multiple topics; +- index `(user_id, chat_id)` for status/listing. + +### 3.5 Unlinked session semantics + +For MVP, a session is unlinked if: + +- `source = telegram`; +- `user_id = current Telegram user`; +- no row in `telegram_dm_topic_bindings` has `session_id = session_id`. + +This is intentionally conservative until a future explicit migration adds richer cross-platform origin metadata. + +Never dedupe by title. + +--- + +## 4. Config + +Suggested config block: + +```yaml +platforms: + telegram: + extra: + multisession_topics: + enabled: false + mode: user_managed_topics + root_chat_behavior: system_lobby + pin_intro_message: true +``` + +Notes: + +- `enabled: false` means existing Telegram behavior is unchanged. +- Activation via `/topic` may create per-chat enabled state only if global config permits it. +- `root_chat_behavior: system_lobby` is the MVP behavior for activated chats. + +--- + +## 5. Command behavior summary + +### `/topic` root/main DM + +- If not activated: capability check, activate, send/pin onboarding, list unlinked sessions. +- If activated: show status and unlinked sessions. + +### `/topic` non-main topic + +- Show current binding. + +### `/topic ` root/main DM + +Reject with instructions: + +```text +Create a new topic with the + button, open it, then send /topic there to restore this session. +``` + +### `/topic ` non-main topic + +Restore that session into this topic if ownership/linking checks pass. + +### `/new` root/main DM when activated + +Reply with instructions to use the `+` button. Do not enter agent loop. + +### `/new` non-main topic + +Create a new session in the current topic lane, persist/update binding, warn that `+` is preferred for parallel work. + +### Normal text root/main DM when activated + +Reply with system-lobby instruction. Do not enter agent loop. + +### Normal text non-main topic + +Normal Hermes agent flow for that topic's session lane. + +--- + +## 6. PR breakdown + +### PR 1 — Explicit topic-mode schema migration + +**Goal:** Add rollback-safe SQLite support for Telegram topic mode without mutating `state.db` on ordinary upgrade/startup. + +**Files likely touched:** + +- `hermes_state.py` +- tests under `tests/` + +**Tests first:** + +1. opening an old/current DB with `SessionDB()` does not create topic-mode tables or `sessions` origin columns; +2. calling `apply_telegram_topic_migration()` creates `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` idempotently; +3. migration records `state_meta.telegram_dm_topic_schema_version = 1`. + +### PR 2 — Topic mode activation and binding APIs + +**Goal:** Add SQLite persistence for activation and topic bindings. + +**Tests first:** + +1. enable/check mode row round-trips; +2. binding upsert and lookup by `(chat_id, user_id, thread_id)`; +3. linked sessions are excluded from unlinked list. + +### PR 3 — `/topic` activation/status command + +**Goal:** Implement root activation/status/listing behavior. + +**Tests first:** + +1. `/topic` in root checks `getMe` capabilities and records activation; +2. capability failure returns readable instructions; +3. activated root `/topic` lists unlinked sessions. + +### PR 4 — System lobby behavior + +**Goal:** Prevent root chat from entering agent loop after activation. + +**Tests first:** + +1. normal text in activated root returns lobby instruction; +2. `/new` in activated root returns `+` button instruction; +3. non-activated root behavior is unchanged. + +### PR 5 — Auto-bind user-created topics + +**Goal:** First message in non-main topic creates/uses an independent session lane. + +**Tests first:** + +1. new topic message creates binding with `auto_created`; +2. repeated topic message reuses same binding/lane; +3. two topics in same DM do not share sessions. + +### PR 6 — Restore legacy sessions into a topic + +**Goal:** Implement `/topic ` in non-main topics. + +**Tests first:** + +1. root `/topic ` rejects with instructions; +2. topic `/topic ` switches current topic lane to target session; +3. restore rejects sessions from other users/chats; +4. restore rejects already-linked sessions; +5. restore emits confirmation and last Hermes assistant message. + +### PR 7 — `/new` inside topic updates binding + +**Goal:** Keep existing `/new` semantics but persist topic binding replacement. + +**Tests first:** + +1. `/new` in topic creates a new session for same topic lane; +2. binding updates to `managed_mode = new_replaced`; +3. response includes guidance to use `+` for parallel work. + +### PR 8 — Docs and polish + +**Goal:** Document the feature and Telegram setup. + +**Files likely touched:** + +- `website/docs/user-guide/messaging/telegram.md` +- maybe `website/docs/user-guide/sessions.md` + +Docs must explain: + +- BotFather/Telegram settings for topic mode and user-created topics; +- `/topic` activation; +- root system lobby; +- using `+` for new parallel chats; +- restoring old sessions with `/topic ` inside a topic; +- limitations. + +--- + +## 7. Testing / quality gates + +Run targeted tests after each TDD cycle, then broader tests before completion. + +Suggested commands after inspection confirms test paths: + +```bash +python -m pytest tests/test_hermes_state.py -q +python -m pytest tests/gateway/ -q +python -m pytest tests/ -o 'addopts=' -q +``` + +Do not ship without verifying disabled-feature backwards compatibility. + +--- + +## 8. Definition of done for MVP + +- `/topic` activates/checks Telegram DM multi-session mode. +- Root DM becomes a system lobby after activation. +- Onboarding message tells users to create new chats with the Telegram `+` button. +- Onboarding message can be pinned in private chat. +- User-created topics automatically become independent Hermes session lanes. +- `/new` in root gives instructions, not a new agent run. +- `/new` in a topic creates a new session in that topic and warns that `+` is preferred for parallel work. +- `/topic` in root lists unlinked old sessions. +- `/topic ` inside a topic restores that session and sends confirmation + last Hermes assistant message. +- Ownership checks prevent restoring other users' sessions. +- Already-linked sessions are not restored into a second topic in MVP. +- Existing Telegram behavior is unchanged when the feature is disabled. +- Tests and docs are included. diff --git a/gateway/run.py b/gateway/run.py index 6047de32203..40c4bdb4535 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1454,6 +1454,85 @@ class GatewayRunner: thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), ) + def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: + """Return whether Telegram DM topic mode is active for this chat.""" + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return False + session_db = getattr(self, "_session_db", None) + if session_db is None: + return False + try: + return bool( + session_db.is_telegram_topic_mode_enabled( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + ) + ) + except Exception: + logger.debug("Failed to read Telegram topic mode state", exc_info=True) + return False + + def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool: + """True for the main Telegram DM when topic mode has made it a lobby.""" + return ( + source.platform == Platform.TELEGRAM + and source.chat_type == "dm" + and not source.thread_id + and self._telegram_topic_mode_enabled(source) + ) + + def _is_telegram_topic_lane(self, source: SessionSource) -> bool: + """True for a user-created Telegram private-chat topic lane.""" + return ( + source.platform == Platform.TELEGRAM + and source.chat_type == "dm" + and bool(source.thread_id) + and self._telegram_topic_mode_enabled(source) + ) + + def _telegram_topic_root_lobby_message(self) -> str: + return ( + "This main chat is reserved for system commands.\n\n" + "To chat with Hermes, create a new topic using the + button in " + "this bot interface. Each topic works as an independent Hermes " + "session." + ) + + def _telegram_topic_root_new_message(self) -> str: + return ( + "To start a new parallel Hermes chat, create a new topic with the " + "+ button in this bot interface.\n\n" + "Each topic is an independent Hermes session. Use /new inside a " + "topic only if you want to replace that topic's current session." + ) + + def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]: + if not self._is_telegram_topic_lane(source): + return None + return ( + "Started a new Hermes session in this topic.\n\n" + "Tip: for parallel work, create a new topic with the + button " + "instead of using /new here. /new replaces the session attached " + "to the current topic." + ) + + def _record_telegram_topic_binding( + self, + source: SessionSource, + session_entry, + ) -> None: + """Persist the Telegram topic -> Hermes session binding for topic lanes.""" + session_db = getattr(self, "_session_db", None) + if session_db is None or not source.chat_id or not source.thread_id: + return + session_db.bind_telegram_topic( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + user_id=str(source.user_id or ""), + session_key=session_entry.session_key, + session_id=session_entry.session_id, + ) + def _resolve_session_agent_runtime( self, *, @@ -5274,7 +5353,12 @@ class GatewayRunner: break if canonical == "new": + if self._is_telegram_topic_root_lobby(source): + return self._telegram_topic_root_new_message() return await self._handle_reset_command(event) + + if canonical == "topic": + return await self._handle_topic_command(event) if canonical == "help": return await self._handle_help_command(event) @@ -5523,6 +5607,9 @@ class GatewayRunner: # No bare text matching — "yes" in normal conversation must not trigger # execution of a dangerous command. + if self._is_telegram_topic_root_lobby(source): + return self._telegram_topic_root_lobby_message() + # ── Claim this session before any await ─────────────────────── # Between here and _run_agent registering the real AIAgent, there # are numerous await points (hooks, vision enrichment, STT, @@ -5798,6 +5885,22 @@ class GatewayRunner: # Get or create session session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + if self._is_telegram_topic_lane(source): + try: + binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) if self._session_db else None + except Exception: + logger.debug("Failed to read Telegram topic binding", exc_info=True) + binding = None + if binding: + session_entry.session_id = str(binding.get("session_id") or session_entry.session_id) + else: + try: + self._record_telegram_topic_binding(source, session_entry) + except Exception: + logger.debug("Failed to record Telegram topic binding", exc_info=True) if getattr(session_entry, "was_auto_reset", False): # Treat auto-reset as a full conversation boundary — drop every # session-scoped transient state so the fresh session does not @@ -6984,11 +7087,11 @@ class GatewayRunner: session_info = "" if new_entry: - header = "✨ Session reset! Starting fresh." + header = self._telegram_topic_new_header(source) or "✨ Session reset! Starting fresh." else: # No existing session, just create one new_entry = self.session_store.get_or_create_session(source, force_new=True) - header = "✨ New session started!" + header = self._telegram_topic_new_header(source) or "✨ New session started!" # Set session title if provided with /new _title_arg = event.get_command_args().strip() @@ -9466,6 +9569,164 @@ class GatewayRunner: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" + async def _handle_topic_command(self, event: MessageEvent) -> str: + """Handle /topic for Telegram DM user-managed topic sessions.""" + source = event.source + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return "The /topic command is only available in Telegram private chats." + if not self._session_db: + return "Session database not available." + + args = event.get_command_args().strip() + if args: + if not source.thread_id: + return ( + "To restore a session, first create or open a Telegram topic " + "with the + button, then send /topic <session-id> inside that topic." + ) + return await self._restore_telegram_topic_session(event, args) + + try: + self._session_db.enable_telegram_topic_mode( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + ) + except Exception as exc: + logger.exception("Failed to enable Telegram topic mode") + return f"Failed to enable Telegram topic mode: {exc}" + + if source.thread_id: + try: + binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) + 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 = self._session_db.get_session_title(session_id) + except Exception: + title = None + session_label = title or "Untitled session" + return ( + "This topic is linked to:\n" + f"Session: {session_label}\n" + f"ID: {session_id}\n\n" + "Use /new to replace this topic with a fresh session.\n" + "For parallel work, create another topic with the + button." + ) + return ( + "Telegram multi-session topics are enabled.\n\n" + "This topic will be used as an independent Hermes session. " + "Use /new to replace this topic's current session. For parallel " + "work, create another topic with the + button." + ) + + return self._telegram_topic_root_status_message(source) + + def _telegram_topic_root_status_message(self, source: SessionSource) -> str: + lines = [ + "Telegram multi-session topics are enabled.", + "", + "Create new Hermes chats with the + button in this bot interface.", + "", + ] + try: + sessions = self._session_db.list_unlinked_telegram_sessions_for_user( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + limit=10, + ) + except Exception: + logger.debug("Failed to list unlinked Telegram sessions", exc_info=True) + sessions = [] + + if sessions: + lines.append("Previous unlinked sessions:") + for session in sessions: + session_id = str(session.get("id") or "") + title = str(session.get("title") or "Untitled session") + preview = str(session.get("preview") or "").strip() + line = f"- {title} — `{session_id}`" + if preview: + line += f" — {preview}" + lines.append(line) + lines.extend([ + "", + "To restore one:", + "1. Create or open a topic with the + button.", + "2. Send /topic <session-id> inside that topic.", + f"Example: Send /topic {sessions[0].get('id')} inside a topic.", + ]) + else: + lines.extend([ + "No previous unlinked Telegram sessions found.", + "", + "To restore a previous session later:", + "1. Create a new topic with the + button.", + "2. Open that topic.", + "3. Send /topic <session-id>.", + ]) + return "\n".join(lines) + + async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session_id: str) -> str: + """Restore an existing Telegram-owned Hermes session into this topic.""" + source = event.source + session_id = self._session_db.resolve_session_id(raw_session_id.strip()) + if not session_id: + return f"Session not found: {raw_session_id.strip()}" + + session = self._session_db.get_session(session_id) + if not session: + return f"Session not found: {raw_session_id.strip()}" + if str(session.get("source") or "") != "telegram": + return "That session is not a Telegram session and cannot be restored into this topic." + if str(session.get("user_id") or "") != str(source.user_id): + return "That session does not belong to this Telegram user." + + linked = self._session_db.is_telegram_session_linked_to_topic(session_id=session_id) + current_binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) + if linked: + if not current_binding or current_binding.get("session_id") != session_id: + return "That session is already linked to another Telegram topic." + + session_key = self._session_key_for_source(source) + try: + self._session_db.bind_telegram_topic( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + user_id=str(source.user_id), + session_key=session_key, + session_id=session_id, + managed_mode="restored", + ) + except ValueError as exc: + if "already linked" in str(exc): + return "That session is already linked to another Telegram topic." + raise + + title = self._session_db.get_session_title(session_id) or session_id + last_assistant = None + try: + for message in reversed(self._session_db.get_messages(session_id)): + if message.get("role") == "assistant" and message.get("content"): + last_assistant = str(message.get("content")) + break + except Exception: + last_assistant = None + + response = f"Session restored: {title}" + if last_assistant: + response += f"\n\nLast Hermes message:\n{last_assistant}" + return response + async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c7ddfa0fa05..cc2365c90dc 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -65,6 +65,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Session CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",), args_hint="[name]"), + CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session", + gateway_only=True, args_hint="[session-id]"), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", diff --git a/hermes_state.py b/hermes_state.py index 2cfd13d6d59..7f26659e7d0 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2148,6 +2148,265 @@ class SessionDB: ) self._execute_write(_do) + def apply_telegram_topic_migration(self) -> None: + """Create Telegram DM topic-mode tables on explicit /topic opt-in. + + This migration is deliberately not part of automatic SessionDB startup + reconciliation. Operators must be able to upgrade Hermes, keep the old + Telegram bot behavior running, and only mutate topic-mode state when the + user executes /topic to opt into the feature. + """ + def _do(conn): + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode ( + chat_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + activated_at REAL NOT NULL, + updated_at REAL NOT NULL, + has_topics_enabled INTEGER, + allows_users_to_create_topics INTEGER, + capability_checked_at REAL, + intro_message_id TEXT, + pinned_message_id TEXT + ); + + CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings ( + chat_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id), + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session + ON telegram_dm_topic_bindings(session_id); + + CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user + ON telegram_dm_topic_bindings(user_id, chat_id); + """ + ) + conn.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ("telegram_dm_topic_schema_version", "1"), + ) + self._execute_write(_do) + + def enable_telegram_topic_mode( + self, + *, + chat_id: str, + user_id: str, + has_topics_enabled: Optional[bool] = None, + allows_users_to_create_topics: Optional[bool] = None, + ) -> None: + """Enable Telegram DM topic mode for one private chat/user. + + This method intentionally owns the explicit topic migration. Ordinary + SessionDB startup must not create these side tables. + """ + self.apply_telegram_topic_migration() + now = time.time() + + def _to_int(value: Optional[bool]) -> Optional[int]: + if value is None: + return None + return 1 if value else 0 + + def _do(conn): + conn.execute( + """ + INSERT INTO telegram_dm_topic_mode ( + chat_id, user_id, enabled, activated_at, updated_at, + has_topics_enabled, allows_users_to_create_topics, + capability_checked_at + ) VALUES (?, ?, 1, ?, ?, ?, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET + user_id = excluded.user_id, + enabled = 1, + updated_at = excluded.updated_at, + has_topics_enabled = excluded.has_topics_enabled, + allows_users_to_create_topics = excluded.allows_users_to_create_topics, + capability_checked_at = excluded.capability_checked_at + """, + ( + str(chat_id), + str(user_id), + now, + now, + _to_int(has_topics_enabled), + _to_int(allows_users_to_create_topics), + now, + ), + ) + self._execute_write(_do) + + def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool: + """Return whether Telegram DM topic mode is enabled for this chat/user.""" + with self._lock: + try: + row = self._conn.execute( + """ + SELECT enabled FROM telegram_dm_topic_mode + WHERE chat_id = ? AND user_id = ? + """, + (str(chat_id), str(user_id)), + ).fetchone() + except sqlite3.OperationalError: + return False + if row is None: + return False + enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0] + return bool(enabled) + + def get_telegram_topic_binding( + self, + *, + chat_id: str, + thread_id: str, + ) -> Optional[Dict[str, Any]]: + """Return the session binding for a Telegram DM topic, if present.""" + with self._lock: + try: + row = self._conn.execute( + """ + SELECT * FROM telegram_dm_topic_bindings + WHERE chat_id = ? AND thread_id = ? + """, + (str(chat_id), str(thread_id)), + ).fetchone() + except sqlite3.OperationalError: + return None + return dict(row) if row else None + + def bind_telegram_topic( + self, + *, + chat_id: str, + thread_id: str, + user_id: str, + session_key: str, + session_id: str, + managed_mode: str = "auto", + ) -> None: + """Bind one Telegram DM topic thread to one Hermes session. + + A Hermes session may only be linked to one Telegram topic in MVP. + Rebinding the same topic to the same session is idempotent; trying to + link the same session to a different topic raises ValueError. + """ + self.apply_telegram_topic_migration() + now = time.time() + chat_id = str(chat_id) + thread_id = str(thread_id) + user_id = str(user_id) + session_key = str(session_key) + session_id = str(session_id) + + def _do(conn): + existing_session = conn.execute( + """ + SELECT chat_id, thread_id FROM telegram_dm_topic_bindings + WHERE session_id = ? + """, + (session_id,), + ).fetchone() + if existing_session is not None: + linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0] + linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1] + if str(linked_chat) != chat_id or str(linked_thread) != thread_id: + raise ValueError("session is already linked to another Telegram topic") + + conn.execute( + """ + INSERT INTO telegram_dm_topic_bindings ( + chat_id, thread_id, user_id, session_key, session_id, + managed_mode, linked_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_id, thread_id) DO UPDATE SET + user_id = excluded.user_id, + session_key = excluded.session_key, + session_id = excluded.session_id, + managed_mode = excluded.managed_mode, + updated_at = excluded.updated_at + """, + ( + chat_id, + thread_id, + user_id, + session_key, + session_id, + managed_mode, + now, + now, + ), + ) + self._execute_write(_do) + + def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool: + """Return True if a Hermes session is already bound to any Telegram DM topic.""" + self.apply_telegram_topic_migration() + row = self._conn.execute( + """ + SELECT 1 FROM telegram_dm_topic_bindings + WHERE session_id = ? + LIMIT 1 + """, + (str(session_id),), + ).fetchone() + return row is not None + + def list_unlinked_telegram_sessions_for_user( + self, + *, + chat_id: str, + user_id: str, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """List previous Telegram sessions for this user that are not bound to a topic.""" + self.apply_telegram_topic_migration() + with self._lock: + rows = self._conn.execute( + """ + SELECT s.*, + COALESCE( + (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id LIMIT 1), + '' + ) AS _preview_raw, + COALESCE( + (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), + s.started_at + ) AS last_active + FROM sessions s + WHERE s.source = 'telegram' + AND s.user_id = ? + AND NOT EXISTS ( + SELECT 1 FROM telegram_dm_topic_bindings b + WHERE b.session_id = s.id + ) + ORDER BY last_active DESC, s.started_at DESC + LIMIT ? + """, + (str(user_id), int(limit)), + ).fetchall() + + sessions: List[Dict[str, Any]] = [] + for row in rows: + session = dict(row) + raw = str(session.pop("_preview_raw", "") or "").strip() + session["preview"] = raw[:60] + ("..." if len(raw) > 60 else "") if raw else "" + sessions.append(session) + return sessions + # ── Space reclamation ── def vacuum(self) -> None: diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py new file mode 100644 index 00000000000..ad72514ed5d --- /dev/null +++ b/tests/gateway/test_telegram_topic_mode.py @@ -0,0 +1,625 @@ +"""Tests for Telegram private-chat topic-mode routing. + +Topic mode makes the root Telegram DM a system lobby while user-created +Telegram topics act as independent Hermes session lanes. +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from hermes_state import SessionDB +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(*, thread_id: str | None = None) -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="208214988", + chat_id="208214988", + user_name="tester", + chat_type="dm", + thread_id=thread_id, + ) + + +def _make_event(text: str, *, thread_id: str | None = None) -> MessageEvent: + return MessageEvent( + text=text, + source=_make_source(thread_id=thread_id), + message_id="m1", + ) + + +def _make_group_source(*, thread_id: str | None = None) -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="208214988", + chat_id="-100123", + user_name="tester", + chat_type="group", + thread_id=thread_id, + ) + + +def _make_group_event(text: str, *, thread_id: str | None = None) -> MessageEvent: + return MessageEvent( + text=text, + source=_make_group_source(thread_id=thread_id), + message_id="gm1", + ) + + +def _make_runner(session_db=None): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + + runner.session_store = MagicMock() + runner.session_store._generate_session_key.side_effect = lambda source: build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ) + runner.session_store.get_or_create_session.side_effect = lambda source, force_new=False: SessionEntry( + session_key=build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ), + session_id="sess-topic" if source.thread_id else "sess-root", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=source, + ) + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner.session_store.reset_session = MagicMock(return_value=None) + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._queued_events = {} + runner._busy_ack_ts = {} + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._session_db = session_db + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._draining = False + runner._busy_input_mode = "interrupt" + runner._is_user_authorized = lambda _source: True + runner._session_key_for_source = lambda source: build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ) + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None + runner._emit_gateway_run_progress = AsyncMock() + runner._invalidate_session_run_generation = MagicMock() + runner._begin_session_run_generation = MagicMock(return_value=1) + runner._is_session_run_current = MagicMock(return_value=True) + runner._release_running_agent_state = MagicMock() + runner._evict_cached_agent = MagicMock() + runner._clear_session_boundary_security_state = MagicMock() + runner._set_session_reasoning_override = MagicMock() + runner._format_session_info = MagicMock(return_value="") + return runner + + +@pytest.mark.asyncio +async def test_root_telegram_dm_prompt_is_system_lobby_when_topic_mode_enabled(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._run_agent = AsyncMock( + side_effect=AssertionError("root Telegram DM prompt leaked to the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("hello from root")) + + assert "main chat is reserved for system commands" in result + assert "+ button" in result + runner._run_agent.assert_not_called() + runner.session_store.get_or_create_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_root_telegram_dm_new_shows_create_topic_instruction(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._run_agent = AsyncMock( + side_effect=AssertionError("/new in root Telegram DM must not start an agent") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/new")) + + assert "create a new topic" in result + assert "+ button" in result + assert "Use /new inside a topic" in result + runner._run_agent.assert_not_called() + runner.session_store.reset_session.assert_not_called() + runner.session_store.get_or_create_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_telegram_topic_prompt_still_runs_agent_when_topic_mode_enabled(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._handle_message_with_agent = AsyncMock(return_value="agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("hello in topic", thread_id="17585")) + + assert result == "agent response" + runner._handle_message_with_agent.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_managed_topic_binding_reuses_restored_session_over_static_lane_session( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="restored-session", + source="telegram", + user_id="208214988", + ) + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key=build_session_key(_make_source(thread_id="17585")), + session_id="restored-session", + managed_mode="restored", + ) + runner = _make_runner(session_db=session_db) + captured = {} + + async def fake_run_agent(*args, **kwargs): + captured["session_id"] = kwargs.get("session_id") + return { + "success": True, + "final_response": "restored response", + "session_id": kwargs.get("session_id"), + "messages": [], + } + + runner._run_agent = AsyncMock(side_effect=fake_run_agent) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("continue restored", thread_id="17585")) + + assert result == "restored response" + assert captured["session_id"] == "restored-session" + + +@pytest.mark.asyncio +async def test_telegram_group_prompt_is_not_topic_lobby_even_when_dm_topic_mode_enabled( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=session_db) + runner._handle_message_with_agent = AsyncMock(return_value="group agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("hello group", thread_id="555")) + + assert result == "group agent response" + runner._handle_message_with_agent.assert_awaited_once() + assert session_db.get_telegram_topic_binding(chat_id="-100123", thread_id="555") is None + + +@pytest.mark.asyncio +async def test_topic_command_is_private_dm_only_and_does_not_enable_group_topic_mode( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("group /topic must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("/topic", thread_id="555")) + + assert "only available in Telegram private chats" in result + assert session_db.is_telegram_topic_mode_enabled(chat_id="-100123", user_id="208214988") is False + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_group_new_keeps_existing_reset_semantics_when_dm_topic_mode_enabled( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=session_db) + group_source = _make_group_source(thread_id="555") + group_key = build_session_key(group_source) + new_entry = SessionEntry( + session_key=group_key, + session_id="new-group-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="group", + origin=group_source, + ) + runner.session_store.reset_session.return_value = new_entry + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("/new", thread_id="555")) + + assert "Started a new Hermes session in this topic" not in result + assert "parallel work" not in result + runner.session_store.reset_session.assert_called_once_with(group_key) + + +@pytest.mark.asyncio +async def test_new_inside_telegram_topic_resets_current_topic_with_parallel_tip(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + topic_source = _make_source(thread_id="17585") + topic_key = build_session_key(topic_source) + old_entry = SessionEntry( + session_key=topic_key, + session_id="old-topic-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + new_entry = SessionEntry( + session_key=topic_key, + session_id="new-topic-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + runner.session_store._entries = {topic_key: old_entry} + runner.session_store.reset_session.return_value = new_entry + runner._agent_cache_lock = None + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/new", thread_id="17585")) + + assert "Started a new Hermes session in this topic" in result + assert "for parallel work" in result + assert "+ button" in result + runner.session_store.reset_session.assert_called_once_with(topic_key) + + +@pytest.mark.asyncio +async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic activation must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "+ button" in result + assert session_db.get_meta("telegram_dm_topic_schema_version") == "1" + assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") + assert runner._telegram_topic_mode_enabled(_make_source()) is True + runner._run_agent.assert_not_called() + + lobby_result = await runner._handle_message(_make_event("hello after activation")) + + assert "main chat is reserved for system commands" in lobby_result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_root_command_lists_unlinked_sessions_for_restore(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="old-unlinked", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("old-unlinked", "Old research") + session_db.append_message("old-unlinked", "user", "first prompt") + session_db.append_message("old-unlinked", "assistant", "old answer") + session_db.create_session( + session_id="already-linked", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("already-linked", "Already linked") + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="11111", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:11111", + session_id="already-linked", + ) + session_db.create_session( + session_id="other-user", + source="telegram", + user_id="someone-else", + ) + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("root /topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "Previous unlinked sessions" in result + assert "Old research" in result + assert "old-unlinked" in result + assert "Send /topic old-unlinked inside a topic" in result + assert "Already linked" not in result + assert "other-user" not in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_root_command_handles_no_unlinked_sessions(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("root /topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "No previous unlinked Telegram sessions found" in result + assert "+ button" in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_command_inside_bound_topic_shows_current_session(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.create_session( + session_id="sess-topic", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("sess-topic", "Research notes") + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="telegram:dm:208214988:thread:17585", + session_id="sess-topic", + ) + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic", thread_id="17585")) + + assert "This topic is linked to" in result + assert "Research notes" in result + assert "sess-topic" in result + assert "Use /new to replace" in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_restore_inside_topic_binds_old_session_and_returns_last_assistant_message( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="old-session", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("old-session", "Research notes") + session_db.append_message("old-session", "user", "summarize this") + session_db.append_message("old-session", "assistant", "Here is the summary.") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic restore must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic old-session", thread_id="17585")) + + assert "Session restored: Research notes" in result + assert "Last Hermes message:" in result + assert "Here is the summary." in result + binding = session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding is not None + assert binding["session_id"] == "old-session" + assert binding["user_id"] == "208214988" + assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_restore_refuses_session_owned_by_another_telegram_user(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="other-session", + source="telegram", + user_id="someone-else", + ) + runner = _make_runner(session_db=session_db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic other-session", thread_id="17585")) + + assert "does not belong to this Telegram user" in result + assert session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + +@pytest.mark.asyncio +async def test_topic_restore_refuses_already_linked_session(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="linked-session", + source="telegram", + user_id="208214988", + ) + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="11111", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:11111", + session_id="linked-session", + ) + runner = _make_runner(session_db=session_db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic linked-session", thread_id="17585")) + + assert "already linked to another Telegram topic" in result + assert session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + +@pytest.mark.asyncio +async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="sess-topic", + source="telegram", + user_id="208214988", + ) + runner = _make_runner(session_db=session_db) + runner._handle_message_with_agent = AsyncMock(return_value="agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + source = _make_source(thread_id="17585") + entry = runner.session_store.get_or_create_session(source) + runner._record_telegram_topic_binding(source, entry) + + binding = session_db.get_telegram_topic_binding( + chat_id="208214988", + thread_id="17585", + ) + assert binding is not None + assert binding["user_id"] == "208214988" + assert binding["session_id"] == "sess-topic" + assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 620611ad42c..ad4c7d5c638 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -109,6 +109,12 @@ class TestResolveCommand: assert resolve_command("reload_mcp").name == "reload-mcp" assert resolve_command("tasks").name == "agents" + def test_topic_is_gateway_command(self): + topic = resolve_command("topic") + assert topic is not None + assert topic.name == "topic" + assert "topic" in GATEWAY_KNOWN_COMMANDS + def test_leading_slash_stripped(self): assert resolve_command("/help").name == "help" assert resolve_command("/bg").name == "background" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 806735f5dff..24020286bd7 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -35,6 +35,7 @@ class TestSessionLifecycle: assert session["model"] == "test-model" assert session["ended_at"] is None + def test_get_nonexistent_session(self, db): assert db.get_session("nonexistent") is None @@ -1421,6 +1422,242 @@ class TestSchemaInit: columns = {row[1] for row in cursor.fetchall()} assert "title" in columns + def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path): + """Opening an old DB should not add topic-mode columns until /topic opts in. + + The gateway must remain rollback-safe: simply upgrading Hermes and starting + the old bot should not eagerly mutate the state DB for this feature. + """ + old_db = tmp_path / "old.db" + import sqlite3 + + conn = sqlite3.connect(old_db) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version VALUES (11); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + """ + ) + conn.close() + + db = SessionDB(db_path=old_db) + cursor = db._conn.execute("PRAGMA table_info(sessions)") + columns = {row[1] for row in cursor.fetchall()} + assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns) + db.close() + + def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path): + """The /topic opt-in path owns the DB migration for Telegram topic mode.""" + old_db = tmp_path / "old.db" + import sqlite3 + + conn = sqlite3.connect(old_db) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version VALUES (11); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + """ + ) + conn.close() + + db = SessionDB(db_path=old_db) + db.apply_telegram_topic_migration() + + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + assert "telegram_dm_topic_mode" in tables + assert "telegram_dm_topic_bindings" in tables + assert db.get_meta("telegram_dm_topic_schema_version") == "1" + db.close() + + def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="topic-session", + source="telegram", + user_id="208214988", + ) + + assert db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="telegram:dm:208214988:thread:17585", + session_id="topic-session", + ) + + binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding is not None + assert binding["chat_id"] == "208214988" + assert binding["thread_id"] == "17585" + assert binding["user_id"] == "208214988" + assert binding["session_key"] == "telegram:dm:208214988:thread:17585" + assert binding["session_id"] == "topic-session" + assert db.get_meta("telegram_dm_topic_schema_version") == "1" + db.close() + + def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="topic-session", + source="telegram", + user_id="208214988", + ) + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="key-17585", + session_id="topic-session", + ) + + with pytest.raises(ValueError, match="already linked"): + db.bind_telegram_topic( + chat_id="208214988", + thread_id="99999", + user_id="208214988", + session_key="key-99999", + session_id="topic-session", + ) + db.close() + + def test_list_unlinked_telegram_sessions_for_user_excludes_bound_and_other_users(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="old-unlinked", + source="telegram", + user_id="208214988", + ) + db.set_session_title("old-unlinked", "Old research") + db.append_message("old-unlinked", "user", "first prompt") + db.create_session( + session_id="already-linked", + source="telegram", + user_id="208214988", + ) + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="key-17585", + session_id="already-linked", + ) + db.create_session( + session_id="other-user", + source="telegram", + user_id="someone-else", + ) + + sessions = db.list_unlinked_telegram_sessions_for_user( + chat_id="208214988", + user_id="208214988", + ) + + assert [s["id"] for s in sessions] == ["old-unlinked"] + assert sessions[0]["title"] == "Old research" + assert sessions[0]["preview"] == "first prompt" + db.close() + def test_migration_from_v2(self, tmp_path): """Simulate a v2 database and verify migration adds title column.""" import sqlite3 diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 0677e8bdc10..fd3af4540ba 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -125,7 +125,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -503,6 +502,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1677,7 +1701,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1688,7 +1711,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1699,7 +1721,6 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1729,7 +1750,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2047,7 +2067,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2450,7 +2469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3186,7 +3204,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3318,7 +3335,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4227,7 +4243,6 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5663,7 +5678,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5773,7 +5787,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6598,7 +6611,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6725,7 +6737,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6835,7 +6846,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7251,7 +7261,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 25065283b3444d729df3d1ed13e0f811de2f1cb8 Mon Sep 17 00:00:00 2001 From: EmelyanenkoK <emelyanenko.kirill@gmail.com> Date: Sat, 2 May 2026 19:28:49 +0300 Subject: [PATCH 02/28] fix: improve telegram topic mode setup --- agent/title_generator.py | 14 +- .../telegram-botfather-threads-settings.jpg | Bin 0 -> 118213 bytes gateway/platforms/telegram.py | 23 ++ gateway/run.py | 288 ++++++++++++++++-- pyproject.toml | 1 + tests/agent/test_title_generator.py | 31 +- tests/gateway/test_telegram_topic_mode.py | 139 ++++++++- 7 files changed, 458 insertions(+), 38 deletions(-) create mode 100644 gateway/assets/telegram-botfather-threads-settings.jpg diff --git a/agent/title_generator.py b/agent/title_generator.py index 3f617093c0b..a7f1e158e1a 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) # so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain) # become visible instead of piling up as NULL session titles. FailureCallback = Callable[[str, BaseException], None] +TitleCallback = Callable[[str], None] _TITLE_PROMPT = ( "Generate a short, descriptive title (3-7 words) for a conversation that starts with the " @@ -90,6 +91,7 @@ def auto_title_session( assistant_response: str, failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, + title_callback: Optional[TitleCallback] = None, ) -> None: """Generate and set a session title if one doesn't already exist. @@ -119,6 +121,11 @@ def auto_title_session( try: session_db.set_session_title(session_id, title) logger.debug("Auto-generated session title: %s", title) + if title_callback is not None: + try: + title_callback(title) + except Exception: + logger.debug("Auto-title callback failed", exc_info=True) except Exception as e: logger.debug("Failed to set auto-generated title: %s", e) @@ -131,6 +138,7 @@ def maybe_auto_title( conversation_history: list, failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, + title_callback: Optional[TitleCallback] = None, ) -> None: """Fire-and-forget title generation after the first exchange. @@ -152,7 +160,11 @@ def maybe_auto_title( thread = threading.Thread( target=auto_title_session, args=(session_db, session_id, user_message, assistant_response), - kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime}, + kwargs={ + "failure_callback": failure_callback, + "main_runtime": main_runtime, + "title_callback": title_callback, + }, daemon=True, name="auto-title", ) diff --git a/gateway/assets/telegram-botfather-threads-settings.jpg b/gateway/assets/telegram-botfather-threads-settings.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b1de115acd45e3002e24da4d2c97ccb990472bec GIT binary patch literal 118213 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 E12mu_#sB~S literal 0 HcmV?d00001 diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 167d47237e4..ad5ed669202 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -688,6 +688,29 @@ class TelegramAdapter(BasePlatformAdapter): ) return None + async def rename_dm_topic( + self, + chat_id: int, + thread_id: int, + name: str, + ) -> None: + """Rename a forum topic in a private (DM) chat.""" + if not self._bot: + return + try: + chat_id_arg = int(chat_id) + except (TypeError, ValueError): + chat_id_arg = chat_id + await self._bot.edit_forum_topic( + chat_id=chat_id_arg, + message_thread_id=int(thread_id), + name=name, + ) + logger.info( + "[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'", + self.name, chat_id, thread_id, name, + ) + def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None: """Save a newly created thread_id back into config.yaml so it persists across restarts.""" try: diff --git a/gateway/run.py b/gateway/run.py index 40c4bdb4535..6fd19472c24 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1050,6 +1050,7 @@ class GatewayRunner: ) self.delivery_router = DeliveryRouter(self.config) self._running = False + self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None self._shutdown_event = asyncio.Event() self._exit_cleanly = False self._exit_with_failure = False @@ -1493,17 +1494,19 @@ class GatewayRunner: def _telegram_topic_root_lobby_message(self) -> str: return ( "This main chat is reserved for system commands.\n\n" - "To chat with Hermes, create a new topic using the + button in " - "this bot interface. Each topic works as an independent Hermes " - "session." + "To start a new Hermes chat, open the All Messages topic at the top " + "of this bot interface and send any message there. Telegram will " + "create a new topic for that message; each topic works as an " + "independent Hermes session." ) def _telegram_topic_root_new_message(self) -> str: return ( - "To start a new parallel Hermes chat, create a new topic with the " - "+ button in this bot interface.\n\n" - "Each topic is an independent Hermes session. Use /new inside a " - "topic only if you want to replace that topic's current session." + "To start a new parallel Hermes chat, open the All Messages topic " + "at the top of this bot interface and send any message there. " + "Telegram will create a new topic for it.\n\n" + "Each topic is an independent Hermes session. Use /new inside an " + "existing topic only if you want to replace that topic's current session." ) def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]: @@ -1511,9 +1514,9 @@ class GatewayRunner: return None return ( "Started a new Hermes session in this topic.\n\n" - "Tip: for parallel work, create a new topic with the + button " - "instead of using /new here. /new replaces the session attached " - "to the current topic." + "Tip: for parallel work, open All Messages and send a message there " + "to create a separate topic instead of using /new here. /new replaces " + "the session attached to the current topic." ) def _record_telegram_topic_binding( @@ -2767,6 +2770,10 @@ class GatewayRunner: Returns True if at least one adapter connected successfully. """ logger.info("Starting Hermes Gateway...") + try: + self._gateway_loop = asyncio.get_running_loop() + except RuntimeError: + self._gateway_loop = None logger.info("Session storage: %s", self.config.sessions_dir) # Log the resolved max_iterations budget so operators can verify the # config.yaml → env bridge did the right thing at a glance (instead @@ -9569,7 +9576,193 @@ class GatewayRunner: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" - async def _handle_topic_command(self, event: MessageEvent) -> str: + async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict: + """Read Telegram private-topic capability flags via Bot API getMe.""" + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + bot = getattr(adapter, "_bot", None) + if bot is None or not hasattr(bot, "get_me"): + return {"checked": False} + try: + me = await bot.get_me() + except Exception: + logger.debug("Failed to fetch Telegram getMe topic capabilities", exc_info=True) + return {"checked": False} + + def _field(name: str): + if hasattr(me, name): + return getattr(me, name) + api_kwargs = getattr(me, "api_kwargs", None) + if isinstance(api_kwargs, dict) and name in api_kwargs: + return api_kwargs.get(name) + if isinstance(me, dict): + return me.get(name) + return None + + return { + "checked": True, + "has_topics_enabled": _field("has_topics_enabled"), + "allows_users_to_create_topics": _field("allows_users_to_create_topics"), + } + + async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: + """Create/pin the managed System topic after /topic activation when possible.""" + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None or not source.chat_id: + return + + thread_id = None + create_topic = getattr(adapter, "_create_dm_topic", None) + if callable(create_topic): + try: + thread_id = await create_topic(int(source.chat_id), "System") + except Exception: + logger.debug("Failed to create Telegram System topic", exc_info=True) + if not thread_id: + return + + message_id = None + try: + send_result = await adapter.send( + source.chat_id, + "System topic for Hermes commands and status.", + metadata={"thread_id": str(thread_id)}, + ) + message_id = getattr(send_result, "message_id", None) + except Exception: + logger.debug("Failed to send Telegram System topic intro", exc_info=True) + if not message_id: + return + + bot = getattr(adapter, "_bot", None) + if bot is None or not hasattr(bot, "pin_chat_message"): + return + try: + await bot.pin_chat_message( + chat_id=int(source.chat_id), + message_id=int(message_id), + disable_notification=True, + ) + except Exception: + logger.debug("Failed to pin Telegram System topic intro", exc_info=True) + + async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None: + """Send the bundled BotFather Threads Settings screenshot when available.""" + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None or not source.chat_id or not hasattr(adapter, "send_image_file"): + return + image_path = Path(__file__).resolve().parent / "assets" / "telegram-botfather-threads-settings.jpg" + if not image_path.exists(): + return + try: + await adapter.send_image_file( + chat_id=source.chat_id, + image_path=str(image_path), + caption="BotFather → Bot Settings → Threads Settings", + metadata={"thread_id": str(source.thread_id)} if source.thread_id else None, + ) + except Exception: + logger.debug("Failed to send Telegram topic setup image", exc_info=True) + + def _sanitize_telegram_topic_title(self, title: str) -> str: + """Return a Bot API-safe forum topic name from a generated session title.""" + cleaned = re.sub(r"\s+", " ", str(title or "")).strip() + if not cleaned: + return "Hermes Chat" + # Telegram forum topic names are short (currently 1-128 chars). Keep + # extra room for multi-byte titles and avoid trailing ellipsis churn. + if len(cleaned) > 120: + cleaned = cleaned[:117].rstrip() + "..." + return cleaned + + async def _rename_telegram_topic_for_session_title( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Best-effort rename of a Telegram DM topic when Hermes auto-titles a session.""" + if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id: + return + session_db = getattr(self, "_session_db", None) + if session_db is not None: + try: + binding = session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) + if binding and str(binding.get("session_id") or "") != str(session_id): + return + except Exception: + logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True) + return + + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None: + return + topic_name = self._sanitize_telegram_topic_title(title) + try: + rename_topic = getattr(adapter, "rename_dm_topic", None) + if rename_topic is not None: + await rename_topic( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + name=topic_name, + ) + return + + bot = getattr(adapter, "_bot", None) + edit_forum_topic = getattr(bot, "edit_forum_topic", None) if bot is not None else None + if edit_forum_topic is None: + edit_forum_topic = getattr(bot, "editForumTopic", None) if bot is not None else None + if edit_forum_topic is None: + return + try: + await edit_forum_topic( + chat_id=int(source.chat_id), + message_thread_id=int(source.thread_id), + name=topic_name, + ) + except (TypeError, ValueError): + await edit_forum_topic( + chat_id=source.chat_id, + message_thread_id=source.thread_id, + name=topic_name, + ) + except Exception: + logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True) + + def _schedule_telegram_topic_title_rename( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Schedule a topic rename from the auto-title background thread.""" + if not title or not self._is_telegram_topic_lane(source): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = getattr(self, "_gateway_loop", None) + if loop is None or loop.is_closed(): + return + try: + copied_source = dataclasses.replace(source) + except Exception: + copied_source = source + future = asyncio.run_coroutine_threadsafe( + self._rename_telegram_topic_for_session_title(copied_source, session_id, title), + loop, + ) + def _log_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Telegram topic title rename failed", exc_info=True) + + future.add_done_callback(_log_rename_failure) + + 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": @@ -9581,20 +9774,48 @@ class GatewayRunner: if args: if not source.thread_id: return ( - "To restore a session, first create or open a Telegram topic " - "with the + button, then send /topic <session-id> inside that topic." + "To restore a session, first create or open a Telegram topic, " + "then send /topic <session-id> inside that topic. To create a " + "new topic, open All Messages and send any message there." ) 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: + await self._send_telegram_topic_setup_image(source) + return ( + "Telegram topics are not enabled for this bot yet.\n\n" + "How to enable them:\n" + "1. Open @BotFather.\n" + "2. Choose your bot.\n" + "3. Open Bot Settings → Threads Settings.\n" + "4. Turn on Threaded Mode and make sure users are allowed to create new threads.\n\n" + "Then send /topic again." + ) + if capabilities.get("allows_users_to_create_topics") is False: + await self._send_telegram_topic_setup_image(source) + return ( + "Telegram topics are enabled, but users are not allowed to create topics.\n\n" + "Open @BotFather → choose your bot → Bot Settings → Threads Settings, " + "then turn off 'Disallow users to create new threads'.\n\n" + "Then send /topic again." + ) + try: self._session_db.enable_telegram_topic_mode( chat_id=str(source.chat_id), user_id=str(source.user_id), + 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 f"Failed to enable Telegram topic mode: {exc}" + if not source.thread_id: + await self._ensure_telegram_system_topic(source) + if source.thread_id: try: binding = self._session_db.get_telegram_topic_binding( @@ -9617,13 +9838,14 @@ class GatewayRunner: f"Session: {session_label}\n" f"ID: {session_id}\n\n" "Use /new to replace this topic with a fresh session.\n" - "For parallel work, create another topic with the + button." + "For parallel work, open All Messages and send a message there " + "to create another topic." ) return ( "Telegram multi-session topics are enabled.\n\n" "This topic will be used as an independent Hermes session. " "Use /new to replace this topic's current session. For parallel " - "work, create another topic with the + button." + "work, open All Messages and send a message there to create another topic." ) return self._telegram_topic_root_status_message(source) @@ -9632,7 +9854,9 @@ class GatewayRunner: lines = [ "Telegram multi-session topics are enabled.", "", - "Create new Hermes chats with the + button in this bot interface.", + "To create a new Hermes chat, open All Messages at the top of this " + "bot interface and send any message there. Telegram will create a " + "new topic for it.", "", ] try: @@ -9658,7 +9882,7 @@ class GatewayRunner: lines.extend([ "", "To restore one:", - "1. Create or open a topic with the + button.", + "1. Create or open a topic. To create a new one, open All Messages and send any message there.", "2. Send /topic <session-id> inside that topic.", f"Example: Send /topic {sessions[0].get('id')} inside a topic.", ]) @@ -9667,9 +9891,8 @@ class GatewayRunner: "No previous unlinked Telegram sessions found.", "", "To restore a previous session later:", - "1. Create a new topic with the + button.", - "2. Open that topic.", - "3. Send /topic <session-id>.", + "1. Create or open a topic. To create a new one, open All Messages and send any message there.", + "2. Send /topic <session-id> inside that topic.", ]) return "\n".join(lines) @@ -13549,20 +13772,29 @@ class GatewayRunner: _title_failure_cb = getattr( agent, "_emit_auxiliary_failure", None ) - maybe_auto_title( - self._session_db, - effective_session_id, - message, - final_response, - all_msgs, - failure_callback=_title_failure_cb, - main_runtime={ + maybe_auto_title_kwargs = { + "failure_callback": _title_failure_cb, + "main_runtime": { "model": getattr(agent, "model", None), "provider": getattr(agent, "provider", None), "base_url": getattr(agent, "base_url", None), "api_key": getattr(agent, "api_key", None), "api_mode": getattr(agent, "api_mode", None), } if agent else None, + } + if self._is_telegram_topic_lane(source): + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_telegram_topic_title_rename( + source, + effective_session_id, + title, + ) + maybe_auto_title( + self._session_db, + effective_session_id, + message, + final_response, + all_msgs, + **maybe_auto_title_kwargs, ) except Exception: pass diff --git a/pyproject.toml b/pyproject.toml index a58e172795e..b5de3d69f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*"] +gateway = ["assets/**/*"] [tool.setuptools.packages.find] include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index e10cba76a89..c498a71ab50 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -136,6 +136,21 @@ class TestAutoTitleSession: auto_title_session(db, "sess-1", "hi", "hello") db.set_session_title.assert_called_once_with("sess-1", "New Title") + def test_invokes_title_callback_after_setting_title(self): + db = MagicMock() + db.get_session_title.return_value = None + seen = [] + with patch("agent.title_generator.generate_title", return_value="Readable Session"): + auto_title_session( + db, + "sess-1", + "hello", + "hi there", + title_callback=seen.append, + ) + db.set_session_title.assert_called_once_with("sess-1", "Readable Session") + assert seen == ["Readable Session"] + def test_skips_if_generation_fails(self): db = MagicMock() db.get_session_title.return_value = None @@ -182,7 +197,13 @@ class TestMaybeAutoTitle: import time time.sleep(0.3) mock_auto.assert_called_once_with( - db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None + db, + "sess-1", + "hello", + "hi there", + failure_callback=None, + main_runtime=None, + title_callback=None, ) def test_forwards_failure_callback_to_worker(self): @@ -202,7 +223,13 @@ class TestMaybeAutoTitle: import time time.sleep(0.3) mock_auto.assert_called_once_with( - db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None + db, + "sess-1", + "hello", + "hi there", + failure_callback=_cb, + main_runtime=None, + title_callback=None, ) def test_skips_if_no_response(self): diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index ad72514ed5d..a797b523523 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -63,6 +63,10 @@ def _make_runner(session_db=None): ) adapter = MagicMock() adapter.send = AsyncMock() + adapter.send_image_file = AsyncMock() + adapter._bot = None + adapter._create_dm_topic = AsyncMock(return_value=None) + adapter.rename_dm_topic = AsyncMock() runner.adapters = {Platform.TELEGRAM: adapter} runner._voice_mode = {} runner.hooks = SimpleNamespace( @@ -150,7 +154,7 @@ async def test_root_telegram_dm_prompt_is_system_lobby_when_topic_mode_enabled(m result = await runner._handle_message(_make_event("hello from root")) assert "main chat is reserved for system commands" in result - assert "+ button" in result + assert "All Messages" in result runner._run_agent.assert_not_called() runner.session_store.get_or_create_session.assert_not_called() @@ -172,8 +176,8 @@ async def test_root_telegram_dm_new_shows_create_topic_instruction(monkeypatch): result = await runner._handle_message(_make_event("/new")) assert "create a new topic" in result - assert "+ button" in result - assert "Use /new inside a topic" in result + assert "All Messages" in result + assert "Use /new inside" in result runner._run_agent.assert_not_called() runner.session_store.reset_session.assert_not_called() runner.session_store.get_or_create_session.assert_not_called() @@ -357,8 +361,8 @@ async def test_new_inside_telegram_topic_resets_current_topic_with_parallel_tip( result = await runner._handle_message(_make_event("/new", thread_id="17585")) assert "Started a new Hermes session in this topic" in result - assert "for parallel work" in result - assert "+ button" in result + assert "parallel work" in result + assert "All Messages" in result runner.session_store.reset_session.assert_called_once_with(topic_key) @@ -379,7 +383,7 @@ async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp result = await runner._handle_message(_make_event("/topic")) assert "Telegram multi-session topics are enabled" in result - assert "+ button" in result + assert "All Messages" in result assert session_db.get_meta("telegram_dm_topic_schema_version") == "1" assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") assert runner._telegram_topic_mode_enabled(_make_source()) is True @@ -462,7 +466,7 @@ async def test_topic_root_command_handles_no_unlinked_sessions(tmp_path, monkeyp assert "Telegram multi-session topics are enabled" in result assert "No previous unlinked Telegram sessions found" in result - assert "+ button" in result + assert "All Messages" in result runner._run_agent.assert_not_called() @@ -623,3 +627,124 @@ async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkey assert binding["user_id"] == "208214988" assert binding["session_id"] == "sess-topic" assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) + + +@pytest.mark.asyncio +async def test_topic_root_command_checks_getme_capabilities_before_enabling(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + bot = AsyncMock() + bot.get_me.return_value = SimpleNamespace( + has_topics_enabled=False, + allows_users_to_create_topics=True, + ) + runner.adapters[Platform.TELEGRAM]._bot = bot + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic capability failure must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "topics are not enabled" in result + assert "Open @BotFather" in result + assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") is False + bot.get_me.assert_awaited_once() + runner.adapters[Platform.TELEGRAM].send_image_file.assert_awaited_once() + image_kwargs = runner.adapters[Platform.TELEGRAM].send_image_file.await_args.kwargs + assert image_kwargs["chat_id"] == "208214988" + assert image_kwargs["image_path"].endswith("telegram-botfather-threads-settings.jpg") + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_root_command_creates_and_pins_system_topic(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + adapter = runner.adapters[Platform.TELEGRAM] + adapter._create_dm_topic.return_value = 4242 + adapter.send.return_value = SimpleNamespace(success=True, message_id="777") + bot = AsyncMock() + bot.get_me.return_value = { + "has_topics_enabled": True, + "allows_users_to_create_topics": True, + } + adapter._bot = bot + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + adapter._create_dm_topic.assert_awaited_once_with(208214988, "System") + adapter.send.assert_awaited_once_with( + "208214988", + "System topic for Hermes commands and status.", + metadata={"thread_id": "4242"}, + ) + bot.pin_chat_message.assert_awaited_once_with( + chat_id=208214988, + message_id=777, + disable_notification=True, + ) + + +@pytest.mark.asyncio +async def test_auto_generated_title_renames_bound_telegram_topic(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.apply_telegram_topic_migration() + db.create_session("sess-topic", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="42", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:42", + session_id="sess-topic", + ) + runner = _make_runner(session_db=db) + runner._telegram_topic_mode_enabled = lambda source: True + + await runner._rename_telegram_topic_for_session_title( + _make_source(thread_id="42"), + "sess-topic", + " Build Telegram Topic UX ", + ) + + runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_awaited_once_with( + chat_id="208214988", + thread_id="42", + name="Build Telegram Topic UX", + ) + + +@pytest.mark.asyncio +async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.apply_telegram_topic_migration() + db.create_session("sess-other", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="42", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:42", + session_id="sess-other", + ) + runner = _make_runner(session_db=db) + runner._telegram_topic_mode_enabled = lambda source: True + + await runner._rename_telegram_topic_for_session_title( + _make_source(thread_id="42"), + "sess-topic", + "Wrong Session Title", + ) + + runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called() From a7683d04a9646f8946c4469c31643d7b2670b815 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 3 May 2026 05:34:07 -0700 Subject: [PATCH 03/28] =?UTF-8?q?fix(telegram):=20harden=20DM=20topic=20bi?= =?UTF-8?q?nding=20=E2=80=94=20persist=20through=20switch=5Fsession,=20reb?= =?UTF-8?q?ind=20on=20/new?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on @EmelyanenkoK's feat: add Telegram DM topic-mode sessions. Three issues: 1. Split-brain session state. After get_or_create_session() returned a SessionEntry for a topic lane, the handler was mutating .session_id in place to the binding's target, but never persisting the switch through SessionStore. The sessions.json session_key → session_id map kept pointing at the lane's natural id; any reader that reloaded from disk saw the wrong id. Fixed by routing through SessionStore.switch_session(), which _save()s the mapping and ends the old session in SQLite like /resume does. 2. /new inside a topic was a one-message no-op. Reset created a new session but left the telegram_dm_topic_bindings row pointing at the old session_id, so the next message's binding lookup switched right back. Now _handle_reset_command rebinds the topic to the new session_id after reset. 3. is_telegram_session_linked_to_topic and list_unlinked_telegram_sessions_for_user both called apply_telegram_topic_migration() on read, contradicting the PR's own invariant that migration only runs on explicit /topic opt-in. They now tolerate missing topic tables and return empty/False. Also: _telegram_topic_mode_enabled() now only treats True as enabled (not any truthy return), so test fixtures with MagicMock session_db don't accidentally flip every DM into lobby mode — this was breaking 4 pre-existing test_status_command tests. Tests: - New regression: /new inside a topic must update the binding row (test_new_inside_telegram_topic_rewrites_binding_to_new_session). - _make_runner now stubs switch_session so existing restore tests still exercise the new code path. Validated end-to-end with real SessionDB + SessionStore: readers on fresh DB don't create topic tables; enable creates them; binding override persists across SessionStore restart; /new rebinds and the new id survives a restart. Co-authored-by: EmelyanenkoK <emelyanenko.kirill@gmail.com> --- gateway/run.py | 34 +++++-- hermes_state.py | 116 +++++++++++++++------- scripts/release.py | 1 + tests/gateway/test_telegram_topic_mode.py | 77 ++++++++++++++ 4 files changed, 184 insertions(+), 44 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 6fd19472c24..4dce7f465ad 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1463,15 +1463,17 @@ class GatewayRunner: if session_db is None: return False try: - return bool( - session_db.is_telegram_topic_mode_enabled( - chat_id=str(source.chat_id), - user_id=str(source.user_id), - ) + raw = session_db.is_telegram_topic_mode_enabled( + chat_id=str(source.chat_id), + user_id=str(source.user_id), ) except Exception: logger.debug("Failed to read Telegram topic mode state", exc_info=True) return False + # Only honor a real True from the SessionDB. Any other value + # (including MagicMock instances from test fixtures that didn't + # opt into topic mode) means topic mode is off for this chat. + return raw is True def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool: """True for the main Telegram DM when topic mode has made it a lobby.""" @@ -5902,7 +5904,16 @@ class GatewayRunner: logger.debug("Failed to read Telegram topic binding", exc_info=True) binding = None if binding: - session_entry.session_id = str(binding.get("session_id") or session_entry.session_id) + bound_session_id = str(binding.get("session_id") or "") + if bound_session_id and bound_session_id != session_entry.session_id: + # Route the override through SessionStore so the session_key + # → session_id mapping is persisted to disk and the previous + # lane session is ended cleanly. Mutating session_entry in + # place here created a split-brain state where the JSON + # index pointed at one id but code downstream used another. + switched = self.session_store.switch_session(session_key, bound_session_id) + if switched is not None: + session_entry = switched else: try: self._record_telegram_topic_binding(source, session_entry) @@ -7123,6 +7134,17 @@ class GatewayRunner: _title_note = "\n⚠️ Title is empty after cleanup — session started 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. Without this, the binding + # still points at the old session and the binding-lookup at the + # top of _handle_message_with_agent would switch right back. + if self._is_telegram_topic_lane(source) and new_entry is not None: + try: + 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.plugins import invoke_hook as _invoke_hook diff --git a/hermes_state.py b/hermes_state.py index 7f26659e7d0..9063231165b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2350,16 +2350,25 @@ class SessionDB: self._execute_write(_do) def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool: - """Return True if a Hermes session is already bound to any Telegram DM topic.""" - self.apply_telegram_topic_migration() - row = self._conn.execute( - """ - SELECT 1 FROM telegram_dm_topic_bindings - WHERE session_id = ? - LIMIT 1 - """, - (str(session_id),), - ).fetchone() + """Return True if a Hermes session is already bound to any Telegram DM topic. + + Read-only: does NOT trigger the telegram-topic migration. If the + topic-mode tables have not been created yet (i.e. nobody has run + ``/topic`` in this profile), the session is by definition unbound + and we return False. + """ + with self._lock: + try: + row = self._conn.execute( + """ + SELECT 1 FROM telegram_dm_topic_bindings + WHERE session_id = ? + LIMIT 1 + """, + (str(session_id),), + ).fetchone() + except sqlite3.OperationalError: + return False return row is not None def list_unlinked_telegram_sessions_for_user( @@ -2369,35 +2378,66 @@ class SessionDB: user_id: str, limit: int = 10, ) -> List[Dict[str, Any]]: - """List previous Telegram sessions for this user that are not bound to a topic.""" - self.apply_telegram_topic_migration() + """List previous Telegram sessions for this user that are not bound to a topic. + + Read-only: does NOT trigger the telegram-topic migration. If the + topic-mode tables are absent, fall back to a simpler query that + just returns this user's Telegram sessions — there can't be any + bindings yet. + """ with self._lock: - rows = self._conn.execute( - """ - SELECT s.*, - COALESCE( - (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) - FROM messages m - WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL - ORDER BY m.timestamp, m.id LIMIT 1), - '' - ) AS _preview_raw, - COALESCE( - (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), - s.started_at - ) AS last_active - FROM sessions s - WHERE s.source = 'telegram' - AND s.user_id = ? - AND NOT EXISTS ( - SELECT 1 FROM telegram_dm_topic_bindings b - WHERE b.session_id = s.id - ) - ORDER BY last_active DESC, s.started_at DESC - LIMIT ? - """, - (str(user_id), int(limit)), - ).fetchall() + try: + rows = self._conn.execute( + """ + SELECT s.*, + COALESCE( + (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id LIMIT 1), + '' + ) AS _preview_raw, + COALESCE( + (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), + s.started_at + ) AS last_active + FROM sessions s + WHERE s.source = 'telegram' + AND s.user_id = ? + AND NOT EXISTS ( + SELECT 1 FROM telegram_dm_topic_bindings b + WHERE b.session_id = s.id + ) + ORDER BY last_active DESC, s.started_at DESC + LIMIT ? + """, + (str(user_id), int(limit)), + ).fetchall() + except sqlite3.OperationalError: + # telegram_dm_topic_bindings doesn't exist yet — no bindings + # means every telegram session for this user is "unlinked". + rows = self._conn.execute( + """ + SELECT s.*, + COALESCE( + (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id LIMIT 1), + '' + ) AS _preview_raw, + COALESCE( + (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), + s.started_at + ) AS last_active + FROM sessions s + WHERE s.source = 'telegram' + AND s.user_id = ? + ORDER BY last_active DESC, s.started_at DESC + LIMIT ? + """, + (str(user_id), int(limit)), + ).fetchall() sessions: List[Dict[str, Any]] = [] for row in rows: diff --git a/scripts/release.py b/scripts/release.py index 7197f3d8330..32a5ff0ce86 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -55,6 +55,7 @@ AUTHOR_MAP = { "14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel", "657290301@qq.com": "IMHaoyan", "revar@users.noreply.github.com": "revaraver", + "emelyanenko.kirill@gmail.com": "EmelyanenkoK", # Matrix parity salvage batch (April 2026) "sr@samirusani": "samrusani", "angelclaw@AngelMacBook.local": "angel12", diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index a797b523523..97d6dd61143 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -100,6 +100,21 @@ def _make_runner(session_db=None): runner.session_store.rewrite_transcript = MagicMock() runner.session_store.update_session = MagicMock() runner.session_store.reset_session = MagicMock(return_value=None) + + # Default switch_session impl: returns a SessionEntry carrying the target + # session_id. Mirrors SessionStore.switch_session semantics for tests that + # exercise Telegram topic binding rebinds without a real store. + def _switch_session(session_key, target_session_id): + return SessionEntry( + session_key=session_key, + session_id=target_session_id, + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=None, + ) + runner.session_store.switch_session = MagicMock(side_effect=_switch_session) runner._running_agents = {} runner._running_agents_ts = {} runner._pending_messages = {} @@ -366,6 +381,68 @@ async def test_new_inside_telegram_topic_resets_current_topic_with_parallel_tip( runner.session_store.reset_session.assert_called_once_with(topic_key) +@pytest.mark.asyncio +async def test_new_inside_telegram_topic_rewrites_binding_to_new_session(tmp_path, monkeypatch): + """Regression: /new inside a topic must rewrite the binding table. + + Previously /new reset the SessionStore entry but the + telegram_dm_topic_bindings row still pointed at the old session_id; + the next inbound message would look up the stale binding and switch + back to the old session, making /new a no-op. + """ + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="old-topic-session", + source="telegram", + user_id="208214988", + ) + topic_source = _make_source(thread_id="17585") + topic_key = build_session_key(topic_source) + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key=topic_key, + session_id="old-topic-session", + ) + + runner = _make_runner(session_db=session_db) + new_entry = SessionEntry( + session_key=topic_key, + session_id="new-topic-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + # Mirror SessionStore.reset_session: in production it calls + # SessionDB.create_session() for the new id before returning, so the + # bindings FK can reference it. + session_db.create_session( + session_id="new-topic-session", + source="telegram", + user_id="208214988", + ) + runner.session_store.reset_session.return_value = new_entry + runner._agent_cache_lock = None + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + await runner._handle_message(_make_event("/new", thread_id="17585")) + + binding = session_db.get_telegram_topic_binding( + chat_id="208214988", thread_id="17585", + ) + assert binding is not None + assert binding["session_id"] == "new-topic-session" + + @pytest.mark.asyncio async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp_path, monkeypatch): import gateway.run as gateway_run From 1a9542cf75fbdf21036a84718a2965d59c8ec09c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 3 May 2026 05:40:56 -0700 Subject: [PATCH 04/28] docs(telegram): document /topic multi-session DM mode Adds a new section 'Multi-session DM mode (/topic)' to the Telegram messaging docs, covering: - Comparison table vs the existing config-driven extra.dm_topics - BotFather prerequisites (Threads Settings, user-create permission) - Activation flow and root-DM lobby behavior - End-user flow for creating topics via the + button / All Messages - Auto-renaming when Hermes generates session titles - /new semantics inside a topic - /topic <session-id> restore of previous sessions - Persistence layout (SQLite side tables) - How to disable the feature Also: - New /topic row in the messaging slash-commands reference - Updated Bot API 9.4 summary to point at both topic features --- website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/messaging/telegram.md | 102 +++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index ceab9190b84..c96a6986d55 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -145,6 +145,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/undo` | Remove the last exchange. | | `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. | | `/compress [focus topic]` | Manually compress conversation context. Optional focus topic narrows what the summary preserves. | +| `/topic [session-id]` | **Telegram DM only.** Enable or inspect user-managed multi-session topic mode. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). | | `/title [name]` | Set or show the session title. | | `/resume [name]` | Resume a previously named session. | | `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. | @@ -174,6 +175,6 @@ The messaging gateway supports the following built-in commands inside Telegram, - `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, and `/quit` are **CLI-only** commands. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. -- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands. +- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands. - `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index dd933aa2fdc..0e518082d58 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -396,6 +396,106 @@ For example, a topic with `skill: arxiv` will have the arxiv skill pre-loaded wh Topics created outside of the config (e.g., by manually calling the Telegram API) are discovered automatically when a `forum_topic_created` service message arrives. You can also add topics to the config while the gateway is running — they'll be picked up on the next cache miss. ::: +## Multi-session DM mode (`/topic`) + +A ChatGPT-style multi-session DM — one bot, many parallel conversations. Unlike the operator-curated `extra.dm_topics` above, this mode is **user-driven**: no config, no pre-declared topic names. The end user flips it on with `/topic`, then taps the Telegram **+** button to create as many topics as they want, each one a fully independent Hermes session. + +### DM Topics vs Multi-session DM mode + +| | `extra.dm_topics` (config-driven) | `/topic` (user-driven) | +|---|---|---| +| Who activates it | Operator, in `config.yaml` | End user, by sending `/topic` | +| Topic list | Fixed set declared in config | User creates/deletes topics freely | +| Topic names | Chosen by operator | Chosen by user; auto-renamed to match Hermes session title | +| Root DM behavior | Unchanged — normal chat | Becomes a system lobby (non-command messages are rejected) | +| Primary use case | Permanent workspaces with optional skill binding | Ad-hoc parallel sessions | +| Persistence | `extra.dm_topics` in config | `telegram_dm_topic_mode` + `telegram_dm_topic_bindings` SQLite tables | + +Both features can coexist on the same bot — you'd run `/topic` from a user's DM, and `extra.dm_topics` continues to manage operator-declared topics for other chats. + +### Prerequisites + +In **@BotFather**, open your bot → **Bot Settings → Threads Settings**: + +1. Turn on **Threaded Mode** (enables `has_topics_enabled`) +2. Do **not** disable users creating topics (keeps `allows_users_to_create_topics` on) + +When the user first runs `/topic`, Hermes calls `getMe` to verify both flags. If either is off, Hermes sends a screenshot of the BotFather Threads Settings page and explains what to toggle — no activation happens until prerequisites are met. + +### Activation flow + +From the root DM, send: + +``` +/topic +``` + +Hermes will: + +1. Check `getMe().has_topics_enabled` and `allows_users_to_create_topics` +2. If both are true, enable multi-session topic mode for this DM +3. Create and pin a **System** topic for status/commands (best-effort) +4. Reply with a list of previous unlinked Telegram sessions the user can restore + +After activation, the **root DM is a lobby**: normal prompts are rejected with guidance pointing at **All Messages**. System commands (`/status`, `/sessions`, `/usage`, `/help`, etc.) still work in the root. + +### Creating a new topic (end-user flow) + +1. Open the bot DM in Telegram +2. Tap **All Messages** at the top of the bot interface, then send any message +3. Telegram creates a new topic for that message +4. Hermes responds inside that topic — the topic is now a standalone session + +Every topic gets its own conversation history, model state, tool execution, and session ID. The isolation key is `agent:main:telegram:dm:{chat_id}:{thread_id}` — identical to the config-driven DM topics isolation. + +### Auto-renamed topics + +When Hermes generates a session title for a topic (via the auto-title pipeline, after the first exchange), the Telegram topic itself is renamed to match — e.g. "New Topic" becomes "Database migration plan". The rename is best-effort: failures are logged but don't break the session. + +### `/new` inside a topic + +Resets the current topic's session (new session ID, fresh history) without touching other topics. Hermes replies with a reminder that for parallel work, creating another topic (via **All Messages**) is usually what you want. + +### Restoring a previous session + +Inside a topic, send: + +``` +/topic <session-id> +``` + +This binds the current topic to an existing Hermes session instead of starting fresh. Useful for continuing a conversation that started before topic mode was enabled. Restrictions: + +- The target session must belong to the same Telegram user +- The target session must not already be bound to another topic + +Hermes confirms with the session title and replays the last assistant message for context. + +To discover session IDs, send `/topic` (no argument) in the root DM — Hermes lists the user's unlinked Telegram sessions. + +### `/topic` inside a topic (no argument) + +Shows the current topic's binding: session title, session ID, and hints for `/new` vs creating another topic. + +### Under the hood + +- Activation persists to `telegram_dm_topic_mode(chat_id, user_id, enabled, ...)` in `state.db` +- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` +- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged +- Each inbound DM message looks up its `(chat_id, thread_id)` binding. If present, the lookup routes the message to the bound session via `SessionStore.switch_session()` so the session-key-to-session-id mapping stays consistent on disk +- `/new` inside a topic rewrites the binding row to point at the new session ID, so the next message stays on the fresh session + +### Disabling multi-session mode + +There is no slash command to exit multi-session mode. If you need to turn it off, remove the row manually: + +```bash +sqlite3 ~/.hermes/state.db \ + "DELETE FROM telegram_dm_topic_mode WHERE chat_id = '<your_chat_id>'" +``` + +Existing topics in Telegram won't disappear — they'll just stop being gated as independent sessions on the Hermes side. The binding rows can also be cleared with `DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_chat_id>'`. + ## Group Forum Topic Skill Binding Supergroups with **Topics mode** enabled (also called "forum topics") already get session isolation per topic — each `thread_id` maps to its own conversation. But you may want to **auto-load a skill** when messages arrive in a specific group topic, just like DM topic skill binding works. @@ -463,7 +563,7 @@ To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and loo ## Recent Bot API Features -- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. See [Private Chat Topics](#private-chat-topics-bot-api-94) above. +- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. Hermes uses this for two distinct features: operator-curated [Private Chat Topics](#private-chat-topics-bot-api-94) (config-driven, fixed topic list) and user-driven [Multi-session DM mode](#multi-session-dm-mode-topic) (activated by `/topic`, unlimited user-created topics). - **Privacy policy:** Telegram now requires bots to have a privacy policy. Set one via BotFather with `/setprivacy_policy`, or Telegram may auto-generate a placeholder. This is particularly important if your bot is public-facing. - **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies. From 1381c89e56fd3e6abbd0233b5a361069ae1862c1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 3 May 2026 08:08:13 -0700 Subject: [PATCH 05/28] =?UTF-8?q?fix(telegram):=20polish=20topic=20mode=20?= =?UTF-8?q?=E2=80=94=20CASCADE,=20General-topic=20handling,=20rename=20gua?= =?UTF-8?q?rd,=20debounce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups to topic mode based on integration audit: 1. ON DELETE CASCADE on telegram_dm_topic_bindings.session_id. Session pruning (manual /delete, auto-cleanup, any future prune job) would have thrown 'FOREIGN KEY constraint failed' for sessions bound to a topic. Migration bumped to v2, rebuilds the bindings table in place if FK lacks CASCADE. Idempotent; only runs once per DB. 2. Never auto-rename operator-declared topics. If an operator has extra.dm_topics configured AND a user runs /topic, messages in those pre-declared topics would previously trigger auto-rename and silently mutate operator config. _rename_telegram_topic_for_session_title now early-returns when _get_dm_topic_info returns a dict for this (chat_id, thread_id). Uses class-based lookup (not hasattr) so MagicMock test fixtures don't accidentally trip the guard. 3. General topic handling. Telegram's General (pinned top) topic in a forum-enabled private chat may send messages with message_thread_id=1 or omit thread_id entirely depending on client. Both are now treated as the root lobby, not a topic lane. Prevents users from accidentally burning a session on the General topic. 4. Debounce the root-lobby reminder. 30-second cooldown per chat so a user who forgets topic mode is enabled and types ten messages in the root gets one reminder, not ten. Explicit command replies (/new-in-lobby, /topic <session-id>) still land every time. 5. Docs: added under-the-hood invariants for the above, plus a Downgrade section explaining that rolling back to a pre-/topic Hermes build leaves the DB tables orphaned but harmless — DMs just revert to native per-thread isolation. Tests: - test_operator_declared_topic_is_not_auto_renamed - test_general_topic_is_treated_as_root_lobby - test_lobby_reminder_is_debounced_per_chat - test_binding_survives_session_deletion_via_cascade - test_migration_rebuilds_v1_binding_table_with_cascade_fk Validated: 4803/4804 tests pass (tests/gateway/ + tests/test_hermes_state.py). Sole failure is a pre-existing test_teams::test_send_typing flake unrelated to this PR. --- gateway/run.py | 84 +++++++-- hermes_state.py | 54 +++++- tests/gateway/test_telegram_topic_mode.py | 160 +++++++++++++++++- tests/test_hermes_state.py | 4 +- website/docs/user-guide/messaging/telegram.md | 10 +- 5 files changed, 291 insertions(+), 21 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 4dce7f465ad..6b40532e64a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1475,23 +1475,52 @@ class GatewayRunner: # opt into topic mode) means topic mode is off for this chat. return raw is True + # Telegram's General (pinned top) topic in forum-enabled private chats. + # Bot API behavior varies: some clients omit message_thread_id for + # General, others send "1". Treat both as "root" for lobby/lane purposes. + _TELEGRAM_GENERAL_TOPIC_IDS = frozenset({"", "1"}) + def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool: - """True for the main Telegram DM when topic mode has made it a lobby.""" - return ( - source.platform == Platform.TELEGRAM - and source.chat_type == "dm" - and not source.thread_id - and self._telegram_topic_mode_enabled(source) - ) + """True for the main Telegram DM (or General topic) when topic mode has made it a lobby.""" + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return False + if not self._telegram_topic_mode_enabled(source): + return False + tid = str(source.thread_id or "") + return tid in self._TELEGRAM_GENERAL_TOPIC_IDS def _is_telegram_topic_lane(self, source: SessionSource) -> bool: """True for a user-created Telegram private-chat topic lane.""" - return ( - source.platform == Platform.TELEGRAM - and source.chat_type == "dm" - and bool(source.thread_id) - and self._telegram_topic_mode_enabled(source) - ) + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return False + if not self._telegram_topic_mode_enabled(source): + return False + tid = str(source.thread_id or "") + if not tid or tid in self._TELEGRAM_GENERAL_TOPIC_IDS: + return False + return True + + _TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0 + + def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool: + """Rate-limit root-DM lobby reminders to one message per cooldown window. + + A user who forgets multi-session mode is enabled and types several + prompts in the root DM would otherwise get a reminder for every + message. Cap it so the first one lands and the rest stay quiet. + """ + if not hasattr(self, "_telegram_lobby_reminder_ts"): + self._telegram_lobby_reminder_ts = {} + chat_id = str(source.chat_id or "") + if not chat_id: + return True + import time as _time + now = _time.monotonic() + last = self._telegram_lobby_reminder_ts.get(chat_id, 0.0) + if now - last < self._TELEGRAM_LOBBY_REMINDER_COOLDOWN_S: + return False + self._telegram_lobby_reminder_ts[chat_id] = now + return True def _telegram_topic_root_lobby_message(self) -> str: return ( @@ -5617,7 +5646,11 @@ class GatewayRunner: # execution of a dangerous command. if self._is_telegram_topic_root_lobby(source): - return self._telegram_topic_root_lobby_message() + # Debounce the lobby reminder so a user who forgets about + # topic mode and fires ten prompts doesn't get ten copies. + if self._should_send_telegram_lobby_reminder(source): + return self._telegram_topic_root_lobby_message() + return None # ── Claim this session before any await ─────────────────────── # Between here and _run_agent registering the real AIAgent, there @@ -9705,6 +9738,28 @@ class GatewayRunner: """Best-effort rename of a Telegram DM topic when Hermes auto-titles a session.""" if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id: return + + # Skip rename when the topic is operator-declared via + # extra.dm_topics. Those topics have fixed names chosen by the + # operator (plus optional skill binding); auto-renaming would + # silently mutate operator config. + # + # Check the class, not the instance — getattr() on MagicMock + # auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")` + # would return True for every test double. + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is not None: + get_info = getattr(type(adapter), "_get_dm_topic_info", None) + if callable(get_info): + try: + operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id)) + except Exception: + operator_topic = None + # Only treat dict-shaped returns as operator-declared; a + # bare MagicMock or other sentinel shouldn't count. + if isinstance(operator_topic, dict): + return + session_db = getattr(self, "_session_db", None) if session_db is not None: try: @@ -9718,7 +9773,6 @@ class GatewayRunner: logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True) return - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None if adapter is None: return topic_name = self._sanitize_telegram_topic_title(title) diff --git a/hermes_state.py b/hermes_state.py index 9063231165b..7d1a7d03a7b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2155,6 +2155,11 @@ class SessionDB: reconciliation. Operators must be able to upgrade Hermes, keep the old Telegram bot behavior running, and only mutate topic-mode state when the user executes /topic to opt into the feature. + + Schema versions: + v1 — initial shape (no ON DELETE CASCADE on session_id FK) + v2 — session_id FK gets ON DELETE CASCADE so session pruning + automatically clears bindings. """ def _do(conn): conn.executescript( @@ -2177,7 +2182,7 @@ class SessionDB: thread_id TEXT NOT NULL, user_id TEXT NOT NULL, session_key TEXT NOT NULL, - session_id TEXT NOT NULL REFERENCES sessions(id), + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, managed_mode TEXT NOT NULL DEFAULT 'auto', linked_at REAL NOT NULL, updated_at REAL NOT NULL, @@ -2191,10 +2196,55 @@ class SessionDB: ON telegram_dm_topic_bindings(user_id, chat_id); """ ) + + # v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK + # lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we + # rebuild the table. Only runs once per DB (version gate). + current = conn.execute( + "SELECT value FROM state_meta WHERE key = ?", + ("telegram_dm_topic_schema_version",), + ).fetchone() + current_version = int(current[0]) if current and str(current[0]).isdigit() else 0 + if current_version < 2: + fk_rows = conn.execute( + "PRAGMA foreign_key_list('telegram_dm_topic_bindings')" + ).fetchall() + needs_rebuild = any( + row[2] == "sessions" and (row[6] or "") != "CASCADE" + for row in fk_rows + ) + if needs_rebuild: + conn.executescript( + """ + CREATE TABLE telegram_dm_topic_bindings_new ( + chat_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ); + INSERT INTO telegram_dm_topic_bindings_new + SELECT chat_id, thread_id, user_id, session_key, + session_id, managed_mode, linked_at, updated_at + FROM telegram_dm_topic_bindings; + DROP TABLE telegram_dm_topic_bindings; + ALTER TABLE telegram_dm_topic_bindings_new + RENAME TO telegram_dm_topic_bindings; + CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session + ON telegram_dm_topic_bindings(session_id); + CREATE INDEX idx_telegram_dm_topic_bindings_user + ON telegram_dm_topic_bindings(user_id, chat_id); + """ + ) + conn.execute( "INSERT INTO state_meta (key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ("telegram_dm_topic_schema_version", "1"), + ("telegram_dm_topic_schema_version", "2"), ) self._execute_write(_do) diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 97d6dd61143..665cff03fd8 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -461,7 +461,7 @@ async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp assert "Telegram multi-session topics are enabled" in result assert "All Messages" in result - assert session_db.get_meta("telegram_dm_topic_schema_version") == "1" + assert session_db.get_meta("telegram_dm_topic_schema_version") == "2" assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") assert runner._telegram_topic_mode_enabled(_make_source()) is True runner._run_agent.assert_not_called() @@ -825,3 +825,161 @@ async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session ) runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called() + + +@pytest.mark.asyncio +async def test_operator_declared_topic_is_not_auto_renamed(tmp_path): + """Topics registered in extra.dm_topics keep their operator-chosen name.""" + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + db.create_session(session_id="sess-topic", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key=build_session_key(_make_source(thread_id="17585")), + session_id="sess-topic", + ) + runner = _make_runner(session_db=db) + runner._telegram_topic_mode_enabled = lambda source: True + + # Give the adapter a concrete class with _get_dm_topic_info so the + # class-based lookup in _rename_telegram_topic_for_session_title + # actually finds it (a MagicMock auto-attr would be skipped). + class _FakeAdapter: + def _get_dm_topic_info(self, chat_id, thread_id): + return {"name": "Research", "skill": "arxiv"} + + async def rename_dm_topic(self, **kwargs): + return None + + fake = _FakeAdapter() + fake.rename_dm_topic = AsyncMock() + runner.adapters[Platform.TELEGRAM] = fake + + await runner._rename_telegram_topic_for_session_title( + _make_source(thread_id="17585"), + "sess-topic", + "Auto-generated title", + ) + + fake.rename_dm_topic.assert_not_called() + + +def test_general_topic_is_treated_as_root_lobby(tmp_path): + """Messages in the Telegram General topic (thread_id=1) route to the lobby, not a lane.""" + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=db) + + general_source = _make_source(thread_id="1") + assert runner._is_telegram_topic_root_lobby(general_source) is True + assert runner._is_telegram_topic_lane(general_source) is False + + no_thread_source = _make_source(thread_id=None) + assert runner._is_telegram_topic_root_lobby(no_thread_source) is True + assert runner._is_telegram_topic_lane(no_thread_source) is False + + real_topic = _make_source(thread_id="17585") + assert runner._is_telegram_topic_root_lobby(real_topic) is False + assert runner._is_telegram_topic_lane(real_topic) is True + + +def test_lobby_reminder_is_debounced_per_chat(tmp_path): + """Consecutive root-DM prompts should only surface one lobby reminder per cooldown.""" + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=db) + + source = _make_source(thread_id=None) + assert runner._should_send_telegram_lobby_reminder(source) is True + # Next call inside the cooldown window must return False. + assert runner._should_send_telegram_lobby_reminder(source) is False + assert runner._should_send_telegram_lobby_reminder(source) is False + + # A different chat gets its own window. + other = _make_source(thread_id=None) + # Swap chat_id so the debounce key is different. + from dataclasses import replace + other = replace(other, chat_id="999999999") + assert runner._should_send_telegram_lobby_reminder(other) is True + + +def test_binding_survives_session_deletion_via_cascade(tmp_path): + """Deleting a session with a topic binding must not raise FK errors.""" + import sqlite3 + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + db.create_session(session_id="sess-to-delete", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:17585", + session_id="sess-to-delete", + ) + + # Before: binding exists. + binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding is not None + + # Delete the session. Without ON DELETE CASCADE this would raise + # sqlite3.IntegrityError: FOREIGN KEY constraint failed. + db._conn.execute("DELETE FROM sessions WHERE id = ?", ("sess-to-delete",)) + db._conn.commit() + + # After: binding row automatically cleared. + binding_after = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding_after is None + + +def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path): + """v1 → v2 migration rebuilds the bindings table when FK lacks ON DELETE CASCADE.""" + import sqlite3 + db_path = tmp_path / "state.db" + db = SessionDB(db_path=db_path) + + # Simulate a v1-shaped DB: migration ran without ON DELETE CASCADE. + db.apply_telegram_topic_migration() # Creates v2 (our new shape) + # Drop the v2 bindings table and recreate it in the old v1 shape. + with db._lock: + db._conn.execute("DROP TABLE telegram_dm_topic_bindings") + db._conn.execute( + """ + CREATE TABLE telegram_dm_topic_bindings ( + chat_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id), + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ) + """ + ) + # Also rewind the version marker so migration treats this as v1. + db._conn.execute( + "UPDATE state_meta SET value = '1' WHERE key = 'telegram_dm_topic_schema_version'" + ) + db._conn.commit() + + # Sanity check: FK has no CASCADE action yet. + fk_rows = db._conn.execute( + "PRAGMA foreign_key_list('telegram_dm_topic_bindings')" + ).fetchall() + assert any(row[2] == "sessions" and (row[6] or "") != "CASCADE" for row in fk_rows) + + # Re-run migration — should upgrade to v2 shape. + db.apply_telegram_topic_migration() + + fk_rows_after = db._conn.execute( + "PRAGMA foreign_key_list('telegram_dm_topic_bindings')" + ).fetchall() + assert any(row[2] == "sessions" and row[6] == "CASCADE" for row in fk_rows_after) + + version = db._conn.execute( + "SELECT value FROM state_meta WHERE key = 'telegram_dm_topic_schema_version'" + ).fetchone() + assert version is not None and version[0] == "2" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 24020286bd7..55249406683 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1565,7 +1565,7 @@ class TestSchemaInit: } assert "telegram_dm_topic_mode" in tables assert "telegram_dm_topic_bindings" in tables - assert db.get_meta("telegram_dm_topic_schema_version") == "1" + assert db.get_meta("telegram_dm_topic_schema_version") == "2" db.close() def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path): @@ -1593,7 +1593,7 @@ class TestSchemaInit: assert binding["user_id"] == "208214988" assert binding["session_key"] == "telegram:dm:208214988:thread:17585" assert binding["session_id"] == "topic-session" - assert db.get_meta("telegram_dm_topic_schema_version") == "1" + assert db.get_meta("telegram_dm_topic_schema_version") == "2" db.close() def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path): diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 0e518082d58..6a572805bf9 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -480,10 +480,14 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne ### Under the hood - Activation persists to `telegram_dm_topic_mode(chat_id, user_id, enabled, ...)` in `state.db` -- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` +- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` with `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding - The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged - Each inbound DM message looks up its `(chat_id, thread_id)` binding. If present, the lookup routes the message to the bound session via `SessionStore.switch_session()` so the session-key-to-session-id mapping stays consistent on disk - `/new` inside a topic rewrites the binding row to point at the new session ID, so the next message stays on the fresh session +- Topics declared in `extra.dm_topics` are **never auto-renamed** — the operator-chosen name is preserved even when multi-session mode is enabled +- The General (pinned top) topic in a forum-enabled DM is treated as the root lobby, regardless of whether Telegram delivers its messages with `message_thread_id=1` or with no thread_id +- Root-lobby reminders are rate-limited to one message per 30 seconds per chat — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies +- `/background <prompt>` started inside a topic delivers its result back to the same topic; background sessions don't trigger auto-rename of the owning topic ### Disabling multi-session mode @@ -496,6 +500,10 @@ sqlite3 ~/.hermes/state.db \ Existing topics in Telegram won't disappear — they'll just stop being gated as independent sessions on the Hermes side. The binding rows can also be cleared with `DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_chat_id>'`. +### Downgrading Hermes + +If you downgrade to a Hermes version that predates `/topic`, the feature simply stops working — the `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` tables remain in `state.db` but are ignored by older code. DMs revert to the native per-thread isolation (each `message_thread_id` still gets its own session via `build_session_key`), so your existing Telegram topics keep working as parallel sessions. The root DM is no longer a lobby — messages there go into the agent like they used to. Re-upgrading reactivates multi-session mode exactly where it was. + ## Group Forum Topic Skill Binding Supergroups with **Topics mode** enabled (also called "forum topics") already get session isolation per topic — each `thread_id` maps to its own conversation. But you may want to **auto-load a skill** when messages arrive in a specific group topic, just like DM topic skill binding works. From d35efb9898843e22d3d203a7fd6822dddb09d342 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 3 May 2026 10:39:47 -0700 Subject: [PATCH 06/28] feat(telegram): /topic off + help + auth gate + screenshot debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four production-readiness additions to topic mode: 1. /topic off — clean disable path. Flips telegram_dm_topic_mode.enabled to 0 and clears telegram_dm_topic_bindings for this chat. Previously users had to edit state.db with sqlite3 to turn the feature off. Idempotent: calling /topic off when the chat was never enabled returns a friendly no-op message. 2. /topic help — inline usage printed in the DM so users don't have to visit docs to discover /topic off, /topic <session-id>, etc. 3. Authorization gate. /topic mutates SQLite side tables and flips the root DM into a lobby, so the action must be authorized. Now calls self._is_user_authorized(source); unauthorized DMs get a refusal instead of activation. Defense in depth on top of the gateway's existing pre-route auth. 4. BotFather screenshot debounce. A user repeatedly running /topic while Threads Settings is still disabled would previously re-upload the same screenshot every time. Now rate-limited to one send per 5 minutes per chat. /topic off resets the counter so re-enabling starts fresh. Command-def args hint updated: /topic [off|help|session-id]. Docs: - New /topic subcommands table at the top of the multi-session section - Disable instructions updated to recommend /topic off first, with the raw SQL fallback kept for bulk cleanup - Under-the-hood list extended with the capability-hint debounce and the authorization gate Tests (6 new): - /topic help returns usage and doesn't create topic tables - /topic off disables mode AND clears bindings - /topic off is idempotent when never enabled - Unauthorized users get refusal, no tables created - Capability-hint debounce is per-chat - /topic off resets both lobby and capability debounce counters All 402 targeted tests pass. Full gateway sweep: 4809/4810 (pre-existing test_teams::test_send_typing unrelated). --- gateway/run.py | 107 +++++++++++++- hermes_cli/commands.py | 2 +- hermes_state.py | 33 +++++ tests/gateway/test_telegram_topic_mode.py | 130 ++++++++++++++++++ website/docs/reference/slash-commands.md | 2 +- website/docs/user-guide/messaging/telegram.md | 24 +++- 6 files changed, 290 insertions(+), 8 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 6b40532e64a..f90b2b1b03c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9838,6 +9838,84 @@ class GatewayRunner: future.add_done_callback(_log_rename_failure) + _TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0 + + def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: + """Rate-limit the BotFather Threads Settings screenshot. + + If a user sends /topic repeatedly while Threads Settings are still + off, we shouldn't keep re-uploading the screenshot every time. + """ + if not hasattr(self, "_telegram_capability_hint_ts"): + self._telegram_capability_hint_ts = {} + chat_id = str(source.chat_id or "") + if not chat_id: + return True + import time as _time + now = _time.monotonic() + last = self._telegram_capability_hint_ts.get(chat_id, 0.0) + if now - last < self._TELEGRAM_CAPABILITY_HINT_COOLDOWN_S: + return False + self._telegram_capability_hint_ts[chat_id] = now + return True + + def _telegram_topic_help_text(self) -> str: + return ( + "/topic — enable multi-session DM mode (one bot, many parallel chats)\n" + "\n" + "Usage:\n" + " /topic Enable topic mode, or show status if already on\n" + " /topic help Show this message\n" + " /topic off Disable topic mode and clear topic bindings\n" + " /topic <id> Inside a topic: restore a previous session by ID\n" + "\n" + "How it works:\n" + "1. Run /topic once in this DM — Hermes checks BotFather Threads\n" + " Settings are enabled and flips on multi-session mode.\n" + "2. Tap All Messages at the top of the bot and send any message.\n" + " Telegram creates a new topic for that message; each topic is\n" + " an independent Hermes session (fresh history, fresh context).\n" + "3. The root DM becomes a system lobby — send /topic, /status,\n" + " /help, /usage there. Normal prompts go in a topic.\n" + "4. /new inside a topic resets just that topic's session.\n" + "5. /topic <id> inside a topic restores an old session into it." + ) + + def _disable_telegram_topic_mode_for_chat(self, source: SessionSource) -> str: + """Cleanly disable topic mode for a chat via /topic off.""" + if not self._session_db: + return "Session database not available." + chat_id = str(source.chat_id or "") + if not chat_id: + return "Could not determine chat ID." + # No-op if never enabled. + try: + currently_enabled = self._session_db.is_telegram_topic_mode_enabled( + chat_id=chat_id, + user_id=str(source.user_id or ""), + ) + except Exception: + currently_enabled = False + if not currently_enabled: + return "Multi-session topic mode is not currently enabled for this chat." + try: + self._session_db.disable_telegram_topic_mode(chat_id=chat_id) + except Exception as exc: + logger.exception("Failed to disable Telegram topic mode") + return f"Failed to disable topic mode: {exc}" + # Reset per-chat debounce state so the user doesn't see a stale + # cooldown on the next activation. + for attr in ("_telegram_lobby_reminder_ts", "_telegram_capability_hint_ts"): + store = getattr(self, attr, None) + if isinstance(store, dict): + store.pop(chat_id, None) + return ( + "Multi-session topic mode is now OFF for this chat.\n\n" + "Existing topics in Telegram aren't removed — they'll just stop " + "being gated as independent sessions. The root DM works as a " + "normal Hermes chat again. Run /topic to re-enable later." + ) + async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str: """Handle /topic for Telegram DM user-managed topic sessions.""" source = event.source @@ -9846,7 +9924,28 @@ class GatewayRunner: if not self._session_db: return "Session database not available." + # 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 "You are not authorized to use /topic on this bot." + 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 self._disable_telegram_topic_mode_for_chat(source) + if args: if not source.thread_id: return ( @@ -9859,7 +9958,10 @@ class GatewayRunner: capabilities = await self._get_telegram_topic_capabilities(source) if capabilities.get("checked"): if capabilities.get("has_topics_enabled") is False: - await self._send_telegram_topic_setup_image(source) + # 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 ( "Telegram topics are not enabled for this bot yet.\n\n" "How to enable them:\n" @@ -9870,7 +9972,8 @@ class GatewayRunner: "Then send /topic again." ) if capabilities.get("allows_users_to_create_topics") is False: - await self._send_telegram_topic_setup_image(source) + if self._should_send_telegram_capability_hint(source): + await self._send_telegram_topic_setup_image(source) return ( "Telegram topics are enabled, but users are not allowed to create topics.\n\n" "Open @BotFather → choose your bot → Bot Settings → Threads Settings, " diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cc2365c90dc..2cf2c3e9f40 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -66,7 +66,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",), args_hint="[name]"), CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session", - gateway_only=True, args_hint="[session-id]"), + gateway_only=True, args_hint="[off|help|session-id]"), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", diff --git a/hermes_state.py b/hermes_state.py index 7d1a7d03a7b..98bd68bee56 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2297,6 +2297,39 @@ class SessionDB: ) self._execute_write(_do) + def disable_telegram_topic_mode( + self, + *, + chat_id: str, + clear_bindings: bool = True, + ) -> None: + """Disable Telegram DM topic mode for one private chat. + + When ``clear_bindings`` is True (default) the (chat_id, thread_id) + bindings for this chat are also cleared so re-enabling later + starts from a clean slate. Set to False if the operator wants to + preserve bindings for a later re-enable. + + Never creates the topic-mode tables from scratch; if they don't + exist there is nothing to disable and the call is a no-op. + """ + def _do(conn): + try: + conn.execute( + "UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? " + "WHERE chat_id = ?", + (time.time(), str(chat_id)), + ) + if clear_bindings: + conn.execute( + "DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?", + (str(chat_id),), + ) + except sqlite3.OperationalError: + # Tables don't exist yet — nothing to disable. + return + self._execute_write(_do) + def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool: """Return whether Telegram DM topic mode is enabled for this chat/user.""" with self._lock: diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 665cff03fd8..bfa92b4fd0a 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -983,3 +983,133 @@ def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path): "SELECT value FROM state_meta WHERE key = 'telegram_dm_topic_schema_version'" ).fetchone() assert version is not None and version[0] == "2" + + +@pytest.mark.asyncio +async def test_topic_help_subcommand_returns_usage(tmp_path): + """/topic help surfaces usage without activating anything.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + result = await runner._handle_topic_command(_make_event("/topic help")) + + assert "/topic help" in result + assert "/topic off" in result + assert "/topic <id>" in result + # No side effects — topic mode tables should not even exist yet. + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'" + ).fetchall() + } + assert tables == set() + + +@pytest.mark.asyncio +async def test_topic_off_disables_mode_and_clears_bindings(tmp_path, monkeypatch): + """/topic off flips the row off AND deletes bindings for this chat.""" + import gateway.run as gateway_run + + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + db.create_session(session_id="topic-sess", source="telegram", user_id="208214988") + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="k", + session_id="topic-sess", + ) + runner = _make_runner(session_db=db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_topic_command(_make_event("/topic off")) + + assert "OFF" in result or "off" in result + assert db.is_telegram_topic_mode_enabled( + chat_id="208214988", user_id="208214988" + ) is False + # Bindings cleared. + assert db.get_telegram_topic_binding( + chat_id="208214988", thread_id="17585" + ) is None + + +@pytest.mark.asyncio +async def test_topic_off_is_idempotent_when_never_enabled(tmp_path): + """/topic off against a chat that never ran /topic is a no-op message.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + result = await runner._handle_topic_command(_make_event("/topic off")) + + assert "not currently enabled" in result + + +@pytest.mark.asyncio +async def test_topic_refuses_unauthorized_user(tmp_path, monkeypatch): + """Unauthorized DMs cannot flip multi-session mode on.""" + import gateway.run as gateway_run + + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + runner._is_user_authorized = lambda _source: False # Deny + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_topic_command(_make_event("/topic")) + + assert "not authorized" in result.lower() + # Tables must not be created for an unauthorized caller. + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'" + ).fetchall() + } + assert tables == set() + + +def test_capability_hint_is_debounced_per_chat(tmp_path): + """BotFather screenshot is sent once per cooldown window per chat.""" + db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=db) + + source = _make_source() + assert runner._should_send_telegram_capability_hint(source) is True + assert runner._should_send_telegram_capability_hint(source) is False + assert runner._should_send_telegram_capability_hint(source) is False + + from dataclasses import replace + other = replace(source, chat_id="999999999") + assert runner._should_send_telegram_capability_hint(other) is True + + +def test_topic_off_resets_debounce_counters(tmp_path): + """Disabling topic mode clears per-chat debounce state.""" + db = SessionDB(db_path=tmp_path / "state.db") + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=db) + + source = _make_source() + # Prime the debounce counters. + assert runner._should_send_telegram_lobby_reminder(source) is True + assert runner._should_send_telegram_capability_hint(source) is True + assert runner._should_send_telegram_lobby_reminder(source) is False + assert runner._should_send_telegram_capability_hint(source) is False + + # /topic off resets them. + result = runner._disable_telegram_topic_mode_for_chat(source) + assert "OFF" in result or "off" in result + + # Re-enable and verify counters reset (so the first reminder/hint + # after re-enabling can land immediately). + db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + assert runner._should_send_telegram_lobby_reminder(source) is True + assert runner._should_send_telegram_capability_hint(source) is True diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index c96a6986d55..75158d6a0d9 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -145,7 +145,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/undo` | Remove the last exchange. | | `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. | | `/compress [focus topic]` | Manually compress conversation context. Optional focus topic narrows what the summary preserves. | -| `/topic [session-id]` | **Telegram DM only.** Enable or inspect user-managed multi-session topic mode. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). | +| `/topic [off\|help\|session-id]` | **Telegram DM only.** Manage user-managed multi-session topic mode. `/topic` enables it or shows status; `/topic off` disables it and clears bindings; `/topic help` shows usage; `/topic <session-id>` inside a topic restores a previous session. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). | | `/title [name]` | Set or show the session title. | | `/resume [name]` | Resume a previously named session. | | `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. | diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 6a572805bf9..eab5212241a 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -400,6 +400,19 @@ Topics created outside of the config (e.g., by manually calling the Telegram API A ChatGPT-style multi-session DM — one bot, many parallel conversations. Unlike the operator-curated `extra.dm_topics` above, this mode is **user-driven**: no config, no pre-declared topic names. The end user flips it on with `/topic`, then taps the Telegram **+** button to create as many topics as they want, each one a fully independent Hermes session. +### `/topic` subcommands + +| Form | Context | Effect | +|------|---------|--------| +| `/topic` | Root DM, not yet enabled | Check BotFather capabilities, enable multi-session mode, create pinned System topic | +| `/topic` | Root DM, already enabled | Show status: unlinked sessions available for restore | +| `/topic` | Inside a topic | Show the current topic's session binding | +| `/topic help` | Any | Inline usage | +| `/topic off` | Root DM | Disable multi-session mode and clear all topic bindings for this chat | +| `/topic <session-id>` | Inside a topic | Restore a previous Telegram session into the current topic | + +Only authorized users (allowlist via `TELEGRAM_ALLOWED_USERS` / platform auth config) can run `/topic`. An unauthorized sender gets a refusal instead of activation. + ### DM Topics vs Multi-session DM mode | | `extra.dm_topics` (config-driven) | `/topic` (user-driven) | @@ -487,19 +500,22 @@ Shows the current topic's binding: session title, session ID, and hints for `/ne - Topics declared in `extra.dm_topics` are **never auto-renamed** — the operator-chosen name is preserved even when multi-session mode is enabled - The General (pinned top) topic in a forum-enabled DM is treated as the root lobby, regardless of whether Telegram delivers its messages with `message_thread_id=1` or with no thread_id - Root-lobby reminders are rate-limited to one message per 30 seconds per chat — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies +- BotFather setup screenshots are rate-limited to one send per 5 minutes per chat — repeated `/topic` attempts while Threads Settings are still disabled won't re-upload the same image - `/background <prompt>` started inside a topic delivers its result back to the same topic; background sessions don't trigger auto-rename of the owning topic +- `/topic` itself is gated by the bot's user authorization check — unauthorized DMs get a refusal instead of activation ### Disabling multi-session mode -There is no slash command to exit multi-session mode. If you need to turn it off, remove the row manually: +Send `/topic off` in the root DM. Hermes flips the row off, clears the chat's `(thread_id → session_id)` bindings, and the root DM reverts to a normal Hermes chat. Existing topics in Telegram aren't deleted — they just stop being gated as independent sessions. Re-run `/topic` later to turn it back on. + +If you need to clean up by hand (e.g. a bulk reset across many chats), remove the rows directly: ```bash sqlite3 ~/.hermes/state.db \ - "DELETE FROM telegram_dm_topic_mode WHERE chat_id = '<your_chat_id>'" + "UPDATE telegram_dm_topic_mode SET enabled = 0 WHERE chat_id = '<your_chat_id>'; \ + DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_chat_id>';" ``` -Existing topics in Telegram won't disappear — they'll just stop being gated as independent sessions on the Hermes side. The binding rows can also be cleared with `DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_chat_id>'`. - ### Downgrading Hermes If you downgrade to a Hermes version that predates `/topic`, the feature simply stops working — the `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` tables remain in `state.db` but are ignored by older code. DMs revert to the native per-thread isolation (each `message_thread_id` still gets its own session via `build_session_key`), so your existing Telegram topics keep working as parallel sessions. The root DM is no longer a lobby — messages there go into the agent like they used to. Re-upgrading reactivates multi-session mode exactly where it was. From 3db6b9cc871c6f1c588cccba1ff2bd09601c1b77 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:31:01 -0700 Subject: [PATCH 07/28] feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) (#19709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern) Adds a no_agent=True option to the cronjob system. When enabled, the scheduler runs the attached script on schedule and delivers its stdout directly to the job's target — no LLM, no agent loop, no token spend. This is the classic bash-watchdog pattern (memory alert every 5 min, disk alert every 15 min, CI ping) reimplemented as a first-class Hermes primitive instead of a systemd timer + curl + bot token triplet living outside the system. ## What hermes cron create "every 5m" \ --no-agent \ --script memory-watchdog.sh \ --deliver telegram \ --name memory-watchdog Agent tool: cronjob(action='create', schedule='every 5m', script='memory-watchdog.sh', no_agent=True, deliver='telegram') Semantics: - Script stdout (trimmed) → delivered verbatim as the message - Empty stdout → silent tick (no delivery; watchdog pattern) - wakeAgent=false gate → silent tick (same gate LLM jobs use) - Non-zero exit/timeout → delivered as an error alert (broken watchdogs shouldn't fail silently) - No LLM ever invoked; no tokens spent; no provider fallback applied ## Implementation cron/jobs.py * create_job gains no_agent: bool = False * prompt becomes Optional (no_agent jobs don't need one) * Validation: no_agent=True requires a script at create time * Field roundtrips via load_jobs / save_jobs / update_job cron/scheduler.py * run_job: new short-circuit branch at the top that runs the script, wraps its output into the (success, doc, final_response, error) tuple downstream delivery already expects, and returns before any AIAgent import or construction * _run_job_script: picks interpreter by extension — .sh/.bash run under /bin/bash, anything else under sys.executable (Python). Shell support unlocks the bash-watchdog pattern without wrapping scripts in Python. Extension is explicit; we deliberately do NOT trust the file's own shebang. Path-containment guard (scripts dir) unchanged. tools/cronjob_tools.py * Schema: new no_agent boolean property with clear trigger guidance * cronjob() accepts no_agent and validates mode-specific shape: - no_agent=True requires script; prompt/skills optional - no_agent=False keeps the existing 'prompt or skill required' rule * update path rejects flipping no_agent=True on a job without a script * _format_job surfaces no_agent in list output * Handler lambda forwards no_agent from tool args hermes_cli/main.py, hermes_cli/cron.py * 'hermes cron create --no-agent' and edit's --no-agent / --agent pair for toggling at CLI parity with the agent tool * Existing --script help text updated to describe both modes * List / create / edit output now shows 'Mode: no-agent (...)' when set ## Tests tests/cron/test_cron_no_agent.py — 18 tests covering: * create_job: no_agent shape, validation, field persistence * update_job: flag roundtrip across reload * cronjob tool: schema validation, update toggling, mode-specific requirements, prompt-relaxation rule * run_job short-circuit: - success path delivers stdout verbatim - empty stdout → SILENT_MARKER (no delivery downstream) - wakeAgent=false gate → silent - script failure → error alert - run_job does NOT import AIAgent (verified via mock) * _run_job_script: - .sh executes via bash (no shebang required) - .bash executes via bash - .py still runs via sys.executable (regression) - path-traversal still blocked (security regression) All 18 new tests pass. 341/342 pre-existing cron tests still pass; the one failure (test_script_empty_output_noted) was already broken on main and is unrelated to this change. ## Docs website/docs/guides/cron-script-only.md — new dedicated guide covering the watchdog pattern, interpreter rules, delivery mapping, worked examples (memory / disk alerts), and the comparison table vs hermes send, regular LLM cron jobs, and OS-level cron. website/docs/user-guide/features/cron.md — new 'No-agent mode' section in the cron feature reference, cross-linked to the guide. website/docs/guides/automate-with-cron.md — new tip box pointing users to no-agent mode when they don't need LLM reasoning. ## Compatibility - Existing jobs: unchanged. no_agent defaults to False, existing code paths untouched until the flag is set. - Schema additive only; older jobs.json without the field load fine via .get() with False default. - New CLI flags are opt-in and don't alter existing flag behavior. * fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero The unconditional `from run_agent import AIAgent` + SessionDB() init at the top of run_job() meant every no_agent tick still paid the full agent module load cost (~300ms + transitive imports + DB open) even though it never touched any of that machinery. Move both to live under the default (LLM) path, after the no_agent short-circuit has returned. Now a no_agent tick's sys.modules stays clean — verified end-to-end: assert 'run_agent' not in sys.modules # before run_job(no_agent_job) assert 'run_agent' not in sys.modules # after The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent) kept passing because patch() replaces the class AFTER import; the leak was only visible via real subprocess-style verification. End-to-end demo confirmed: agent calls cronjob(no_agent=True) → script runs → stdout delivered → no LLM machinery loaded. * docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule Previous description buried the important bits in one long sentence. Agents could plausibly miss three things an LLM-facing schema should make unmissable: 1. What the default is — now first sentence + JSON Schema `default: false` 2. What 'silent run' actually means for the user — now spelled out: 'nothing is sent to the user and they won't see anything happened' 3. When to pick True vs False — now a concrete decision rule with examples on both sides (watchdogs/metrics/pollers → True; summarize/draft/pick/rephrase → False) Also adds explicit 'prompt and skills are ignored when True' since the agent could otherwise still pass them out of habit. No behavior change — schema text only. --- cron/jobs.py | 37 ++- cron/scheduler.py | 141 ++++++++- hermes_cli/cron.py | 8 + hermes_cli/main.py | 43 ++- tests/cron/test_cron_no_agent.py | 332 ++++++++++++++++++++++ tools/cronjob_tools.py | 57 +++- website/docs/guides/automate-with-cron.md | 4 + website/docs/guides/cron-script-only.md | 194 +++++++++++++ website/docs/user-guide/features/cron.md | 24 ++ 9 files changed, 823 insertions(+), 17 deletions(-) create mode 100644 tests/cron/test_cron_no_agent.py create mode 100644 website/docs/guides/cron-script-only.md diff --git a/cron/jobs.py b/cron/jobs.py index 5e493ae3f7a..93098bd86be 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -420,7 +420,7 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: def create_job( - prompt: str, + prompt: Optional[str], schedule: str, name: Optional[str] = None, repeat: Optional[int] = None, @@ -435,12 +435,14 @@ def create_job( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + no_agent: bool = False, ) -> Dict[str, Any]: """ Create a new cron job. Args: - prompt: The prompt to run (must be self-contained, or a task instruction when skill is set) + prompt: The prompt to run (must be self-contained, or a task instruction when skill is set). + Ignored when ``no_agent=True`` except as an optional name hint. schedule: Schedule string (see parse_schedule) name: Optional friendly name repeat: How many times to run (None = forever, 1 = once) @@ -451,21 +453,33 @@ def create_job( model: Optional per-job model override provider: Optional per-job provider override base_url: Optional per-job base URL override - script: Optional path to a Python script whose stdout is injected into the - prompt each run. The script runs before the agent turn, and its output - is prepended as context. Useful for data collection / change detection. + script: Optional path to a script whose stdout feeds the job. With + ``no_agent=True`` the script IS the job — its stdout is + delivered verbatim. Without ``no_agent``, its stdout is + injected into the agent's prompt as context (data-collection / + change-detection pattern). Paths resolve under + ~/.hermes/scripts/; ``.sh`` / ``.bash`` files run via bash, + anything else via Python. context_from: Optional job ID (or list of job IDs) whose most recent output is injected into the prompt as context before each run. Useful for chaining cron jobs: job A finds data, job B processes it. enabled_toolsets: Optional list of toolset names to restrict the agent to. When set, only tools from these toolsets are loaded, reducing token overhead. When omitted, all default tools are loaded. + Ignored when ``no_agent=True``. workdir: Optional absolute path. When set, the job runs as if launched from that directory: AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory (via TERMINAL_CWD). When unset, the old behaviour is preserved (no context files injected, tools use the scheduler's cwd). + With ``no_agent=True``, ``workdir`` is still applied as the + script's cwd so relative paths inside the script behave + predictably. + no_agent: When True, skip the agent entirely — run ``script`` on schedule + and deliver its stdout directly. Empty stdout = silent (no + delivery). Requires ``script`` to be set. Ideal for classic + watchdogs and periodic alerts that don't need LLM reasoning. Returns: The created job dict @@ -499,6 +513,16 @@ def create_job( normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) + normalized_no_agent = bool(no_agent) + + # no_agent jobs are meaningless without a script — the script IS the job. + # Surface this as a clear ValueError at create time so bad configs never + # reach the scheduler. + if normalized_no_agent and not normalized_script: + raise ValueError( + "no_agent=True requires a script — with no agent and no script " + "there is nothing for the job to run." + ) # Normalize context_from: accept str or list of str, store as list or None if isinstance(context_from, str): @@ -508,7 +532,7 @@ def create_job( else: context_from = None - label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" + label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job" job = { "id": job_id, "name": name or label_source[:50].strip(), @@ -519,6 +543,7 @@ def create_job( "provider": normalized_provider, "base_url": normalized_base_url, "script": normalized_script, + "no_agent": normalized_no_agent, "context_from": context_from, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), diff --git a/cron/scheduler.py b/cron/scheduler.py index cee1cb40672..81e256a3295 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -576,8 +576,18 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: prevent arbitrary script execution via path traversal or absolute path injection. + Supported interpreters (chosen by file extension): + + * ``.sh`` / ``.bash`` — run with ``/bin/bash`` + * anything else — run with the current Python interpreter + (``sys.executable``), preserving the original behaviour for + Python-based pre-check and data-collection scripts. + + Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs + (the `memory-watchdog.sh` pattern) without wrapping them in Python. + Args: - script_path: Path to a Python script. Relative paths are resolved + script_path: Path to the script. Relative paths are resolved against HERMES_HOME/scripts/. Absolute and ~-prefixed paths are also validated to ensure they stay within the scripts dir. @@ -614,9 +624,19 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: script_timeout = _get_script_timeout() + # Pick an interpreter by extension. Bash for .sh/.bash, Python for + # everything else. We deliberately do NOT honour the file's own + # shebang: the scripts dir is trusted, but keeping the interpreter + # choice explicit here keeps the allowed surface small and auditable. + suffix = path.suffix.lower() + if suffix in (".sh", ".bash"): + argv = ["/bin/bash", str(path)] + else: + argv = [sys.executable, str(path)] + try: result = subprocess.run( - [sys.executable, str(path)], + argv, capture_output=True, text=True, timeout=script_timeout, @@ -830,8 +850,120 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: Returns: Tuple of (success, full_output_doc, final_response, error_message) """ + job_id = job["id"] + job_name = job["name"] + + # --------------------------------------------------------------- + # no_agent short-circuit — the script IS the job, no LLM involvement. + # --------------------------------------------------------------- + # This mirrors the classic "run a bash script on a timer, send its + # stdout to telegram" watchdog pattern. The agent path is skipped + # entirely: no AIAgent, no prompt, no tool loop, no token spend. + # + # We check this BEFORE importing run_agent / constructing SessionDB so + # a pure-script tick never pays for the agent machinery it isn't going + # to use. Keep this block self-contained. + # + # Semantics: + # - script stdout (trimmed) → delivered verbatim as the final message + # - empty stdout → silent run (no delivery, success=True) + # - non-zero exit / timeout → delivered as an error alert, success=False + # - wakeAgent=false gate → treated like empty stdout (silent), since + # the whole point of no_agent is that there + # is no agent to wake + if job.get("no_agent"): + script_path = job.get("script") + if not script_path: + err = "no_agent=True but no script is set for this job" + logger.error("Job '%s': %s", job_id, err) + return False, "", "", err + + # Apply workdir if configured — lets scripts use predictable relative + # paths. For no_agent jobs this is just the subprocess cwd (not an + # agent TERMINAL_CWD bridge). + _job_workdir = (job.get("workdir") or "").strip() or None + _prior_cwd = None + if _job_workdir and Path(_job_workdir).is_dir(): + _prior_cwd = os.getcwd() + try: + os.chdir(_job_workdir) + except OSError: + _prior_cwd = None + + try: + ok, output = _run_job_script(script_path) + finally: + if _prior_cwd is not None: + try: + os.chdir(_prior_cwd) + except OSError: + pass + + now_iso = _hermes_now().strftime("%Y-%m-%d %H:%M:%S") + + if not ok: + # Script crashed / timed out / exited non-zero. Deliver the + # error so the user knows the watchdog itself broke — silent + # failure for an alerting job is the worst-case outcome. + alert = ( + f"⚠ Cron watchdog '{job_name}' script failed\n\n" + f"{output}\n\n" + f"Time: {now_iso}" + ) + doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** script failed\n\n" + f"{output}\n" + ) + return False, doc, alert, output + + # Honour the wakeAgent gate as a silent signal — `wakeAgent: false` + # means "nothing to report this tick", same as empty stdout. + if not _parse_wake_gate(output): + logger.info( + "Job '%s' (no_agent): wakeAgent=false gate — silent run", job_id + ) + silent_doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** silent (wakeAgent=false)\n" + ) + return True, silent_doc, SILENT_MARKER, None + + if not output.strip(): + logger.info("Job '%s' (no_agent): empty stdout — silent run", job_id) + silent_doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n" + f"**Status:** silent (empty output)\n" + ) + return True, silent_doc, SILENT_MARKER, None + + doc = ( + f"# Cron Job: {job_name}\n\n" + f"**Job ID:** {job_id}\n" + f"**Run Time:** {now_iso}\n" + f"**Mode:** no_agent (script)\n\n" + f"---\n\n" + f"{output}\n" + ) + return True, doc, output, None + + # --------------------------------------------------------------- + # Default (LLM) path — import and construct the agent machinery now + # that we know we actually need it. Doing these imports here instead of + # at module top keeps no_agent ticks from paying for AIAgent / SessionDB + # construction costs. + # --------------------------------------------------------------- from run_agent import AIAgent - + # Initialize SQLite session store so cron job messages are persisted # and discoverable via session_search (same pattern as gateway/run.py). _session_db = None @@ -840,9 +972,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: _session_db = SessionDB() except Exception as e: logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e) - - job_id = job["id"] - job_name = job["name"] # Wake-gate: if this job has a pre-check script, run it BEFORE building # the prompt so a ``{"wakeAgent": false}`` response can short-circuit diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 78639d465a5..adf4f0c0927 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -93,6 +93,8 @@ def cron_list(show_all: bool = False): script = job.get("script") if script: print(f" Script: {script}") + if job.get("no_agent"): + print(f" Mode: {color('no-agent', Colors.DIM)} (script stdout delivered directly)") workdir = job.get("workdir") if workdir: print(f" Workdir: {workdir}") @@ -172,6 +174,7 @@ def cron_create(args): skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + no_agent=getattr(args, "no_agent", False) or None, ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -184,6 +187,8 @@ def cron_create(args): job_data = result.get("job", {}) if job_data.get("script"): print(f" Script: {job_data['script']}") + if job_data.get("no_agent"): + print(" Mode: no-agent (script stdout delivered directly)") if job_data.get("workdir"): print(f" Workdir: {job_data['workdir']}") print(f" Next run: {result['next_run_at']}") @@ -225,6 +230,7 @@ def cron_edit(args): skills=final_skills, script=getattr(args, "script", None), workdir=getattr(args, "workdir", None), + no_agent=getattr(args, "no_agent", None), ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -240,6 +246,8 @@ def cron_edit(args): print(" Skills: none") if updated.get("script"): print(f" Script: {updated['script']}") + if updated.get("no_agent"): + print(" Mode: no-agent (script stdout delivered directly)") if updated.get("workdir"): print(f" Workdir: {updated['workdir']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4f15cd26d59..ac7da65a23e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8678,7 +8678,24 @@ def main(): ) cron_create.add_argument( "--script", - help="Path to a Python script whose stdout is injected into the prompt each run", + help=( + "Path to a script under ~/.hermes/scripts/. Default mode: " + "script stdout is injected into the agent's prompt each run. " + "With --no-agent: the script IS the job and its stdout is " + "delivered verbatim. .sh/.bash files run via bash, everything " + "else via Python." + ), + ) + cron_create.add_argument( + "--no-agent", + dest="no_agent", + action="store_true", + default=False, + help=( + "Skip the LLM entirely — run --script on schedule and deliver " + "its stdout directly. Empty stdout = silent. Classic watchdog " + "pattern (memory alerts, disk alerts, CI pings)." + ), ) cron_create.add_argument( "--workdir", @@ -8720,7 +8737,29 @@ def main(): ) cron_edit.add_argument( "--script", - help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.", + help=( + "Path to a script under ~/.hermes/scripts/. Pass empty string to clear. " + "With --no-agent the script IS the job; otherwise its stdout is " + "injected into the agent's prompt each run." + ), + ) + cron_edit.add_argument( + "--no-agent", + dest="no_agent", + action="store_const", + const=True, + default=None, + help=( + "Enable no-agent mode on this job (requires --script or an " + "existing script on the job)." + ), + ) + cron_edit.add_argument( + "--agent", + dest="no_agent", + action="store_const", + const=False, + help="Disable no-agent mode on this job (reverts to LLM-driven execution).", ) cron_edit.add_argument( "--workdir", diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py new file mode 100644 index 00000000000..117cb8c7d9a --- /dev/null +++ b/tests/cron/test_cron_no_agent.py @@ -0,0 +1,332 @@ +"""Tests for cronjob no_agent mode — script-driven jobs that skip the LLM. + +Covers: + +* ``create_job(no_agent=True)`` shape, validation, and serialization. +* ``cronjob(action='create', no_agent=True)`` tool-level validation. +* ``cronjob(action='update')`` flipping no_agent on/off. +* ``scheduler.run_job`` short-circuit path: success/silent/failure. +* Shell script support in ``_run_job_script`` (.sh runs via bash). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def hermes_env(tmp_path, monkeypatch): + """Isolate HERMES_HOME for each test so jobs/scripts don't leak.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "scripts").mkdir() + (home / "cron").mkdir() + + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Reload modules that cache get_hermes_home() at import time. + import importlib + import hermes_constants + importlib.reload(hermes_constants) + import cron.jobs + importlib.reload(cron.jobs) + import cron.scheduler + importlib.reload(cron.scheduler) + + return home + + +# --------------------------------------------------------------------------- +# create_job / update_job: data-layer semantics +# --------------------------------------------------------------------------- + + +def test_create_job_no_agent_requires_script(hermes_env): + from cron.jobs import create_job + + with pytest.raises(ValueError, match="no_agent=True requires a script"): + create_job(prompt=None, schedule="every 5m", no_agent=True) + + +def test_create_job_no_agent_stores_field(hermes_env): + from cron.jobs import create_job + + script_path = hermes_env / "scripts" / "watchdog.sh" + script_path.write_text("#!/bin/bash\necho hi\n") + + job = create_job( + prompt=None, + schedule="every 5m", + script="watchdog.sh", + no_agent=True, + deliver="local", + ) + assert job["no_agent"] is True + assert job["script"] == "watchdog.sh" + # Prompt can be empty/None for no_agent jobs. + assert job["prompt"] in (None, "") + + +def test_create_job_default_is_not_no_agent(hermes_env): + from cron.jobs import create_job + + job = create_job(prompt="say hi", schedule="every 5m", deliver="local") + assert job.get("no_agent") is False + + +def test_update_job_roundtrips_no_agent_flag(hermes_env): + from cron.jobs import create_job, update_job, get_job + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + job = create_job(prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local") + + update_job(job["id"], {"no_agent": False}) + reloaded = get_job(job["id"]) + assert reloaded["no_agent"] is False + + update_job(job["id"], {"no_agent": True}) + reloaded = get_job(job["id"]) + assert reloaded["no_agent"] is True + + +# --------------------------------------------------------------------------- +# cronjob tool: API-layer validation +# --------------------------------------------------------------------------- + + +def test_cronjob_tool_create_no_agent_without_script_errors(hermes_env): + from tools.cronjob_tools import cronjob + + result = json.loads( + cronjob(action="create", schedule="every 5m", no_agent=True, deliver="local") + ) + assert result.get("success") is False + assert "no_agent=True requires a script" in result.get("error", "") + + +def test_cronjob_tool_create_no_agent_with_script_succeeds(hermes_env): + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho alert\n") + + result = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="alert.sh", + no_agent=True, + deliver="local", + ) + ) + assert result.get("success") is True + assert result["job"]["no_agent"] is True + assert result["job"]["script"] == "alert.sh" + + +def test_cronjob_tool_update_toggles_no_agent(hermes_env): + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + + created = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="w.sh", + no_agent=True, + deliver="local", + ) + ) + job_id = created["job_id"] + + off = json.loads(cronjob(action="update", job_id=job_id, no_agent=False, prompt="run")) + assert off["success"] is True + assert off["job"].get("no_agent") in (False, None) + + on = json.loads(cronjob(action="update", job_id=job_id, no_agent=True)) + assert on["success"] is True + assert on["job"]["no_agent"] is True + + +def test_cronjob_tool_update_no_agent_without_script_errors(hermes_env): + """Flipping no_agent=True on a job that has no script must fail.""" + from tools.cronjob_tools import cronjob + + created = json.loads( + cronjob(action="create", schedule="every 5m", prompt="do a thing", deliver="local") + ) + job_id = created["job_id"] + + result = json.loads(cronjob(action="update", job_id=job_id, no_agent=True)) + assert result.get("success") is False + assert "without a script" in result.get("error", "") + + +def test_cronjob_tool_create_does_not_require_prompt_when_no_agent(hermes_env): + """The 'prompt or skill required' rule is relaxed for no_agent jobs.""" + from tools.cronjob_tools import cronjob + + script_path = hermes_env / "scripts" / "w.sh" + script_path.write_text("echo hi\n") + + result = json.loads( + cronjob( + action="create", + schedule="every 5m", + script="w.sh", + no_agent=True, + deliver="local", + ) + ) + assert result.get("success") is True + + +# --------------------------------------------------------------------------- +# scheduler.run_job: short-circuit behavior +# --------------------------------------------------------------------------- + + +def test_run_job_no_agent_success_returns_script_stdout(hermes_env): + """Happy path: script exits 0 with output, delivered verbatim.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert error is None + assert "RAM 92% on host" in final_response + assert "RAM 92% on host" in doc + + +def test_run_job_no_agent_empty_output_is_silent(hermes_env): + """Empty stdout → SILENT_MARKER, which suppresses delivery downstream.""" + from cron.jobs import create_job + from cron.scheduler import run_job, SILENT_MARKER + + script_path = hermes_env / "scripts" / "quiet.sh" + script_path.write_text("#!/bin/bash\n# nothing to say\n") + + job = create_job( + prompt=None, schedule="every 5m", script="quiet.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert error is None + assert final_response == SILENT_MARKER + + +def test_run_job_no_agent_wake_gate_is_silent(hermes_env): + """wakeAgent=false gate in stdout triggers a silent run.""" + from cron.jobs import create_job + from cron.scheduler import run_job, SILENT_MARKER + + script_path = hermes_env / "scripts" / "gated.sh" + script_path.write_text('#!/bin/bash\necho \'{"wakeAgent": false}\'\n') + + job = create_job( + prompt=None, schedule="every 5m", script="gated.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is True + assert final_response == SILENT_MARKER + + +def test_run_job_no_agent_script_failure_delivers_error(hermes_env): + """Non-zero exit → success=False, error alert is the delivered message.""" + from cron.jobs import create_job + from cron.scheduler import run_job + + script_path = hermes_env / "scripts" / "broken.sh" + script_path.write_text("#!/bin/bash\necho oops >&2\nexit 3\n") + + job = create_job( + prompt=None, schedule="every 5m", script="broken.sh", no_agent=True, deliver="local" + ) + success, doc, final_response, error = run_job(job) + assert success is False + assert error is not None + assert "oops" in final_response or "exited with code 3" in final_response + assert "Cron watchdog" in final_response # alert header + + +def test_run_job_no_agent_never_invokes_aiagent(hermes_env): + """no_agent jobs must NOT import/construct the AIAgent.""" + from cron.jobs import create_job + + script_path = hermes_env / "scripts" / "alert.sh" + script_path.write_text("#!/bin/bash\necho alert\n") + + job = create_job( + prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local" + ) + + with patch("run_agent.AIAgent") as ai_mock: + from cron.scheduler import run_job + + run_job(job) + + ai_mock.assert_not_called() + + +# --------------------------------------------------------------------------- +# _run_job_script: shell-script support +# --------------------------------------------------------------------------- + + +def test_run_job_script_shell_script_runs_via_bash(hermes_env): + """.sh files should execute under /bin/bash even without a shebang line.""" + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "shelly.sh" + # No shebang — relies on the interpreter-by-extension rule. + script_path.write_text('echo "shell: $BASH_VERSION" | head -c 7\n') + + ok, output = _run_job_script("shelly.sh") + assert ok is True + assert output.startswith("shell:") + + +def test_run_job_script_bash_extension_also_runs_via_bash(hermes_env): + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "thing.bash" + script_path.write_text('printf "via bash\\n"\n') + + ok, output = _run_job_script("thing.bash") + assert ok is True + assert output == "via bash" + + +def test_run_job_script_python_still_runs_via_python(hermes_env): + """Regression: .py files must keep running via sys.executable.""" + from cron.scheduler import _run_job_script + + script_path = hermes_env / "scripts" / "py.py" + script_path.write_text("import sys\nprint(f'python {sys.version_info.major}')\n") + + ok, output = _run_job_script("py.py") + assert ok is True + assert output.startswith("python ") + + +def test_run_job_script_path_traversal_still_blocked(hermes_env): + """Security regression: shell-script support must NOT loosen containment.""" + from cron.scheduler import _run_job_script + + # Absolute path outside the scripts dir should be rejected. + ok, output = _run_job_script("/etc/passwd") + assert ok is False + assert "Blocked" in output or "outside" in output diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index ec4b41b3c7c..5e9ffa51ead 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -245,6 +245,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: } if job.get("script"): result["script"] = job["script"] + if job.get("no_agent"): + result["no_agent"] = True if job.get("enabled_toolsets"): result["enabled_toolsets"] = job["enabled_toolsets"] if job.get("workdir"): @@ -271,6 +273,7 @@ def cronjob( context_from: Optional[Union[str, List[str]]] = None, enabled_toolsets: Optional[List[str]] = None, workdir: Optional[str] = None, + no_agent: Optional[bool] = None, task_id: str = None, ) -> str: """Unified cron job management tool.""" @@ -283,8 +286,22 @@ def cronjob( if not schedule: return tool_error("schedule is required for create", success=False) canonical_skills = _canonical_skills(skill, skills) - if not prompt and not canonical_skills: - return tool_error("create requires either prompt or at least one skill", success=False) + _no_agent = bool(no_agent) + # Job-shape validation differs by mode: + # - no_agent=True → script is the job; prompt/skills are optional + # (and irrelevant to execution). + # - no_agent=False (default) → at least one of prompt/skills must + # be set, same as before. + if _no_agent: + if not script: + return tool_error( + "create with no_agent=True requires a script — " + "the script is the job.", + success=False, + ) + else: + if not prompt and not canonical_skills: + return tool_error("create requires either prompt or at least one skill", success=False) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: @@ -323,6 +340,7 @@ def cronjob( context_from=context_from, enabled_toolsets=enabled_toolsets or None, workdir=_normalize_optional_job_value(workdir), + no_agent=_no_agent, ) return json.dumps( { @@ -436,6 +454,20 @@ def cronjob( # Empty string clears the field (restores old behaviour); # otherwise pass raw — update_job() validates / normalizes. updates["workdir"] = _normalize_optional_job_value(workdir) or None + if no_agent is not None: + # Toggling no_agent on/off at update time. If flipping to True, + # we need a script to already exist on the job (or be part of + # the same update) — otherwise the next tick would error out. + target_no_agent = bool(no_agent) + if target_no_agent: + effective_script = updates.get("script") if "script" in updates else job.get("script") + if not effective_script: + return tool_error( + "Cannot set no_agent=True on a job without a script. " + "Set `script` in the same update, or on the job first.", + success=False, + ) + updates["no_agent"] = target_no_agent if repeat is not None: # Normalize: treat 0 or negative as None (infinite) normalized_repeat = None if repeat <= 0 else repeat @@ -533,7 +565,25 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "script": { "type": "string", - "description": f"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear." + "description": f"Optional path to a script that runs each tick. In the default mode its stdout is injected into the agent's prompt as context (data-collection / change-detection pattern). With no_agent=True, the script IS the job and its stdout is delivered verbatim (classic watchdog pattern). Relative paths resolve under {display_hermes_home()}/scripts/. ``.sh``/``.bash`` extensions run via bash, everything else via Python. On update, pass empty string to clear." + }, + "no_agent": { + "type": "boolean", + "default": False, + "description": ( + "Default: False (LLM-driven job — the agent runs the prompt each tick). " + "Set True to skip the LLM entirely: the scheduler just runs ``script`` on schedule and delivers its stdout verbatim. No tokens, no agent loop, no model override honoured. " + "\n\n" + "REQUIREMENTS when True: ``script`` MUST be set (``prompt`` and ``skills`` are ignored). " + "\n\n" + "DELIVERY SEMANTICS when True: " + "(a) non-empty stdout is sent verbatim as the message; " + "(b) EMPTY stdout means SILENT — nothing is sent to the user and they won't see anything happened, so design your script to stay quiet when there's nothing to report (the watchdog pattern); " + "(c) non-zero exit / timeout sends an error alert so a broken watchdog can't fail silently. " + "\n\n" + "WHEN TO USE True: recurring script-only pings where the script itself produces the exact message text (memory/disk/GPU watchdogs, threshold alerts, heartbeats, CI notifications, API pollers with a fixed output shape). " + "WHEN TO USE False (default): anything that needs reasoning — summarize a feed, draft a daily briefing, pick interesting items, rephrase data for a human, follow conditional logic based on content." + ), }, "context_from": { "type": "array", @@ -604,6 +654,7 @@ registry.register( context_from=args.get("context_from"), enabled_toolsets=args.get("enabled_toolsets"), workdir=args.get("workdir"), + no_agent=args.get("no_agent"), task_id=kw.get("task_id"), ))(), check_fn=check_cronjob_requirements, diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md index b35897e8971..b47dae9378b 100644 --- a/website/docs/guides/automate-with-cron.md +++ b/website/docs/guides/automate-with-cron.md @@ -14,6 +14,10 @@ For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/fe Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know. ::: +:::tip Don't need the LLM? Use no-agent mode. +For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. +::: + --- ## Pattern 1: Website Change Monitor diff --git a/website/docs/guides/cron-script-only.md b/website/docs/guides/cron-script-only.md new file mode 100644 index 00000000000..67ab178d7a7 --- /dev/null +++ b/website/docs/guides/cron-script-only.md @@ -0,0 +1,194 @@ +--- +sidebar_position: 13 +title: "Script-Only Cron Jobs (No LLM)" +description: "Classic watchdog cron jobs that skip the LLM entirely — a script runs on schedule and its stdout gets delivered to your messaging platform. Memory alerts, disk alerts, CI pings, periodic health checks." +--- + +# Script-Only Cron Jobs + +Sometimes you already know exactly what message you want to send. You don't need an agent to reason about it — you just need a script to run on a timer, and its output (if any) to land in Telegram / Discord / Slack / Signal. + +Hermes calls this **no-agent mode**. It's the cron system minus the LLM. + +``` + ┌──────────────────┐ ┌──────────────────┐ + │ scheduler tick │ every │ run script │ + │ (every N minutes)│ ──────▶ │ (bash or python) │ + └──────────────────┘ └──────────────────┘ + │ + │ stdout + ▼ + ┌──────────────────┐ + │ delivery router │ + │ (telegram/disc…) │ + └──────────────────┘ +``` + +- **No LLM call.** Zero tokens, zero agent loop, zero model spend. +- **Script is the job.** The script decides whether to alert. Emit output → message gets sent. Emit nothing → silent tick. +- **Bash or Python.** `.sh` / `.bash` files run under `/bin/bash`; any other extension runs under the current Python interpreter. Anything in `~/.hermes/scripts/` is accepted. +- **Same scheduler.** Lives in `cronjob` alongside LLM jobs — pausing, resuming, listing, logs, and delivery targeting all work the same way. + +## When to Use It + +Use no-agent mode for: + +- **Memory / disk / GPU watchdogs.** Run every 5 minutes, alert only when a threshold is breached. +- **CI hooks.** Deploy finished → post the commit SHA. Build failed → send the last 100 lines of the log. +- **Periodic metrics.** "Daily Stripe revenue at 9am" as a simple API call + pretty-print. +- **External event pollers.** Check an API, alert on state change. +- **Heartbeats.** Ping a dashboard every N minutes to prove the host is alive. + +Use a normal (LLM-driven) cron job when you need the agent to **decide** what to say — summarize a long document, pick interesting items from a feed, draft a human-friendly message. The no-agent path is for cases where the script's stdout already IS the message. + +## Create One from the CLI + +```bash +# 1. Write your script +cat > ~/.hermes/scripts/memory-watchdog.sh <<'EOF' +#!/usr/bin/env bash +# Alert when RAM usage is over 85%. Silent otherwise. +RAM_PCT=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}') +if [ "$RAM_PCT" -ge 85 ]; then + echo "⚠ RAM ${RAM_PCT}% on $(hostname)" +fi +# Empty stdout = silent run; no message sent. +EOF +chmod +x ~/.hermes/scripts/memory-watchdog.sh + +# 2. Schedule it +hermes cron create "every 5m" \ + --no-agent \ + --script memory-watchdog.sh \ + --deliver telegram \ + --name "memory-watchdog" + +# 3. Verify +hermes cron list +hermes cron run <job_id> # fire it once to test +``` + +That's the whole thing. No prompt, no skill, no model. + +## Create One from Chat + +You can also ask the agent to set one up conversationally. The `cronjob` tool now accepts a `no_agent` parameter: + +> *"Ping me on Telegram if RAM is over 85%, every 5 minutes."* + +The agent will: + +1. Write the check script to `~/.hermes/scripts/` via `write_file`. +2. Call `cronjob(action='create', schedule='every 5m', script='memory-watchdog.sh', no_agent=true, deliver='telegram')`. + +This is the same scheduler the agent already uses for LLM-driven jobs; `no_agent=true` just picks the script-only code path. + +## How Script Output Maps to Delivery + +| Script behavior | Result | +|-----------------|--------| +| Exit 0, non-empty stdout | stdout is delivered verbatim | +| Exit 0, empty stdout | Silent tick — no delivery | +| Exit 0, stdout contains `{"wakeAgent": false}` on the last line | Silent tick (shared gate with LLM jobs) | +| Non-zero exit code | Error alert is delivered (so a broken watchdog doesn't fail silently) | +| Script timeout | Error alert is delivered | + +The "silent when empty" behavior is the key to the classic watchdog pattern: the script is free to run every minute, but the channel only sees a message when something actually needs attention. + +## Script Rules + +Scripts must live in `~/.hermes/scripts/`. This is enforced at both job-creation time and run time — absolute paths, `~/` expansion, and path-traversal patterns (`../`) are rejected. The same directory is shared with the pre-check script gate used by LLM jobs. + +Interpreter choice is by file extension: + +| Extension | Interpreter | +|-----------|-------------| +| `.sh`, `.bash` | `/bin/bash` | +| anything else | `sys.executable` (current Python) | + +We intentionally do NOT honour `#!/...` shebangs — keeping the interpreter set explicit and small reduces the surface the scheduler trusts. + +## Schedule Syntax + +Same as all other cron jobs: + +```bash +hermes cron create "every 5m" # interval +hermes cron create "every 2h" +hermes cron create "0 9 * * *" # standard cron: 9am daily +hermes cron create "30m" # one-shot: run once in 30 minutes +``` + +See the [cron feature reference](/docs/user-guide/features/cron) for the full syntax. + +## Delivery Targets + +`--deliver` accepts everything the gateway knows about. Some common shapes: + +```bash +--deliver telegram # platform home channel +--deliver telegram:-1001234567890 # specific chat +--deliver telegram:-1001234567890:17585 # specific Telegram forum topic +--deliver discord:#ops +--deliver slack:#engineering +--deliver signal:+15551234567 +--deliver local # just save to ~/.hermes/cron/output/ +``` + +No running gateway is required at script-run time for bot-token platforms (Telegram, Discord, Slack, Signal, SMS, WhatsApp) — the tool calls each platform's REST endpoint directly using the credentials already in `~/.hermes/.env` / `~/.hermes/config.yaml`. + +## Editing and Lifecycle + +```bash +hermes cron list # see all jobs +hermes cron pause <job_id> # stop firing, keep definition +hermes cron resume <job_id> +hermes cron edit <job_id> --schedule "every 10m" # adjust cadence +hermes cron edit <job_id> --agent # flip to LLM mode +hermes cron edit <job_id> --no-agent --script … # flip back +hermes cron remove <job_id> # delete it +``` + +Everything that works on LLM jobs (pause, resume, manual trigger, delivery target changes) works on no-agent jobs too. + +## Worked Example: Disk Space Alert + +```bash +cat > ~/.hermes/scripts/disk-alert.sh <<'EOF' +#!/usr/bin/env bash +# Alert when / or /home is over 90% full. +THRESHOLD=90 +df -h / /home 2>/dev/null | awk -v t="$THRESHOLD" ' + NR > 1 && $5+0 >= t { + printf "⚠ Disk %s full on %s\n", $5, $6 + } +' +EOF +chmod +x ~/.hermes/scripts/disk-alert.sh + +hermes cron create "*/15 * * * *" \ + --no-agent \ + --script disk-alert.sh \ + --deliver telegram \ + --name "disk-alert" +``` + +Silent when both filesystems are under 90%; fires exactly one line per over-threshold filesystem when one fills up. + +## Comparison with Other Patterns + +| Approach | What runs | When to use | +|----------|-----------|-------------| +| `hermes send` (one-shot) | Any shell command piping into it | Ad-hoc delivery or as the action of an external scheduler (systemd, launchd) | +| `cronjob --no-agent` (this page) | Your script on Hermes' schedule | Recurring watchdogs / alerts / metrics that don't need reasoning | +| `cronjob` (default, LLM) | Agent with optional pre-check script | When the message content requires reasoning over data | +| OS cron + `hermes send` | Your script on the OS schedule | When Hermes might be unhealthy (the thing you're monitoring) | + +For critical system-health watchdogs that must fire *even when the gateway is down*, keep using OS-level cron + a plain `curl` or `hermes send` call — those run as independent OS processes and don't depend on Hermes being up. The in-gateway scheduler is the right choice when the thing being monitored is external. + +## Related + +- [Automate Anything with Cron](/docs/guides/automate-with-cron) — LLM-driven cron patterns. +- [Scheduled Tasks (Cron) reference](/docs/user-guide/features/cron) — full schedule syntax, lifecycle, delivery routing. +- [Pipe Script Output with `hermes send`](/docs/guides/pipe-script-output) — the one-shot counterpart for ad-hoc scripts. +- [Gateway Internals](/docs/developer-guide/gateway-internals) — delivery-router internals. diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index e74d8004608..cd6b4652bae 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -286,6 +286,30 @@ cron: Or set the `HERMES_CRON_SCRIPT_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 120s default. +## No-agent mode (script-only jobs) + +For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely: + +```bash +hermes cron create "every 5m" \ + --no-agent \ + --script memory-watchdog.sh \ + --deliver telegram \ + --name "memory-watchdog" +``` + +Semantics: + +- Script stdout (trimmed) → delivered verbatim as the message. +- **Empty stdout → silent tick**, no delivery. This is the watchdog pattern: "only say something when something is wrong". +- Non-zero exit or timeout → an error alert is delivered, so a broken watchdog can't fail silently. +- `{"wakeAgent": false}` on the last line → silent tick (same gate LLM jobs use). +- No tokens, no model, no provider fallback — the job never touches the inference layer. + +`.sh` / `.bash` files run under `/bin/bash`; anything else under the current Python interpreter (`sys.executable`). Scripts must live in `~/.hermes/scripts/` (same sandboxing rule as the pre-run script gate). + +See the [Script-Only Cron Jobs guide](/docs/guides/cron-script-only) for worked examples. + ## Provider recovery Cron jobs inherit your configured fallback providers and credential pool rotation. If the primary API key is rate-limited or the provider returns an error, the cron agent can: From 1c7c7c3c5f483cccd8b671410297cd33f1120a87 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:31:21 -0700 Subject: [PATCH 08/28] feat(kanban-dashboard): per-platform home-channel notification toggles (#19864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * revert: auto-subscribe gateway chat on tool-driven kanban_create (#19718) Reverts ff3d2773e2. Teknium reviewed the merged PR and decided this behavior isn't wanted — tool-driven kanban_create should not mirror the slash-command path's auto-subscribe. Orchestrators that want their originating chat notified can call kanban_notify-subscribe explicitly; we're not going to make it implicit. * feat(kanban-dashboard): per-platform home-channel notification toggles Adds a "Notify home channels" section to the task drawer in the kanban dashboard plugin. Each platform where the user has set a home channel (/sethome, TELEGRAM_HOME_CHANNEL env var, gateway.platforms.<p>.home_channel in config.yaml) gets a toggle pill. Toggling on writes a kanban_notify_subs row keyed to that platform's home (chat_id + thread_id); toggling off removes it. The existing gateway notifier watcher delivers completed / blocked / gave_up events without any new plumbing — this is purely a GUI surface over existing machinery. Replaces the reverted auto-subscribe behavior from #19718 with an explicit, per-task, per-platform, user-controlled opt-in. No implicit subscription on tool-driven kanban_create; no CLI commands; no slash commands. Just a toggle in the drawer. Backend (plugins/kanban/dashboard/plugin_api.py): - GET /api/plugins/kanban/home-channels[?task_id=X] Returns every platform with a configured home, plus a per-entry subscribed: bool relative to task_id (false when task_id omitted). Reads the live GatewayConfig via load_gateway_config() so env-var overlays stay honored. - POST /api/plugins/kanban/tasks/:id/home-subscribe/:platform Idempotent add_notify_sub keyed to the platform's home. - DELETE /api/plugins/kanban/tasks/:id/home-subscribe/:platform remove_notify_sub for the same tuple. - 404 when the platform has no home configured, or task_id doesn't exist (POST only). Frontend (plugins/kanban/dashboard/dist/index.js): - TaskDrawer fetches /home-channels on open, keyed on task_id. - HomeSubsSection renders nothing when zero platforms have a home (so users who haven't set one up don't see an empty UI block). - Optimistic toggle with busy flag + revert-on-failure. One pill per platform; ✓ prefix and --on class indicate the subscribed state. CSS (plugins/kanban/dashboard/dist/style.css): - .hermes-kanban-home-subs flex row + .hermes-kanban-home-sub pill style + --on subscribed variant (subtle ring-colored background). Live-tested against a dashboard with TELEGRAM + DISCORD_BOT_TOKEN / HOME_CHANNEL env vars set: drawer shows both pills, toggling each flips its visual state AND writes/removes the correct kanban_notify_subs row (verified via direct DB read). Tests (tests/plugins/test_kanban_dashboard_plugin.py, 11 new, 53/53 pass total): - home-channels lists only platforms with a home (slack with a token but no home is excluded) - no task_id -> all subscribed=false - subscribe creates notify_sub row with correct chat/thread/platform - subscribed=true reflected in subsequent GET - idempotent re-subscribe - unknown platform -> 404 - unknown task -> 404 - unsubscribe removes the row - telegram + discord subscribe/unsubscribe independent - zero homes -> empty list --- plugins/kanban/dashboard/dist/index.js | 96 +++++++++++ plugins/kanban/dashboard/dist/style.css | 20 +++ plugins/kanban/dashboard/plugin_api.py | 149 +++++++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 158 ++++++++++++++++++ 4 files changed, 423 insertions(+) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 3bdd92d47e1..d60bc192895 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1375,6 +1375,11 @@ const [err, setErr] = useState(null); const [newComment, setNewComment] = useState(""); const [editing, setEditing] = useState(false); + // Home-channel notification toggles. homeChannels is the list of platforms + // the user has a /sethome on; each entry has a `subscribed` bool telling + // us whether this task is currently subscribed via that platform's home. + const [homeChannels, setHomeChannels] = useState([]); + const [homeBusy, setHomeBusy] = useState({}); const boardSlug = props.boardSlug; const load = useCallback(function () { @@ -1384,10 +1389,19 @@ .finally(function () { setLoading(false); }); }, [props.taskId, boardSlug]); + const loadHomeChannels = useCallback(function () { + const qs = new URLSearchParams({ task_id: props.taskId }); + const url = withBoard(`${API}/home-channels?${qs}`, boardSlug); + return SDK.fetchJSON(url) + .then(function (d) { setHomeChannels(d.home_channels || []); }) + .catch(function () { /* silent — endpoint optional on older gateways */ }); + }, [props.taskId, boardSlug]); + // Reload when the WS stream reports new events for this task id // (completion, block, crash, etc. — anything that'd make the drawer // show stale data if we only loaded on mount). useEffect(function () { load(); }, [load, props.eventTick]); + useEffect(function () { loadHomeChannels(); }, [loadHomeChannels]); useEffect(function () { function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); } window.addEventListener("keydown", onKey); @@ -1448,6 +1462,43 @@ .catch(function (e) { setErr(String(e.message || e)); }); }; + const toggleHomeSubscription = function (platform, currentlySubscribed) { + // Optimistic flip + busy flag to keep double-clicks idempotent. + setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); }); + setHomeChannels(function (list) { + return list.map(function (h) { + return h.platform === platform + ? Object.assign({}, h, { subscribed: !currentlySubscribed }) + : h; + }); + }); + const method = currentlySubscribed ? "DELETE" : "POST"; + const url = withBoard( + `${API}/tasks/${encodeURIComponent(props.taskId)}/home-subscribe/${encodeURIComponent(platform)}`, + boardSlug, + ); + return SDK.fetchJSON(url, { method: method }) + .then(function () { return loadHomeChannels(); }) + .catch(function (e) { + // Revert optimistic flip on failure. + setHomeChannels(function (list) { + return list.map(function (h) { + return h.platform === platform + ? Object.assign({}, h, { subscribed: currentlySubscribed }) + : h; + }); + }); + setErr(String(e.message || e)); + }) + .finally(function () { + setHomeBusy(function (b) { + const next = Object.assign({}, b); + delete next[platform]; + return next; + }); + }); + }; + return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose }, h("div", { className: "hermes-kanban-drawer", @@ -1474,6 +1525,9 @@ onRemoveParent: removeLink, onAddChild: addChild, onRemoveChild: removeChild, + homeChannels: homeChannels, + homeBusy: homeBusy, + onToggleHomeSub: toggleHomeSubscription, }) : null, data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, h(Input, { @@ -1535,6 +1589,11 @@ t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, ), h(StatusActions, { task: t, onPatch: props.onPatch }), + h(HomeSubsSection, { + homeChannels: props.homeChannels || [], + homeBusy: props.homeBusy || {}, + onToggle: props.onToggleHomeSub, + }), h(BodyEditor, { task: t, renderMarkdown: props.renderMarkdown, @@ -1950,6 +2009,43 @@ ); } + + // One toggle per gateway platform the user has a home channel set on + // (telegram, discord, slack, etc.). Toggling on creates a kanban_notify_subs + // row routed to that platform's home; toggling off removes it. Nothing + // renders when no platforms have a home configured — this section stays + // invisible for users who haven't set one up. + function HomeSubsSection(props) { + const channels = props.homeChannels || []; + if (channels.length === 0) return null; + const busy = props.homeBusy || {}; + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, + "Notify home channels"), + h("div", { className: "hermes-kanban-home-subs" }, + channels.map(function (hc) { + const isBusy = !!busy[hc.platform]; + const label = hc.subscribed ? "✓ " + hc.platform : hc.platform; + const title = hc.subscribed + ? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.` + : `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`; + return h(Button, { + key: hc.platform, + size: "sm", + title: title, + disabled: isBusy || !props.onToggle, + onClick: function () { + if (props.onToggle) props.onToggle(hc.platform, hc.subscribed); + }, + className: hc.subscribed + ? "hermes-kanban-home-sub hermes-kanban-home-sub--on" + : "hermes-kanban-home-sub", + }, label); + }) + ) + ); + } + // ------------------------------------------------------------------------- // Register // ------------------------------------------------------------------------- diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 3c197e62095..34fc714d118 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -351,6 +351,26 @@ gap: 0.3rem; } +/* ---- Home channel subscription toggles (per-platform, per-task) ----- */ + +.hermes-kanban-home-subs { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.hermes-kanban-home-sub { + font-family: var(--font-mono, ui-monospace, monospace); + text-transform: lowercase; + letter-spacing: 0.02em; +} +.hermes-kanban-home-sub--on { + /* Subtly indicate the subscribed state without a hard color change so + * dashboard themes stay coherent. Border + tinted background. */ + border-color: color-mix(in srgb, var(--color-ring) 55%, var(--color-border)); + background: color-mix(in srgb, var(--color-ring) 14%, transparent); + color: var(--color-foreground); +} + .hermes-kanban-section { display: flex; flex-direction: column; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 1c25f372e61..2378baaac7a 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -733,6 +733,155 @@ def get_config(): } +# --------------------------------------------------------------------------- +# Home-channel subscriptions (per-task, per-platform toggles) +# --------------------------------------------------------------------------- +# +# Home channels are a first-class gateway concept — each configured platform +# can have exactly one (chat_id, thread_id, name) it considers "home". The +# dashboard surfaces these as per-task toggles so a user can opt a specific +# task into receiving terminal notifications (completed / blocked / gave_up) +# at their telegram/discord/slack home, without touching the CLI. +# +# The wire format mirrors kanban_db.add_notify_sub — (task_id, platform, +# chat_id, thread_id) — so toggle-on creates exactly the same row the +# `/kanban create` slash command would, and the existing gateway notifier +# watcher delivers events without any additional plumbing. + + +def _configured_home_channels() -> list[dict]: + """Return every platform that has a home_channel set, fully hydrated. + + Reads the live GatewayConfig so env-var overlays (``TELEGRAM_HOME_CHANNEL`` + etc.) are honored alongside config.yaml. Returns platforms in a stable + order and drops platforms without a home. + """ + try: + from gateway.config import load_gateway_config + except Exception: + return [] + try: + gw_cfg = load_gateway_config() + except Exception: + return [] + result: list[dict] = [] + for platform, pcfg in gw_cfg.platforms.items(): + if not pcfg or not pcfg.home_channel: + continue + hc = pcfg.home_channel + result.append({ + "platform": platform.value, + "chat_id": hc.chat_id, + "thread_id": hc.thread_id or "", + "name": hc.name or "Home", + }) + # Stable order for deterministic UI — platform name alphabetical. + result.sort(key=lambda r: r["platform"]) + return result + + +def _home_sub_matches(sub: dict, home: dict) -> bool: + """True if a notify_subs row corresponds to the given home channel.""" + return ( + sub.get("platform") == home["platform"] + and str(sub.get("chat_id", "")) == str(home["chat_id"]) + and str(sub.get("thread_id") or "") == str(home["thread_id"] or "") + ) + + +@router.get("/home-channels") +def get_home_channels( + task_id: Optional[str] = Query(None), + board: Optional[str] = Query(None), +): + """List every platform with a home channel, plus whether *task_id* + (if given) is currently subscribed to that home. + + When ``task_id`` is omitted, every entry's ``subscribed`` is ``false`` + — useful for the "no task selected" state of the UI. + """ + homes = _configured_home_channels() + subscribed_homes: set[tuple[str, str, str]] = set() + if task_id: + board = _resolve_board(board) + conn = _conn(board=board) + try: + subs = kanban_db.list_notify_subs(conn, task_id) + finally: + conn.close() + for sub in subs: + key = ( + str(sub.get("platform") or ""), + str(sub.get("chat_id") or ""), + str(sub.get("thread_id") or ""), + ) + subscribed_homes.add(key) + result = [] + for home in homes: + key = (home["platform"], home["chat_id"], home["thread_id"]) + result.append({**home, "subscribed": key in subscribed_homes}) + return {"home_channels": result} + + +@router.post("/tasks/{task_id}/home-subscribe/{platform}") +def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)): + """Subscribe *task_id* to notifications routed to *platform*'s home channel. + + Idempotent — re-subscribing is a no-op at the DB layer. 404 if the + platform has no home channel configured. 404 if the task doesn't exist. + """ + homes = _configured_home_channels() + home = next((h for h in homes if h["platform"] == platform), None) + if not home: + raise HTTPException( + status_code=404, + detail=f"No home channel configured for platform {platform!r}. " + f"Set one from the messenger via /sethome, or configure " + f"gateway.platforms.{platform}.home_channel in config.yaml.", + ) + board = _resolve_board(board) + conn = _conn(board=board) + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + kanban_db.add_notify_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=home["chat_id"], + thread_id=home["thread_id"] or None, + ) + return {"ok": True, "task_id": task_id, "home_channel": home} + finally: + conn.close() + + +@router.delete("/tasks/{task_id}/home-subscribe/{platform}") +def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)): + """Remove any notify subscription on *task_id* that matches *platform*'s home.""" + homes = _configured_home_channels() + home = next((h for h in homes if h["platform"] == platform), None) + if not home: + raise HTTPException( + status_code=404, + detail=f"No home channel configured for platform {platform!r}.", + ) + board = _resolve_board(board) + conn = _conn(board=board) + try: + kanban_db.remove_notify_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=home["chat_id"], + thread_id=home["thread_id"] or None, + ) + return {"ok": True, "task_id": task_id, "home_channel": home} + finally: + conn.close() + + # --------------------------------------------------------------------------- # Stats (per-profile / per-status counts + oldest-ready age) # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 0055fc80f04..23589ce6909 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -914,3 +914,161 @@ def test_create_task_probe_error_does_not_break_create(client, monkeypatch): ) assert r.status_code == 200 assert r.json()["task"]["title"] == "resilient" + + + +# --------------------------------------------------------------------------- +# Home-channel subscription endpoints (#19534 follow-up: GUI opt-in) +# --------------------------------------------------------------------------- +# +# Dashboard surface for per-task, per-platform notification toggles. The +# backend endpoints read the live GatewayConfig, so tests set env vars +# (BOT_TOKEN + HOME_CHANNEL) to simulate a user who has run /sethome on +# telegram and discord. + + +@pytest.fixture +def with_home_channels(monkeypatch): + """Simulate a user with home channels set on telegram and discord.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc:fake") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "1234567") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_THREAD_ID", "42") + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_NAME", "Main TG") + monkeypatch.setenv("DISCORD_BOT_TOKEN", "disc_fake") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "9999999") + monkeypatch.setenv("DISCORD_HOME_CHANNEL_NAME", "Main Discord") + # Slack has a token but NO home — should be excluded from the list. + monkeypatch.setenv("SLACK_BOT_TOKEN", "slack_fake") + + +def test_home_channels_lists_only_platforms_with_home(client, with_home_channels): + """GET /home-channels returns entries only for platforms where the + user has set a home; untoggled-subscribed bool is false by default.""" + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + platforms = {h["platform"] for h in r.json()["home_channels"]} + assert platforms == {"telegram", "discord"}, ( + f"slack has a token but no home — must not appear. got {platforms}" + ) + for h in r.json()["home_channels"]: + assert h["subscribed"] is False + + +def test_home_channels_no_task_id_all_unsubscribed(client, with_home_channels): + """Without task_id, every entry's subscribed=false (UI "no task" state).""" + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + assert all(not h["subscribed"] for h in r.json()["home_channels"]) + + +def test_home_subscribe_creates_notify_sub_row(client, with_home_channels): + """POST .../home-subscribe/telegram writes a kanban_notify_subs row + keyed to the telegram home's (chat_id, thread_id).""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + + r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + assert r.status_code == 200 + assert r.json()["ok"] is True + + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, t["id"]) + finally: + conn.close() + assert len(subs) == 1 + assert subs[0]["platform"] == "telegram" + assert subs[0]["chat_id"] == "1234567" + assert subs[0]["thread_id"] == "42" + + +def test_home_subscribe_flips_subscribed_flag_in_subsequent_get(client, with_home_channels): + """After subscribe, the GET endpoint reports subscribed=true for that + platform and false for the others.""" + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + + r = client.get(f"/api/plugins/kanban/home-channels?task_id={t['id']}") + flags = {h["platform"]: h["subscribed"] for h in r.json()["home_channels"]} + assert flags == {"telegram": True, "discord": False} + + +def test_home_subscribe_is_idempotent(client, with_home_channels): + """Re-subscribing keeps a single row at the DB layer.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + conn = kb.connect() + try: + assert len(kb.list_notify_subs(conn, t["id"])) == 1 + finally: + conn.close() + + +def test_home_subscribe_unknown_platform_returns_404(client, with_home_channels): + """Platforms without a home configured (slack in the fixture) return 404.""" + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/slack") + assert r.status_code == 404 + assert "slack" in r.json()["detail"] + + +def test_home_subscribe_unknown_task_returns_404(client, with_home_channels): + r = client.post("/api/plugins/kanban/tasks/t_nonexistent/home-subscribe/telegram") + assert r.status_code == 404 + + +def test_home_unsubscribe_removes_notify_sub_row(client, with_home_channels): + """DELETE .../home-subscribe/telegram removes the matching row.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + assert r.status_code == 200 + + conn = kb.connect() + try: + assert kb.list_notify_subs(conn, t["id"]) == [] + finally: + conn.close() + + +def test_home_subscribe_multiple_platforms_independent(client, with_home_channels): + """Subscribing on telegram does not affect discord and vice versa.""" + from hermes_cli import kanban_db as kb + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/discord") + + conn = kb.connect() + try: + subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])} + finally: + conn.close() + assert set(subs) == {"telegram", "discord"} + + # Unsubscribe telegram only. + client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram") + conn = kb.connect() + try: + subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])} + finally: + conn.close() + assert set(subs) == {"discord"} + + +def test_home_channels_empty_when_no_homes_configured(client, monkeypatch): + """Zero platforms with a home -> empty list (UI hides the section).""" + # No BOT_TOKEN env vars set → load_gateway_config().platforms is empty. + for var in [ + "TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL", + "DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL", + "SLACK_BOT_TOKEN", + ]: + monkeypatch.delenv(var, raising=False) + r = client.get("/api/plugins/kanban/home-channels") + assert r.status_code == 200 + assert r.json()["home_channels"] == [] From a21f364ad7dc7a76849f09ba2aff93d0cb36eff7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:31:57 -0700 Subject: [PATCH 09/28] chore(release): AUTHOR_MAP entries for Tier 1g salvage batch --- scripts/release.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 32a5ff0ce86..0acdf219df9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -708,6 +708,20 @@ AUTHOR_MAP = { "59465365+0xsir0000@users.noreply.github.com": "0xsir0000", "lisanhu2014@hotmail.com": "lisanhu", "0668001438@zte.com.cn": "chenyunbo411", + "steven_chanin@alum.mit.edu": "stevenchanin", + "fiver@example.com": "halmisen", + "mayq0422@gmail.com": "yuqianma", + "scott@bubble.local": "bassings", + "highland0971@users.noreply.github.com": "highland0971", + "sudolewis@gmail.com": "lewislulu", + "gaurav2301v@gmail.com": "Gaurav23V", + "tranquil_flow@protonmail.com": "Tranquil-Flow", + "albert748@gmail.com": "albert748", + "ntconguit@gmail.com": "0xharryriddle", + "lhysdl@gmail.com": "lhysdl", + "shemol@163.com": "SherlockShemol", + "clawdia@fmercurio-macstudio.local": "fmercurio", + "ricardoporsche001@icloud.com": "Ricardo-M-L", "leozeli@qq.com": "leozeli", "linlehao@cuhk.edu.cn": "LehaoLin", "liutong@isacas.ac.cn": "I3eg1nner", From d90f73bcec3daad4fc72b9f3471392acabdd5747 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:33:21 -0700 Subject: [PATCH 10/28] fix(gateway): use git HEAD SHA, not file mtimes, for stale-code check (#19740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-code self-check (Issue #17648) used sentinel-file mtimes to decide whether the gateway survived a `hermes update` with stale `sys.modules`. That signal false-positives on any write to the sentinel files — including agent-driven edits during Hermes-on-Hermes dev sessions. Telling the agent to patch `run_agent.py` would flip the check to True on the next user message and force a gateway restart even though no update happened. Switch the signal to `git rev-parse HEAD`. Agent file edits don't move HEAD; `hermes update` (git pull) always does. Reading .git/HEAD directly (no subprocess) with a 5s cache keeps the overhead negligible on bursty chats. Non-git installs short-circuit to False — the stale-modules class can't occur without a git-backed update path, so there's nothing to detect. The legacy `_compute_repo_mtime` helper is kept but unused by detection, reserved as a fallback hook for future pip-install update paths. - _read_git_head_sha(): resolves HEAD across main checkout, worktree (follows `gitdir:` + `commondir` pointers), and packed-refs layouts. - _current_git_sha_cached(): per-runner 5s SHA cache. - _detect_stale_code(): boot SHA vs current SHA, returns False when either is unavailable. - Tests cover all four layouts, the agent-edits-don't-trigger regression, and cache behavior. Refs #17648. --- gateway/run.py | 202 ++++++++-- tests/gateway/test_stale_code_self_check.py | 419 ++++++++++++++------ 2 files changed, 482 insertions(+), 139 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index f90b2b1b03c..2b085d9915f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -101,10 +101,21 @@ _AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60 # already-loaded stale module object and raises ``ImportError`` — see # Issue #17648. Rather than papering over the import failure site-by-site # in every tool file, detect the stale state centrally and auto-restart -# so the gateway reloads with fresh code. The sentinel files below are -# the canonical repo-level markers that every update touches; if any is -# newer than the gateway's boot time, we know the running process is out -# of date. +# so the gateway reloads with fresh code. +# +# The signal we use is ``git rev-parse HEAD`` — the only thing ``hermes +# update`` moves that is NOT moved by agent-driven file edits. Earlier +# revisions of this check compared file mtimes across a sentinel set +# (run_agent.py, gateway/run.py, ...), but that produced false positives +# whenever the agent edited its own source files during a session: +# mtime jumps, stale-check fires, gateway restarts, user must retype. +# See the conversation at PR #<this> for the motivating incident. +# +# The legacy mtime sentinels are kept ONLY as a last-resort fallback for +# non-git installs (pip install from wheel, sparse clones with no .git +# dir). In those environments ``hermes update`` is not a supported path, +# so the check effectively no-ops — which is the safe behavior: better +# to ship one broken import than to restart on every agent-edit. _STALE_CODE_SENTINELS: tuple[str, ...] = ( "hermes_cli/config.py", "hermes_cli/__init__.py", @@ -113,10 +124,106 @@ _STALE_CODE_SENTINELS: tuple[str, ...] = ( "pyproject.toml", ) +# Cache git HEAD reads across consecutive messages so a chat burst doesn't +# spawn one subprocess per message. 5s is long enough to collapse a burst +# and short enough that the real post-update detection still fires within +# the user's perceived "next message" window. +_GIT_SHA_CACHE_TTL_SECS = 5.0 + + +def _read_git_head_sha(repo_root: Path) -> Optional[str]: + """Return the git HEAD SHA for ``repo_root``, or None if unavailable. + + Reads ``.git/HEAD`` directly (and follows one level of ref) instead + of shelling out to ``git`` — cheaper, no subprocess tax, works on + gateway hosts that don't have a ``git`` binary on PATH. Returns + None for non-git installs (no ``.git`` dir) or any I/O error; callers + treat None as "can't tell" and skip the check. + + Supports the three layouts we care about: + 1. Main checkout: ``<repo>/.git/`` is a directory. + 2. Git worktree: ``<repo>/.git`` is a file ``gitdir: <path>`` that + points at ``<main>/.git/worktrees/<name>/``. The worktree's + gitdir has HEAD + index but NOT refs/heads/ — those live in + the main checkout, and ``<worktree-gitdir>/commondir`` points + at the main ``.git``. We search both locations for refs. + 3. Packed refs: ``refs/heads/<branch>`` is absent on disk but + listed in ``<main-git-dir>/packed-refs``. + """ + try: + git_dir = repo_root / ".git" + # Worktrees store ``.git`` as a file pointing at gitdir: <path> + if git_dir.is_file(): + try: + content = git_dir.read_text().strip() + if content.startswith("gitdir:"): + git_dir = Path(content.split(":", 1)[1].strip()) + if not git_dir.is_absolute(): + git_dir = (repo_root / git_dir).resolve() + except OSError: + return None + if not git_dir.is_dir(): + return None + + # Figure out the "common" git dir — the one that owns shared refs. + # For a worktree, commondir points at it (relative path, resolve + # against git_dir). For a main checkout, common_dir == git_dir. + common_dir = git_dir + commondir_file = git_dir / "commondir" + if commondir_file.is_file(): + try: + rel = commondir_file.read_text().strip() + candidate = (git_dir / rel).resolve() if rel else git_dir + if candidate.is_dir(): + common_dir = candidate + except OSError: + pass + + head_path = git_dir / "HEAD" + if not head_path.is_file(): + return None + head_content = head_path.read_text().strip() + + if head_content.startswith("ref:"): + # Symbolic ref — follow one level (e.g. ref: refs/heads/main). + # Worktree-local refs (bisect, rebase-merge state) live under + # git_dir; shared refs (refs/heads/*, refs/tags/*) live under + # common_dir. Try git_dir first, then common_dir. + ref_rel = head_content.split(":", 1)[1].strip() + for base in (git_dir, common_dir) if git_dir != common_dir else (git_dir,): + ref_path = base / ref_rel + if ref_path.is_file(): + try: + sha = ref_path.read_text().strip() + except OSError: + continue + if sha: + return sha + # Packed refs fallback — always stored in the common dir. + packed = common_dir / "packed-refs" + if packed.is_file(): + try: + for line in packed.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("^"): + continue + parts = line.split(None, 1) + if len(parts) == 2 and parts[1] == ref_rel: + return parts[0] or None + except OSError: + return None + return None + + # Detached HEAD — content is the SHA directly. + return head_content or None + except Exception: + return None + def _compute_repo_mtime(repo_root: Path) -> float: """Return the newest mtime across the stale-code sentinel files. + Legacy fallback used only for non-git installs (``.git`` missing). Missing files are ignored (they may not exist on older checkouts). Returns 0.0 if no sentinel file is readable — treat that as "can't tell", which downstream callers interpret as "not stale" to avoid @@ -1005,6 +1112,7 @@ class GatewayRunner: # running __init__ don't crash when _handle_message reads these. _boot_wall_time: float = 0.0 _boot_repo_mtime: float = 0.0 + _boot_git_sha: Optional[str] = None _stale_code_restart_triggered: bool = False def __init__(self, config: Optional[GatewayConfig] = None): @@ -1020,15 +1128,23 @@ class GatewayRunner: try: self._boot_wall_time: float = time.time() self._repo_root_for_staleness: Path = Path(__file__).resolve().parent.parent + self._boot_git_sha: Optional[str] = _read_git_head_sha( + self._repo_root_for_staleness, + ) self._boot_repo_mtime: float = _compute_repo_mtime( self._repo_root_for_staleness, ) except Exception: self._boot_wall_time = 0.0 self._repo_root_for_staleness = Path(".") + self._boot_git_sha = None self._boot_repo_mtime = 0.0 self._stale_code_notified: set[str] = set() self._stale_code_restart_triggered: bool = False + # Cached current-SHA read, refreshed at most every + # _GIT_SHA_CACHE_TTL_SECS so bursty chats don't hammer the filesystem. + self._cached_current_sha: Optional[str] = self._boot_git_sha + self._cached_current_sha_at: float = self._boot_wall_time # Load ephemeral config from config.yaml / env vars. # Both are injected at API-call time only and never persisted. @@ -2737,36 +2853,69 @@ class GatewayRunner: task.add_done_callback(self._background_tasks.discard) return True + def _current_git_sha_cached(self) -> Optional[str]: + """Return the current HEAD SHA, cached for _GIT_SHA_CACHE_TTL_SECS. + + A bursty chat (user mashes "hello?" three times) would otherwise + re-read ``.git/HEAD`` on every message. Caching collapses that + into a single read and still re-checks within the user's + perceived "next message" window. + """ + now = time.time() + if ( + self._cached_current_sha is not None + and (now - self._cached_current_sha_at) < _GIT_SHA_CACHE_TTL_SECS + ): + return self._cached_current_sha + try: + sha = _read_git_head_sha(self._repo_root_for_staleness) + except Exception: + sha = None + self._cached_current_sha = sha + self._cached_current_sha_at = now + return sha + def _detect_stale_code(self) -> bool: - """Return True if source files on disk are newer than the running process. + """Return True if the git HEAD moved since this process booted. A gateway that survives ``hermes update`` (manual SIGTERM never escalated, systemd restart race, detached-process respawn failed, etc.) keeps pre-update modules cached in ``sys.modules``. Later imports of names added post-update — e.g. ``cfg_get`` from PR #17304 — raise ImportError against the stale module object (see - Issue #17648). Detecting this at the source — "the code on disk - is newer than me" — lets us auto-restart instead of serving - broken responses until the user notices and runs - ``hermes gateway restart`` manually. + Issue #17648). - Returns False when the boot-time snapshot is unavailable or no - sentinel file is readable, to avoid false-positive restart loops - in unusual checkouts (sparse clones, read-only filesystems). + We compare the git HEAD SHA at boot to the current SHA on disk. + ``hermes update`` always moves HEAD forward via ``git pull``; + agent file edits (the agent patching ``run_agent.py`` or + ``gateway/run.py`` during a self-dev session) never move HEAD. + That makes SHA comparison free of the false-positive class that + the old mtime check suffered from — the agent can edit any file + without triggering a phantom restart. + + Returns False when: + - the boot SHA is unavailable (non-git install, first call + during partial init, etc.); we can't tell and refuse to loop + - the current SHA matches the boot SHA + - reading the current SHA fails for any reason """ - if not self._boot_wall_time or not self._boot_repo_mtime: + if not self._boot_wall_time: + return False + if not self._boot_git_sha: + # Non-git install. ``hermes update`` is git-based, so a + # non-git install can't experience the stale-modules class + # this check exists to catch. Return False — no check, no + # false positives. (If we ever ship a pip-install update + # path, we'd add a persistent update marker here and compare + # its timestamp to self._boot_wall_time.) return False try: - current = _compute_repo_mtime(self._repo_root_for_staleness) + current = self._current_git_sha_cached() except Exception: return False - if current <= 0.0: + if not current: return False - # 2-second slack guards against filesystems with coarse mtime - # resolution (FAT32, some NFS mounts). Real updates always move - # the newest-file mtime forward by minutes, so this doesn't hide - # genuine staleness. - return current > self._boot_repo_mtime + 2.0 + return current != self._boot_git_sha def _trigger_stale_code_restart(self) -> None: """Idempotently kick off a graceful restart after stale-code detection. @@ -2782,12 +2931,17 @@ class GatewayRunner: if self._stale_code_restart_triggered: return self._stale_code_restart_triggered = True + current_sha = None + try: + current_sha = self._current_git_sha_cached() + except Exception: + pass logger.warning( - "Stale-code self-check: source files newer than gateway boot " - "time (boot=%.0f, newest=%.0f) — requesting graceful restart. " + "Stale-code self-check: git HEAD moved since gateway boot " + "(boot=%s, current=%s) — requesting graceful restart. " "See Issue #17648.", - self._boot_repo_mtime, - _compute_repo_mtime(self._repo_root_for_staleness), + (self._boot_git_sha or "?")[:12], + (current_sha or "?")[:12], ) try: self.request_restart(detached=False, via_service=True) diff --git a/tests/gateway/test_stale_code_self_check.py b/tests/gateway/test_stale_code_self_check.py index 5289f575d40..64ad347145d 100644 --- a/tests/gateway/test_stale_code_self_check.py +++ b/tests/gateway/test_stale_code_self_check.py @@ -3,25 +3,34 @@ A gateway that survives ``hermes update`` keeps pre-update modules cached in ``sys.modules``. Later imports of names added post-update (e.g. ``cfg_get`` from PR #17304) raise ImportError against the stale module -object. The self-check in ``GatewayRunner._detect_stale_code()`` detects -this by comparing boot-time sentinel-file mtimes against current ones, -and ``_trigger_stale_code_restart()`` triggers a graceful restart. +object. + +The self-check compares the git HEAD SHA at boot to the current SHA on +disk. ``hermes update`` always moves HEAD forward via ``git pull``; +agent-driven file edits (Hermes editing ``run_agent.py`` / ``gateway/run.py`` +during a self-dev session) never move HEAD — so the SHA signal is free of +the false-positive class that the earlier mtime-based check suffered from. """ import os import time from pathlib import Path -from unittest.mock import MagicMock, patch import pytest from gateway.run import ( GatewayRunner, _compute_repo_mtime, + _read_git_head_sha, _STALE_CODE_SENTINELS, + _GIT_SHA_CACHE_TTL_SECS, ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def _make_tmp_repo(tmp_path: Path) -> Path: """Create a fake repo with all stale-code sentinel files.""" for rel in _STALE_CODE_SENTINELS: @@ -31,109 +40,303 @@ def _make_tmp_repo(tmp_path: Path) -> Path: return tmp_path -def _make_runner(repo_root: Path, *, boot_mtime: float, boot_wall: float): +def _make_git_repo(tmp_path: Path, sha: str = "a" * 40, branch: str = "main") -> Path: + """Stamp a minimal .git directory so _read_git_head_sha can resolve a SHA. + + We don't run real git — just lay down the files the reader walks + (.git/HEAD pointing at refs/heads/<branch>, refs/heads/<branch> + containing the SHA). + """ + git_dir = tmp_path / ".git" + git_dir.mkdir(parents=True, exist_ok=True) + (git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n") + refs_dir = git_dir / "refs" / "heads" + refs_dir.mkdir(parents=True, exist_ok=True) + (refs_dir / branch).write_text(f"{sha}\n") + return tmp_path + + +def _set_head_sha(repo_root: Path, sha: str, branch: str = "main") -> None: + """Rewrite the current branch ref to a new SHA (simulates git pull).""" + (repo_root / ".git" / "refs" / "heads" / branch).write_text(f"{sha}\n") + + +def _make_runner( + repo_root: Path, + *, + boot_sha: str | None, + boot_wall: float = None, + boot_mtime: float = 0.0, +): """Bare GatewayRunner with just the stale-check attributes set.""" + if boot_wall is None: + boot_wall = time.time() runner = object.__new__(GatewayRunner) runner._repo_root_for_staleness = repo_root runner._boot_wall_time = boot_wall + runner._boot_git_sha = boot_sha runner._boot_repo_mtime = boot_mtime runner._stale_code_notified = set() runner._stale_code_restart_triggered = False + runner._cached_current_sha = boot_sha + runner._cached_current_sha_at = boot_wall return runner -def test_compute_repo_mtime_returns_newest(tmp_path): - """_compute_repo_mtime returns the newest mtime across sentinel files.""" - repo = _make_tmp_repo(tmp_path) +# --------------------------------------------------------------------------- +# _read_git_head_sha — raw SHA reader +# --------------------------------------------------------------------------- - # Stamp a baseline mtime across all sentinels - baseline = time.time() - 100 - for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) - - # Touch one file forward - newer = time.time() - os.utime(repo / "hermes_cli/config.py", (newer, newer)) - - result = _compute_repo_mtime(repo) - assert abs(result - newer) < 1.0 # within 1s (filesystem mtime resolution) +def test_read_git_head_sha_branch_ref(tmp_path): + """Resolves ref: refs/heads/<branch> → SHA from refs/heads/<branch>.""" + sha = "b" * 40 + _make_git_repo(tmp_path, sha=sha, branch="main") + assert _read_git_head_sha(tmp_path) == sha -def test_compute_repo_mtime_missing_files_returns_zero(tmp_path): - """Missing sentinel files return 0.0 (treated as 'can't tell' upstream).""" - # tmp_path has none of the sentinels - assert _compute_repo_mtime(tmp_path) == 0.0 +def test_read_git_head_sha_detached_head(tmp_path): + """Detached HEAD: .git/HEAD contains the SHA directly.""" + sha = "c" * 40 + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text(f"{sha}\n") + assert _read_git_head_sha(tmp_path) == sha -def test_compute_repo_mtime_partial_files_still_works(tmp_path): - """Partial sentinel presence still returns newest of the readable ones.""" - (tmp_path / "hermes_cli").mkdir() - target = tmp_path / "hermes_cli" / "config.py" - target.write_text("# partial\n") - target_mtime = time.time() - 50 - os.utime(target, (target_mtime, target_mtime)) - - result = _compute_repo_mtime(tmp_path) - assert abs(result - target_mtime) < 1.0 +def test_read_git_head_sha_packed_refs(tmp_path): + """Falls back to packed-refs when refs/heads/<branch> is missing.""" + sha = "d" * 40 + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("ref: refs/heads/main\n") + # No refs/heads/main file — only packed-refs + (git_dir / "packed-refs").write_text( + f"# pack-refs with: peeled fully-peeled sorted\n" + f"{sha} refs/heads/main\n" + ) + assert _read_git_head_sha(tmp_path) == sha -def test_detect_stale_code_false_when_no_boot_snapshot(tmp_path): - """No boot snapshot → can't tell → not stale (no restart loop).""" - repo = _make_tmp_repo(tmp_path) - runner = _make_runner(repo, boot_mtime=0.0, boot_wall=0.0) +def test_read_git_head_sha_worktree_gitdir_file(tmp_path): + """Worktree: .git is a file with `gitdir: <path>` pointing to the real git dir. + + Real git worktrees store shared refs (refs/heads/*) in the main + checkout's .git/ and write a ``commondir`` pointer into the + worktree-gitdir. The reader must follow commondir to resolve the + branch ref — this is the layout Hermes dev sessions actually use. + """ + sha = "e" * 40 + # Main repo layout + main_repo = tmp_path / "main-repo" + main_git = main_repo / ".git" + (main_git / "refs" / "heads").mkdir(parents=True) + (main_git / "HEAD").write_text("ref: refs/heads/main\n") + (main_git / "refs" / "heads" / "main").write_text("0" * 40 + "\n") + + # Worktree lives in main-repo/.git/worktrees/<name>/ + worktree_git_dir = main_git / "worktrees" / "feature" + worktree_git_dir.mkdir(parents=True) + (worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n") + # commondir points back at the main .git (relative path, "../..") + (worktree_git_dir / "commondir").write_text("../..\n") + # Feature branch ref lives in the shared refs/heads + (main_git / "refs" / "heads" / "feature").write_text(f"{sha}\n") + + # Worktree checkout with .git file pointing at worktree_git_dir + worktree = tmp_path / "wt" + worktree.mkdir() + (worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n") + + assert _read_git_head_sha(worktree) == sha + + +def test_read_git_head_sha_worktree_packed_refs_in_common(tmp_path): + """Worktree + packed-refs in common dir: fallback still resolves.""" + sha = "f" * 40 + main_repo = tmp_path / "main-repo" + main_git = main_repo / ".git" + main_git.mkdir(parents=True) + (main_git / "HEAD").write_text("ref: refs/heads/main\n") + # packed-refs in the common (main) .git + (main_git / "packed-refs").write_text( + f"# pack-refs with: peeled fully-peeled sorted\n" + f"{sha} refs/heads/feature\n" + ) + + worktree_git_dir = main_git / "worktrees" / "feature" + worktree_git_dir.mkdir(parents=True) + (worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n") + (worktree_git_dir / "commondir").write_text("../..\n") + + worktree = tmp_path / "wt" + worktree.mkdir() + (worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n") + + assert _read_git_head_sha(worktree) == sha + + +def test_read_git_head_sha_no_git_returns_none(tmp_path): + """No .git dir → None (non-git install, safely disables the check).""" + assert _read_git_head_sha(tmp_path) is None + + +def test_read_git_head_sha_malformed_head_returns_none(tmp_path): + """Empty HEAD file → None (don't loop on corrupt repos).""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "HEAD").write_text("") + assert _read_git_head_sha(tmp_path) is None + + +# --------------------------------------------------------------------------- +# _detect_stale_code — the main regression guard +# --------------------------------------------------------------------------- + +def test_detect_stale_code_false_when_sha_unchanged(tmp_path): + """Boot SHA == current SHA → not stale (no restart).""" + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) + # Force fresh read by expiring the cache + runner._cached_current_sha_at = 0.0 assert runner._detect_stale_code() is False -def test_detect_stale_code_false_when_files_unchanged(tmp_path): - """Source files at boot mtime → not stale.""" - repo = _make_tmp_repo(tmp_path) - # Freeze all sentinels to the same mtime - baseline = time.time() - 100 - for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) - - runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline) - assert runner._detect_stale_code() is False - - -def test_detect_stale_code_true_after_update(tmp_path): - """Sentinel files newer than boot snapshot → stale.""" - repo = _make_tmp_repo(tmp_path) - baseline = time.time() - 100 - for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) - - runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline) - - # Simulate hermes update touching config.py - new_mtime = time.time() - os.utime(repo / "hermes_cli/config.py", (new_mtime, new_mtime)) - +def test_detect_stale_code_true_after_git_pull(tmp_path): + """Boot SHA != current SHA → stale (hermes update happened).""" + boot_sha = "a" * 40 + _make_git_repo(tmp_path, sha=boot_sha) + runner = _make_runner(tmp_path, boot_sha=boot_sha) + # Simulate git pull moving HEAD forward + _set_head_sha(tmp_path, "b" * 40) + runner._cached_current_sha_at = 0.0 # expire cache assert runner._detect_stale_code() is True -def test_detect_stale_code_ignores_subsecond_drift(tmp_path): - """2-second slack prevents false positives on coarse-mtime filesystems.""" - repo = _make_tmp_repo(tmp_path) - baseline = time.time() - 100 +def test_detect_stale_code_ignores_agent_file_edits(tmp_path): + """THE CORE REGRESSION: agent edits to source files do NOT trigger restart. + + This is the motivating incident for the SHA-based check. Under the + previous mtime-based scheme, any ``patch`` / ``write_file`` call + against run_agent.py / gateway/run.py / hermes_cli/config.py would + flip the stale-check to True and force a gateway restart on the + next message — even though no update actually happened. SHA + comparison decouples the two: git HEAD only moves on ``git pull``, + never on file writes. + """ + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + _make_tmp_repo(tmp_path) # lay down sentinel files too + runner = _make_runner(tmp_path, boot_sha=sha) + + # Simulate the agent editing run_agent.py and gateway/run.py with + # mtimes far into the future — exactly the scenario that used to + # false-positive the old mtime check. + future = time.time() + 10_000 for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) + p = tmp_path / rel + if p.is_file(): + p.write_text("# agent just edited this\n") + os.utime(p, (future, future)) - runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline) - - # Touch config.py 1s newer — within the 2s slack → not stale - os.utime(repo / "hermes_cli/config.py", (baseline + 1.0, baseline + 1.0)) + # HEAD SHA has NOT moved — check must stay False. + runner._cached_current_sha_at = 0.0 # expire cache assert runner._detect_stale_code() is False - # Touch 5s newer → stale - os.utime(repo / "hermes_cli/config.py", (baseline + 5.0, baseline + 5.0)) - assert runner._detect_stale_code() is True +def test_detect_stale_code_false_for_non_git_install(tmp_path): + """Non-git install (no .git dir) → check disabled, never fires.""" + # No .git dir at all; runner's boot_sha is None + runner = _make_runner(tmp_path, boot_sha=None) + # Even if we pretended the current SHA differed, the check should + # short-circuit on boot_sha=None and return False. + assert runner._detect_stale_code() is False + + +def test_detect_stale_code_false_when_no_boot_wall_time(tmp_path): + """No boot snapshot at all → can't tell → not stale (no restart loop).""" + runner = _make_runner(tmp_path, boot_sha="a" * 40, boot_wall=0.0) + assert runner._detect_stale_code() is False + + +def test_detect_stale_code_handles_disappearing_git_dir(tmp_path): + """.git vanishes mid-run → current_sha = None → not stale (don't loop).""" + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) + # Nuke the git dir after boot + import shutil + shutil.rmtree(tmp_path / ".git") + runner._cached_current_sha_at = 0.0 # expire cache + assert runner._detect_stale_code() is False + + +# --------------------------------------------------------------------------- +# SHA cache +# --------------------------------------------------------------------------- + +def test_current_sha_cache_collapses_bursts(tmp_path, monkeypatch): + """Consecutive calls inside the TTL window reuse the cached SHA.""" + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) + + read_calls = {"n": 0} + real_reader = _read_git_head_sha + + def counting_reader(repo_root): + read_calls["n"] += 1 + return real_reader(repo_root) + + from gateway import run as run_mod + monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader) + + # Force cache expiry so the first call definitely reads + runner._cached_current_sha_at = 0.0 + runner._current_git_sha_cached() + first_count = read_calls["n"] + + # Immediate second/third calls should hit cache (no new read) + runner._current_git_sha_cached() + runner._current_git_sha_cached() + assert read_calls["n"] == first_count + + +def test_current_sha_cache_expires_after_ttl(tmp_path, monkeypatch): + """After _GIT_SHA_CACHE_TTL_SECS elapses, a fresh read happens.""" + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) + + read_calls = {"n": 0} + real_reader = _read_git_head_sha + + def counting_reader(repo_root): + read_calls["n"] += 1 + return real_reader(repo_root) + + from gateway import run as run_mod + monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader) + + runner._cached_current_sha_at = 0.0 + runner._current_git_sha_cached() + first = read_calls["n"] + + # Age the cache past the TTL + runner._cached_current_sha_at = time.time() - (_GIT_SHA_CACHE_TTL_SECS + 1.0) + runner._current_git_sha_cached() + assert read_calls["n"] == first + 1 + + +# --------------------------------------------------------------------------- +# _trigger_stale_code_restart — idempotency preserved +# --------------------------------------------------------------------------- def test_trigger_stale_code_restart_is_idempotent(tmp_path): """Calling _trigger_stale_code_restart twice only requests restart once.""" - repo = _make_tmp_repo(tmp_path) - runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0) + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) calls = [] @@ -153,8 +356,9 @@ def test_trigger_stale_code_restart_is_idempotent(tmp_path): def test_trigger_stale_code_restart_survives_request_failure(tmp_path): """If request_restart raises, we swallow and mark as triggered anyway.""" - repo = _make_tmp_repo(tmp_path) - runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0) + sha = "a" * 40 + _make_git_repo(tmp_path, sha=sha) + runner = _make_runner(tmp_path, boot_sha=sha) def boom(*, detached=False, via_service=False): raise RuntimeError("no event loop") @@ -168,56 +372,41 @@ def test_trigger_stale_code_restart_survives_request_failure(tmp_path): assert runner._stale_code_restart_triggered is True -def test_detect_stale_code_handles_disappearing_repo_root(tmp_path): - """If the repo root vanishes after boot, return False (don't loop).""" - repo = _make_tmp_repo(tmp_path) - baseline = time.time() - 100 - for rel in _STALE_CODE_SENTINELS: - os.utime(repo / rel, (baseline, baseline)) - - runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline) - - # Remove all sentinel files — _compute_repo_mtime returns 0.0 - for rel in _STALE_CODE_SENTINELS: - (repo / rel).unlink(missing_ok=True) - - assert runner._detect_stale_code() is False - +# --------------------------------------------------------------------------- +# Class-level defaults — tests that build bare runners via object.__new__ +# --------------------------------------------------------------------------- def test_class_level_defaults_prevent_uninitialized_access(): """Partial construction via object.__new__ must not crash _detect_stale_code.""" runner = object.__new__(GatewayRunner) # Don't set any instance attrs — class-level defaults should kick in runner._repo_root_for_staleness = Path(".") - # _boot_wall_time / _boot_repo_mtime fall through to class defaults (0.0) + # _boot_wall_time / _boot_git_sha fall through to class defaults + # (0.0 and None respectively) assert runner._detect_stale_code() is False # _stale_code_restart_triggered falls through to class default (False) assert runner._stale_code_restart_triggered is False -def test_init_captures_boot_snapshot(monkeypatch, tmp_path): - """GatewayRunner.__init__ captures a usable stale-code baseline.""" - # Stub out the heavy parts of __init__ we don't need. We only want - # to prove the stale-code snapshot is captured before anything else. - from gateway import run as run_mod +# --------------------------------------------------------------------------- +# Legacy mtime reader kept for compatibility — light sanity check only +# --------------------------------------------------------------------------- - calls = {} +def test_compute_repo_mtime_still_returns_newest(tmp_path): + """_compute_repo_mtime remains available for any legacy callers.""" + repo = _make_tmp_repo(tmp_path) - def fake_compute(repo_root): - calls["repo_root"] = repo_root - return 1234567890.0 + baseline = time.time() - 100 + for rel in _STALE_CODE_SENTINELS: + os.utime(repo / rel, (baseline, baseline)) - monkeypatch.setattr(run_mod, "_compute_repo_mtime", fake_compute) + newer = time.time() + os.utime(repo / "hermes_cli/config.py", (newer, newer)) - # Build a runner without running the full __init__ — then manually - # exercise the stale-check init block that __init__ contains. - runner = object.__new__(GatewayRunner) - runner._boot_wall_time = time.time() - runner._repo_root_for_staleness = Path(run_mod.__file__).resolve().parent.parent - runner._boot_repo_mtime = run_mod._compute_repo_mtime(runner._repo_root_for_staleness) - runner._stale_code_notified = set() - runner._stale_code_restart_triggered = False + result = _compute_repo_mtime(repo) + assert abs(result - newer) < 1.0 - assert runner._boot_repo_mtime == 1234567890.0 - assert calls["repo_root"] == runner._repo_root_for_staleness - assert runner._boot_wall_time > 0 + +def test_compute_repo_mtime_missing_files_returns_zero(tmp_path): + """Legacy sanity: missing sentinels → 0.0.""" + assert _compute_repo_mtime(tmp_path) == 0.0 From 8ab9f61dcf787b6cbf4c2ac258621c5f4c2b18d7 Mon Sep 17 00:00:00 2001 From: fiver <fiver@example.com> Date: Mon, 27 Apr 2026 14:17:58 +0800 Subject: [PATCH 11/28] fix(gateway): preserve WSL interop PATH in systemd units --- hermes_cli/gateway.py | 42 ++++++++++++++++++++++++ tests/hermes_cli/test_gateway_service.py | 37 +++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index dff0a4aa755..c1804f9c7f9 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1608,6 +1608,46 @@ def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]: return [p for p in candidates if p not in path_entries and Path(p).exists()] +def _build_wsl_interop_paths(path_entries: list[str]) -> list[str]: + """Return WSL Windows interop PATH entries for generated systemd units. + + WSL shells normally inherit Windows PATH entries such as + ``/mnt/c/WINDOWS/System32``. systemd user services do not, so gateway tools + that call ``powershell.exe``/``cmd.exe`` work in a terminal but fail in the + background service unless we persist the relevant entries at install time. + """ + if not is_wsl(): + return [] + + candidates: list[str] = [] + for entry in os.environ.get("PATH", "").split(os.pathsep): + if entry.startswith("/mnt/"): + candidates.append(entry) + + for executable in ("powershell.exe", "cmd.exe", "explorer.exe", "wsl.exe"): + resolved = shutil.which(executable) + if resolved: + candidates.append(str(Path(resolved).parent)) + + for entry in ( + "/mnt/c/WINDOWS/system32", + "/mnt/c/WINDOWS", + "/mnt/c/WINDOWS/System32/Wbem", + "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/", + "/mnt/c/WINDOWS/System32/OpenSSH/", + ): + if Path(entry).exists(): + candidates.append(entry) + + result: list[str] = [] + seen = set(path_entries) + for entry in candidates: + if entry and entry not in seen: + seen.add(entry) + result.append(entry) + return result + + def _remap_path_for_user(path: str, target_home_dir: str) -> str: """Remap *path* from the current user's home to *target_home_dir*. @@ -1699,6 +1739,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) node_bin = _remap_path_for_user(node_bin, home_dir) path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) + path_entries.extend(_build_wsl_interop_paths(path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) return f"""[Unit] @@ -1738,6 +1779,7 @@ WantedBy=multi-user.target hermes_home = str(get_hermes_home().resolve()) profile_arg = _profile_arg(hermes_home) path_entries.extend(_build_user_local_paths(Path.home(), path_entries)) + path_entries.extend(_build_wsl_interop_paths(path_entries)) path_entries.extend(common_bin_paths) sane_path = ":".join(path_entries) return f"""[Unit] diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 210c9c144e7..3e9a4d37202 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -182,6 +182,43 @@ class TestGeneratedSystemdUnits: assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit + def test_user_unit_includes_wsl_windows_interop_paths(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True) + monkeypatch.setenv( + "PATH", + "/usr/local/bin:/mnt/c/WINDOWS/system32:/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/", + ) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + unit = gateway_cli.generate_systemd_unit(system=False) + + assert "/mnt/c/WINDOWS/system32" in unit + assert "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/" in unit + + def test_user_unit_omits_windows_interop_paths_outside_wsl(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) + monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32") + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + unit = gateway_cli.generate_systemd_unit(system=False) + + assert "/mnt/c/WINDOWS/system32" not in unit + + def test_system_unit_includes_wsl_windows_interop_paths(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True) + monkeypatch.setattr( + gateway_cli, + "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", "/home/alice"), + ) + monkeypatch.setattr(gateway_cli, "_hermes_home_for_target_user", lambda home: "/home/alice/.hermes") + monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32") + monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None) + + unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") + + assert "/mnt/c/WINDOWS/system32" in unit + def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self): unit = gateway_cli.generate_systemd_unit(system=True) From 103f51ad34ee7817d3cd1cbf01144a66573333bb Mon Sep 17 00:00:00 2001 From: jjjojoj <88077783+jjjojoj@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:13:46 +0800 Subject: [PATCH 12/28] fix(doctor): check gh auth status when GITHUB_TOKEN absent hermes doctor showed 'No GITHUB_TOKEN (60 req/hr)' warning even when users had authenticated via gh auth login. Now falls back to gh auth status --json authenticated when GITHUB_TOKEN and GH_TOKEN are both unset. Fixes #16115 --- hermes_cli/doctor.py | 14 ++++++ tests/hermes_cli/test_doctor.py | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 446f576a612..21e6cd05cee 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1264,9 +1264,23 @@ def run_doctor(args): check_warn("Skills Hub directory not initialized", "(run: hermes skills list)") from hermes_cli.config import get_env_value + + def _gh_authenticated() -> bool: + """Check if gh CLI is authenticated via token file or device flow.""" + try: + result = subprocess.run( + ["gh", "auth", "status", "--json", "authenticated"], + capture_output=True, timeout=10, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") if github_token: check_ok("GitHub token configured (authenticated API access)") + elif _gh_authenticated(): + check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)") else: check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index de80e240d1c..0f48606141a 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -663,3 +663,79 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path ) assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls) assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls) + + +class TestGitHubTokenCheck: + """Tests for GitHub token / gh auth detection in doctor.""" + + def test_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("PATH", "/nonexistent") # gh not found + + from hermes_cli.doctor import run_doctor, _DHH + import io, contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "No GITHUB_TOKEN" in out + assert "60 req/hr" in out + + def test_token_env_present_shows_ok(self, monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123") + monkeypatch.setenv("PATH", "/nonexistent") # gh not found + + from hermes_cli.doctor import run_doctor + import io, contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "GitHub token configured" in out + + def test_gh_authenticated_without_env_token_shows_ok(self, monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + # No GITHUB_TOKEN or GH_TOKEN + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + + # Mock gh to return success + import shutil + real_which = shutil.which + def mock_which(cmd): + return "/usr/local/bin/gh" if cmd == "gh" else real_which(cmd) + monkeypatch.setattr(shutil, "which", mock_which) + + call_log = [] + def mock_run(cmd, **kwargs): + call_log.append(cmd) + if cmd[:2] == ["gh", "auth"]: + result = types.SimpleNamespace(returncode=0, stdout="", stderr="") + else: + result = types.SimpleNamespace(returncode=1, stdout="", stderr="") + return result + + import subprocess + monkeypatch.setattr(subprocess, "run", mock_run) + + from hermes_cli.doctor import run_doctor + import io, contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}" + assert "GitHub authenticated via gh CLI" in out or "token configured" in out From 20edca75e9929e05435defca4e873d2366ef2fe2 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:13:32 -0700 Subject: [PATCH 13/28] fix(update): sync bundled skills to all profiles, including active (#16176) `hermes update` iterated only non-active profiles when seeding bundled skills. `seed_profile_skills()` uses a subprocess with an explicit HERMES_HOME so it correctly targets any profile path; the `p.name != active` filter was the only thing preventing the active profile from being included, leaving it silently on stale skill content after every update. Drop the filter and update the header line from "other profiles" to "all profiles". The active profile is now seeded on the same path as every other profile. The earlier `sync_skills()` call (module-level HERMES_HOME) remains for backward compatibility; the subprocess-based loop is reliable regardless of which HERMES_HOME the CLI was invoked with. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- hermes_cli/main.py | 16 +++--- tests/hermes_cli/test_cmd_update.py | 75 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ac7da65a23e..2f10d3f4712 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7069,20 +7069,22 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception as e: logger.debug("Skills sync during update failed: %s", e) - # Sync bundled skills to all other profiles + # Sync bundled skills to all profiles (including the active one). + # seed_profile_skills() uses subprocess with an explicit HERMES_HOME so + # it is not affected by sync_skills()'s module-level HERMES_HOME cache, + # which means the active profile is reliably synced regardless of whether + # the caller's HERMES_HOME env var points at the default or a named profile. try: from hermes_cli.profiles import ( list_profiles, - get_active_profile_name, seed_profile_skills, ) - active = get_active_profile_name() - other_profiles = [p for p in list_profiles() if p.name != active] - if other_profiles: + all_profiles = list_profiles() + if all_profiles: print() - print("→ Syncing bundled skills to other profiles...") - for p in other_profiles: + print("→ Syncing bundled skills to all profiles...") + for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) if r: diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index caac6d37278..57a671beab1 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -163,3 +163,78 @@ class TestCmdUpdateBranchFallback: mock_input.assert_not_called() captured = capsys.readouterr() assert "Non-interactive session" in captured.out + + +class TestCmdUpdateProfileSkillSync: + """cmd_update syncs bundled skills to all profiles, including the active one. + + Regression guard for #16176: previously the active profile was excluded + from the seed_profile_skills loop, leaving it on stale skill content. + """ + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_active_profile_included_in_skill_sync( + self, mock_run, _mock_which, mock_args, capsys + ): + from pathlib import Path + + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="1" + ) + + default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes")) + active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit")) + other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work")) + all_profiles = [default_p, active_p, other_p] + + synced_paths = [] + + def fake_seed(path, quiet=False): + synced_paths.append(path) + return {"copied": [], "updated": [], "user_modified": []} + + empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []} + + with ( + patch("hermes_cli.profiles.list_profiles", return_value=all_profiles), + patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed), + patch("tools.skills_sync.sync_skills", return_value=empty_sync), + ): + cmd_update(mock_args) + + assert active_p.path in synced_paths, ( + f"Active profile 'bit' must be included in skill sync; got: {synced_paths}" + ) + assert set(synced_paths) == {p.path for p in all_profiles}, ( + f"All profiles must be synced; got: {synced_paths}" + ) + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_single_profile_default_is_synced( + self, mock_run, _mock_which, mock_args, capsys + ): + from pathlib import Path + + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="1" + ) + + default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes")) + synced_paths = [] + + def fake_seed(path, quiet=False): + synced_paths.append(path) + return {"copied": [], "updated": [], "user_modified": []} + + empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []} + + with ( + patch("hermes_cli.profiles.list_profiles", return_value=[default_p]), + patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed), + patch("tools.skills_sync.sync_skills", return_value=empty_sync), + ): + cmd_update(mock_args) + + assert default_p.path in synced_paths From fd9c32c0f285bb09f926e50980b71da809b3f1ed Mon Sep 17 00:00:00 2001 From: "Albert.Zhou" <albert748@gmail.com> Date: Mon, 27 Apr 2026 09:23:38 +0800 Subject: [PATCH 14/28] fix(email): drop non-allowlisted senders before dispatch to prevent mail loops Add EMAIL_ALLOWED_USERS check in EmailAdapter._dispatch_message() to silently discard emails from senders not in the allowlist. This prevents the adapter from creating thread context and dispatching a MessageEvent for unauthorized senders, which could race with the gateway authorization check and result in SMTP replies being sent despite the handler returning None. Test: tests/gateway/test_email.py::TestDispatchMessage::test_non_allowlisted_sender_dropped Test: tests/gateway/test_email.py::TestDispatchMessage::test_allowlisted_sender_proceeds Test: tests/gateway/test_email.py::TestDispatchMessage::test_empty_allowlist_allows_all --- gateway/platforms/email.py | 12 ++++++ tests/gateway/test_email.py | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index a3436926363..7717494de52 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -416,6 +416,18 @@ class EmailAdapter(BasePlatformAdapter): logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr) return + # Skip senders not in EMAIL_ALLOWED_USERS — prevents the adapter + # from creating a MessageEvent (and thus thread context) for senders + # that the gateway will never authorize. Without this early guard, + # a race between dispatch and authorization can result in the adapter + # sending a reply even though the handler returned None. + allowed_raw = os.getenv("EMAIL_ALLOWED_USERS", "").strip() + if allowed_raw: + allowed = {addr.strip().lower() for addr in allowed_raw.split(",") if addr.strip()} + if sender_addr.lower() not in allowed: + logger.debug("[Email] Dropping non-allowlisted sender at dispatch: %s", sender_addr) + return + subject = msg_data["subject"] body = msg_data["body"].strip() attachments = msg_data["attachments"] diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 7c1d0d48e17..d378eecea7c 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -425,6 +425,91 @@ class TestDispatchMessage(unittest.TestCase): self.assertEqual(event.source.user_name, "John Doe") self.assertEqual(event.source.chat_type, "dm") + def test_non_allowlisted_sender_dropped(self): + """Senders not in EMAIL_ALLOWED_USERS should be dropped before dispatch.""" + import asyncio + with patch.dict(os.environ, { + "EMAIL_ALLOWED_USERS": "hermes@test.com,admin@test.com", + }): + adapter = self._make_adapter() + adapter._message_handler = MagicMock() + + msg_data = { + "uid": b"99", + "sender_addr": "outsider@evil.com", + "sender_name": "Spammer", + "subject": "Buy now!!!", + "message_id": "<spam@evil.com>", + "in_reply_to": "", + "body": "Cheap meds", + "attachments": [], + "date": "", + } + + asyncio.run(adapter._dispatch_message(msg_data)) + # Handler should NOT be called for non-allowlisted sender + adapter._message_handler.assert_not_called() + # Thread context should NOT be created + self.assertNotIn("outsider@evil.com", adapter._thread_context) + + def test_allowlisted_sender_proceeds(self): + """Senders in EMAIL_ALLOWED_USERS should proceed to dispatch normally.""" + import asyncio + with patch.dict(os.environ, { + "EMAIL_ALLOWED_USERS": "hermes@test.com,admin@test.com", + }): + adapter = self._make_adapter() + captured_events = [] + + async def mock_handler(event): + captured_events.append(event) + return None + + adapter._message_handler = mock_handler + + msg_data = { + "uid": b"100", + "sender_addr": "admin@test.com", + "sender_name": "Admin", + "subject": "Important", + "message_id": "<msg@test.com>", + "in_reply_to": "", + "body": "Hello", + "attachments": [], + "date": "", + } + + asyncio.run(adapter._dispatch_message(msg_data)) + self.assertEqual(len(captured_events), 1) + self.assertEqual(captured_events[0].source.chat_id, "admin@test.com") + + def test_empty_allowlist_allows_all(self): + """When EMAIL_ALLOWED_USERS is not set, all senders should proceed.""" + import asyncio + with patch.dict(os.environ, {}, clear=False): + # Ensure EMAIL_ALLOWED_USERS is not in the env + if "EMAIL_ALLOWED_USERS" in os.environ: + del os.environ["EMAIL_ALLOWED_USERS"] + + adapter = self._make_adapter() + adapter._message_handler = MagicMock() + + msg_data = { + "uid": b"101", + "sender_addr": "anyone@test.com", + "sender_name": "Anyone", + "subject": "Hey", + "message_id": "<any@test.com>", + "in_reply_to": "", + "body": "Hi", + "attachments": [], + "date": "", + } + + asyncio.run(adapter._dispatch_message(msg_data)) + # Handler should be called when no allowlist is configured + adapter._message_handler.assert_called() + class TestThreadContext(unittest.TestCase): """Test email reply threading logic.""" From 75bce317a30b33dc7d0610120ad2ea3c970c4ddd Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:14:36 -0700 Subject: [PATCH 15/28] fix(cron): expand \${VAR} refs in config.yaml during job execution (#15890) The cron scheduler's run_job() loaded config.yaml with yaml.safe_load() but never called _expand_env_vars(), so ${HERMES_MODEL} and similar references in model:, fallback_providers:, and other config.yaml fields were forwarded to the LLM API as literal strings, causing HTTP 400 errors. The normal CLI path has always called _expand_env_vars() via load_config(), so this was a cron-only gap. The .env load at the top of run_job() already populates os.environ before config.yaml is read, so the expansion sees the correct values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- cron/scheduler.py | 3 +- tests/cron/test_scheduler.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 81e256a3295..c49370352c1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -35,7 +35,7 @@ from typing import List, Optional sys.path.insert(0, str(Path(__file__).parent.parent)) from hermes_constants import get_hermes_home -from hermes_cli.config import load_config +from hermes_cli.config import load_config, _expand_env_vars from hermes_time import now as _hermes_now logger = logging.getLogger(__name__) @@ -1082,6 +1082,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: if os.path.exists(_cfg_path): with open(_cfg_path) as _f: _cfg = yaml.safe_load(_f) or {} + _cfg = _expand_env_vars(_cfg) _model_cfg = _cfg.get("model", {}) if not job.get("model"): if isinstance(_model_cfg, str): diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 66df251a454..460c00add08 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1307,6 +1307,103 @@ class TestRunJobConfigLogging: f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}" +class TestRunJobConfigEnvVarExpansion: + """Verify that ${VAR} references in config.yaml are expanded when running cron jobs.""" + + _RUNTIME = { + "api_key": "test-key", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + } + + def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch): + """${VAR} in config.yaml model: is expanded using env after .env is loaded.""" + (tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_MODEL}\n") + monkeypatch.setenv("_HERMES_TEST_CRON_MODEL", "gpt-4o-mini-cron-test") + + job = {"id": "env-job", "name": "env test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, _, _, error = run_job(job) + + assert success is True + assert error is None + kwargs = mock_agent_cls.call_args.kwargs + assert kwargs["model"] == "gpt-4o-mini-cron-test", ( + f"Expected model='gpt-4o-mini-cron-test', got {kwargs['model']!r}. " + "config.yaml ${VAR} was not expanded in the cron execution path." + ) + + def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch): + """${VAR} in config.yaml fallback_providers model: is expanded.""" + (tmp_path / "config.yaml").write_text( + "fallback_providers:\n" + " - provider: openrouter\n" + " model: ${_HERMES_TEST_CRON_FALLBACK}\n" + ) + monkeypatch.setenv("_HERMES_TEST_CRON_FALLBACK", "gpt-4o-fallback-test") + + job = {"id": "fb-job", "name": "fallback test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + run_job(job) + + kwargs = mock_agent_cls.call_args.kwargs + fb = kwargs.get("fallback_model") or [] + fb_list = fb if isinstance(fb, list) else [fb] + expanded = [e.get("model") for e in fb_list if isinstance(e, dict)] + assert "gpt-4o-fallback-test" in expanded, ( + f"Expected expanded fallback model in {expanded!r}. " + "config.yaml ${VAR} in fallback_providers was not expanded." + ) + + def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch): + """When the env var is not set, the literal ${VAR} is kept verbatim (not crashed).""" + (tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n") + monkeypatch.delenv("_HERMES_TEST_CRON_UNSET_VAR", raising=False) + + job = {"id": "unset-job", "name": "unset var test", "prompt": "hi"} + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=self._RUNTIME), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, _, _, error = run_job(job) + + assert success is True + kwargs = mock_agent_cls.call_args.kwargs + # Unresolved refs are kept verbatim — _expand_env_vars contract + assert kwargs["model"] == "${_HERMES_TEST_CRON_UNSET_VAR}" + + class TestRunJobSkillBacked: def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path): job = { From 68754719165265e3dc152f10cabf9f46a4b45122 Mon Sep 17 00:00:00 2001 From: lhysdl <lhysdl@gmail.com> Date: Sun, 26 Apr 2026 16:46:09 +0800 Subject: [PATCH 16/28] fix(tts): update MiniMax API endpoint to v1/text_to_speech MiniMax deprecated the old v1/t2a_v2 endpoint (api.minimax.io) and moved to v1/text_to_speech (api.minimax.chat). The new API: - Uses a flat payload: {model, text, voice_id} instead of nested voice_setting / audio_setting objects - Returns raw audio bytes (Content-Type: audio/mpeg) instead of JSON with hex-encoded audio - Uses model 'speech-01' instead of 'speech-2.8-hd' - Updated default voice_id to 'female-shaonv' for Chinese TTS The implementation detects Content-Type to handle both old and new API responses, maintaining backward compatibility for any users who manually configured the legacy base_url. --- tests/tools/test_tts_speed.py | 39 ++++++++++++----------- tools/tts_tool.py | 60 ++++++++++++++++------------------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py index 7622a7f6227..8a3866aaa8a 100644 --- a/tests/tools/test_tts_speed.py +++ b/tests/tools/test_tts_speed.py @@ -110,7 +110,7 @@ class TestOpenaiTtsSpeed: # --------------------------------------------------------------------------- -# MiniMax TTS speed (global fallback wired) +# MiniMax TTS (new API: raw audio, no speed/voice_setting) # --------------------------------------------------------------------------- class TestMinimaxTtsSpeed: @@ -118,28 +118,29 @@ class TestMinimaxTtsSpeed: monkeypatch.setenv("MINIMAX_API_KEY", "test-key") mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = { - "data": {"audio": "deadbeef"}, - "base_resp": {"status_code": 0, "status_msg": "success"}, - "extra_info": {"audio_size": 8}, - } + mock_response.headers = {"Content-Type": "audio/mpeg"} + mock_response.content = b"\x00\x01\x02\x03" # requests is imported locally inside _generate_minimax_tts with patch("requests.post", return_value=mock_response) as mock_post: from tools.tts_tool import _generate_minimax_tts - _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) - return mock_post + output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + return mock_post, output - def test_global_speed_fallback(self, tmp_path, monkeypatch): - """Global tts.speed used when minimax.speed not set.""" - mock_post = self._run({"speed": 1.5}, tmp_path, monkeypatch) + def test_simple_payload(self, tmp_path, monkeypatch): + """New API uses flat payload with model, text, voice_id.""" + mock_post, _ = self._run({}, tmp_path, monkeypatch) payload = mock_post.call_args[1]["json"] - assert payload["voice_setting"]["speed"] == 1.5 + assert "model" in payload + assert "text" in payload + assert "voice_id" in payload + assert "voice_setting" not in payload + assert "audio_setting" not in payload + assert "stream" not in payload - def test_provider_speed_overrides_global(self, tmp_path, monkeypatch): - """tts.minimax.speed takes precedence over tts.speed.""" - mock_post = self._run( - {"speed": 1.5, "minimax": {"speed": 2.0}}, tmp_path, monkeypatch - ) - payload = mock_post.call_args[1]["json"] - assert payload["voice_setting"]["speed"] == 2.0 + def test_writes_raw_audio(self, tmp_path, monkeypatch): + """New API returns raw bytes written directly to file.""" + _, output = self._run({}, tmp_path, monkeypatch) + assert output == str(tmp_path / "out.mp3") + with open(output, "rb") as f: + assert f.read() == b"\x00\x01\x02\x03" diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 7473b32a1dc..8b82e1665b2 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -136,9 +136,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper" DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality DEFAULT_OPENAI_VOICE = "alloy" DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" -DEFAULT_MINIMAX_MODEL = "speech-2.8-hd" -DEFAULT_MINIMAX_VOICE_ID = "English_Graceful_Lady" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2" +DEFAULT_MINIMAX_MODEL = "speech-01" +DEFAULT_MINIMAX_VOICE_ID = "female-shaonv" +DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech" DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603" DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral DEFAULT_XAI_VOICE_ID = "eve" @@ -925,10 +925,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - # =========================================================================== def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: """ - Generate audio using MiniMax TTS API. + Generate audio using MiniMax TTS API (v1/text_to_speech). - MiniMax returns hex-encoded audio data. Supports streaming (SSE) and - non-streaming modes. This implementation uses non-streaming for simplicity. + The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload + and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike + the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio. Args: text: Text to convert (max 10,000 characters). @@ -947,35 +948,12 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any mm_config = tts_config.get("minimax", {}) model = mm_config.get("model", DEFAULT_MINIMAX_MODEL) voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID) - speed = mm_config.get("speed", tts_config.get("speed", 1)) - vol = mm_config.get("vol", 1) - pitch = mm_config.get("pitch", 0) base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL) - # Determine audio format from output extension - if output_path.endswith(".wav"): - audio_format = "wav" - elif output_path.endswith(".flac"): - audio_format = "flac" - else: - audio_format = "mp3" - payload = { "model": model, "text": text, - "stream": False, - "voice_setting": { - "voice_id": voice_id, - "speed": speed, - "vol": vol, - "pitch": pitch, - }, - "audio_setting": { - "sample_rate": 32000, - "bitrate": 128000, - "format": audio_format, - "channel": 1, - }, + "voice_id": voice_id, } headers = { @@ -984,9 +962,25 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any } response = requests.post(base_url, json=payload, headers=headers, timeout=60) - response.raise_for_status() - result = response.json() + content_type = response.headers.get("Content-Type", "") + + if "audio/" in content_type: + # New API: returns raw audio directly + with open(output_path, "wb") as f: + f.write(response.content) + return output_path + + # Legacy / fallback: try parsing as JSON with hex-encoded audio + try: + result = response.json() + except Exception: + response.raise_for_status() + raise RuntimeError( + f"MiniMax TTS returned unexpected Content-Type '{content_type}' " + f"({len(response.content)} bytes)" + ) + base_resp = result.get("base_resp", {}) status_code = base_resp.get("status_code", -1) @@ -998,7 +992,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any if not hex_audio: raise RuntimeError("MiniMax TTS returned empty audio data") - # MiniMax returns hex-encoded audio (not base64) + # Legacy: hex-encoded audio audio_bytes = bytes.fromhex(hex_audio) with open(output_path, "wb") as f: From 1c7f47a58c523b1e97f6d563314002bdef67c362 Mon Sep 17 00:00:00 2001 From: Ioodu <chinadbo@foxmail.com> Date: Mon, 27 Apr 2026 19:08:53 +0800 Subject: [PATCH 17/28] fix(cron): add concurrency regression test for parallel job state writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_due_jobs() called load_jobs() and save_jobs() without holding _jobs_file_lock, creating a race with the locked mark_job_run() and advance_next_run(). Wrap get_due_jobs() with the lock (delegating to a new _get_due_jobs_locked() inner function) so all load→modify→save cycles are serialised. Add two regression tests: one verifying 3 concurrent mark_job_run() calls each land their correct last_status and last_run_at without overwrites, and a stress test confirming 10 parallel calls each increment their job's completed count to exactly 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- cron/jobs.py | 6 +++ tests/cron/test_jobs.py | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index 93098bd86be..93ad4c17fbe 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -810,6 +810,12 @@ def get_due_jobs() -> List[Dict[str, Any]]: the job is fast-forwarded to the next future run instead of firing immediately. This prevents a burst of missed jobs on gateway restart. """ + with _jobs_file_lock: + return _get_due_jobs_locked() + + +def _get_due_jobs_locked() -> List[Dict[str, Any]]: + """Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held.""" now = _hermes_now() raw_jobs = load_jobs() jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index b9d34e1a5c6..0405f997b14 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1,6 +1,7 @@ """Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection.""" import json +import threading import pytest from datetime import datetime, timedelta, timezone from pathlib import Path @@ -745,6 +746,100 @@ class TestEnabledToolsets: assert fetched["enabled_toolsets"] == ["web", "delegation"] +class TestMarkJobRunConcurrency: + """Regression tests for concurrent parallel job state writes. + + tick() dispatches multiple jobs to separate threads simultaneously. + Without _jobs_file_lock protecting the load→modify→save cycle in + mark_job_run(), concurrent writes can clobber each other's updates + (last-writer-wins), leaving some jobs with stale last_status / last_run_at. + """ + + def test_three_concurrent_mark_job_run_no_overwrites(self, tmp_cron_dir): + """Run mark_job_run() for 3 jobs in parallel threads; all must land correctly.""" + # Create 3 distinct recurring jobs + job_a = create_job(prompt="Job A", schedule="every 1h") + job_b = create_job(prompt="Job B", schedule="every 1h") + job_c = create_job(prompt="Job C", schedule="every 1h") + + errors: list = [] + + def run_mark(job_id: str, success: bool, error_msg=None): + try: + mark_job_run(job_id, success=success, error=error_msg) + except Exception as exc: # pragma: no cover + errors.append(exc) + + # Fire all three concurrently + threads = [ + threading.Thread(target=run_mark, args=(job_a["id"], True)), + threading.Thread(target=run_mark, args=(job_b["id"], False, "timeout")), + threading.Thread(target=run_mark, args=(job_c["id"], True)), + ] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Unexpected exceptions in worker threads: {errors}" + + # Verify each job has the correct state — no overwrites + a = get_job(job_a["id"]) + b = get_job(job_b["id"]) + c = get_job(job_c["id"]) + + assert a is not None, "Job A was unexpectedly deleted" + assert b is not None, "Job B was unexpectedly deleted" + assert c is not None, "Job C was unexpectedly deleted" + + assert a["last_status"] == "ok", f"Job A last_status wrong: {a['last_status']}" + assert a["last_run_at"] is not None, "Job A last_run_at not set" + assert a["repeat"]["completed"] == 1, f"Job A completed count wrong: {a['repeat']['completed']}" + + assert b["last_status"] == "error", f"Job B last_status wrong: {b['last_status']}" + assert b["last_error"] == "timeout", f"Job B last_error wrong: {b['last_error']}" + assert b["last_run_at"] is not None, "Job B last_run_at not set" + assert b["repeat"]["completed"] == 1, f"Job B completed count wrong: {b['repeat']['completed']}" + + assert c["last_status"] == "ok", f"Job C last_status wrong: {c['last_status']}" + assert c["last_run_at"] is not None, "Job C last_run_at not set" + assert c["repeat"]["completed"] == 1, f"Job C completed count wrong: {c['repeat']['completed']}" + + def test_repeated_concurrent_runs_accumulate_completed_count(self, tmp_cron_dir): + """Stress test: 10 threads each call mark_job_run on a different job once. + + The completed count for every job must be exactly 1 after all threads finish, + confirming no thread's write was silently dropped. + """ + n = 10 + jobs = [create_job(prompt=f"Stress job {i}", schedule="every 1h") for i in range(n)] + errors: list = [] + + def run_mark(job_id: str): + try: + mark_job_run(job_id, success=True) + except Exception as exc: # pragma: no cover + errors.append(exc) + + threads = [threading.Thread(target=run_mark, args=(j["id"],)) for j in jobs] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Unexpected exceptions: {errors}" + + for job in jobs: + updated = get_job(job["id"]) + assert updated is not None, f"Job {job['id']} was deleted" + assert updated["last_status"] == "ok", ( + f"Job {job['id']} has wrong last_status: {updated['last_status']}" + ) + assert updated["repeat"]["completed"] == 1, ( + f"Job {job['id']} completed count is {updated['repeat']['completed']}, expected 1" + ) + + class TestSaveJobOutput: def test_creates_output_file(self, tmp_cron_dir): output_file = save_job_output("test123", "# Results\nEverything ok.") From 9e2628ee7c723ec8daa5e906016bdd38bfeb6d42 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:37:47 -0700 Subject: [PATCH 18/28] test(discord): annotate make_attachment content_type as Optional[str] Copilot review: the helper accepted None in one test but was annotated str. Matches actual usage where no-content-type attachments are a tested scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- tests/gateway/test_discord_document_handling.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_discord_document_handling.py b/tests/gateway/test_discord_document_handling.py index a22e0f0d669..d3ad137b61c 100644 --- a/tests/gateway/test_discord_document_handling.py +++ b/tests/gateway/test_discord_document_handling.py @@ -9,6 +9,7 @@ import os import sys from datetime import datetime, timezone from types import SimpleNamespace +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -111,7 +112,7 @@ def adapter(monkeypatch): def make_attachment( *, filename: str, - content_type: str, + content_type: Optional[str], size: int = 1024, url: str = "https://cdn.discordapp.com/attachments/fake/file", ) -> SimpleNamespace: From 64ad7dec0d02256a0ef8330d98ebe5949b517e5e Mon Sep 17 00:00:00 2001 From: ClawdIA <clawdia@fmercurio-macstudio.local> Date: Mon, 27 Apr 2026 15:31:15 -0300 Subject: [PATCH 19/28] fix(file-ops): allow file search in hidden roots --- tests/tools/test_file_operations.py | 61 +++++++++++++++++++++++++++++ tools/file_operations.py | 41 ++++++++++++++++--- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index 500cd6141aa..9e9ffa8ad33 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -2,6 +2,7 @@ import os import pytest +import subprocess from pathlib import Path from unittest.mock import MagicMock @@ -388,6 +389,66 @@ class TestSearchPathValidation: assert "search failed" in result.error.lower() or "Search error" in result.error +class TestSearchFilesFallbackHiddenPaths: + def _make_env(self): + env = MagicMock() + env.cwd = "/" + + def execute(command, **kwargs): + completed = subprocess.run( + command, + shell=True, + text=True, + capture_output=True, + ) + return { + "output": completed.stdout, + "returncode": completed.returncode, + } + + env.execute = execute + return env + + def test_hidden_root_with_hidden_ancestor_includes_files(self, tmp_path, monkeypatch): + """Fallback find should include visible files when path is inside hidden root.""" + root = tmp_path / ".hermes" / "logs" + root.mkdir(parents=True) + visible_file = root / "agent.log" + hidden_dir_file = root / ".hidden" / "secret.log" + nested_hidden_file = root / "nested" / ".secret.log" + visible_nested_file = root / "nested" / "visible.log" + + for p in [visible_file, nested_hidden_file, visible_nested_file, hidden_dir_file]: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x") + + ops = ShellFileOperations(self._make_env()) + monkeypatch.setattr(ops, "_has_command", lambda command: command == "find") + result = ops._search_files("*.log", str(root), limit=50, offset=0) + + assert result.error is None + assert set(result.files) == {str(visible_file), str(visible_nested_file)} + + def test_normal_root_still_excludes_hidden_descendants(self, tmp_path, monkeypatch): + """Fallback find should still exclude hidden descendant paths for normal roots.""" + root = tmp_path / "repo" + root.mkdir() + visible_file = root / "agent.log" + visible_nested_file = root / "nested" / "visible.log" + hidden_dir_file = root / ".hidden" / "secret.log" + + for p in [visible_file, visible_nested_file, hidden_dir_file]: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x") + + ops = ShellFileOperations(self._make_env()) + monkeypatch.setattr(ops, "_has_command", lambda command: command == "find") + result = ops._search_files("*.log", str(root), limit=50, offset=0) + + assert result.error is None + assert set(result.files) == {str(visible_file), str(visible_nested_file)} + + class TestShellFileOpsWriteDenied: def test_write_file_denied_path(self, file_ops): result = file_ops.write_file("~/.ssh/authorized_keys", "evil key") diff --git a/tools/file_operations.py b/tools/file_operations.py index 73e739e730a..627fdf96785 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -987,6 +987,12 @@ class ShellFileOperations(FileOperations): else: search_pattern = pattern.split('/')[-1] + search_root = Path(path) + has_hidden_path_ancestor = any( + part not in (".", "..") and part.startswith(".") + for part in search_root.parts + ) + # Prefer ripgrep: respects .gitignore, excludes hidden dirs by # default, and has parallel directory traversal (~200x faster than # find on wide trees). Mirrors _search_content which already uses rg. @@ -1002,17 +1008,25 @@ class ShellFileOperations(FileOperations): ) # Exclude hidden directories (matching ripgrep's default behavior). - hidden_exclude = "-not -path '*/.*'" + hidden_exclude = "-not -path '*/.*'" if not has_hidden_path_ancestor else "" + hidden_filter_expr = f" {hidden_exclude}" if hidden_exclude else "" - cmd = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \ - f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn | tail -n +{offset + 1} | head -n {limit}" + # Use shell pagination for standard roots. For hidden roots, gather full + # output so we can re-apply hidden-descendant filtering while allowing + # explicit hidden-root searches. + pagination_expr = "" + if not has_hidden_path_ancestor: + pagination_expr = f" | tail -n +{offset + 1} | head -n {limit}" + + cmd = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd, timeout=60) if not result.stdout.strip(): # Try without -printf (BSD find compatibility -- macOS) - cmd_simple = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \ - f"2>/dev/null | head -n {limit + offset} | tail -n +{offset + 1}" + cmd_simple = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + f"2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd_simple, timeout=60) files = [] @@ -1025,6 +1039,23 @@ class ShellFileOperations(FileOperations): else: files.append(line) + # For explicit hidden roots, find's path-based filtering excludes every + # file under the hidden path. Apply descendant filtering after command + # execution so only the explicit root ancestry is bypassed. + if has_hidden_path_ancestor: + normalized_root = search_root.resolve() + filtered_files = [] + for file_path in files: + try: + rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts + except ValueError: + rel_parts = Path(file_path).parts + if any(part not in (".", "..") and part.startswith(".") for part in rel_parts): + continue + filtered_files.append(file_path) + files = filtered_files[offset:offset + limit] + # pagination for standard roots is already applied in shell + return SearchResult( files=files, total_count=len(files) From fbc477df7181e459fc6b300eeaaf54b479a635dc Mon Sep 17 00:00:00 2001 From: Ricardo-M-L <ricardoporsche001@icloud.com> Date: Mon, 27 Apr 2026 00:28:59 +0800 Subject: [PATCH 20/28] fix(run_agent): acquire lock in IterationBudget.used property The `used` property was reading `self._used` without holding the lock, while `consume()`, `refund()`, and `remaining` all properly acquire `self._lock` before accessing `_used`. This means a concurrent call to `used` during `consume()` or `refund()` could observe a partially- updated value, leading to incorrect iteration budget metrics reported to the gateway, or in extreme cases a ValueError from CPython's list implementation when the internal array resizes during iteration. Fix: acquire the lock in `used` just like `remaining` does. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- run_agent.py | 3 +- tests/run_agent/test_iteration_budget_race.py | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/run_agent/test_iteration_budget_race.py diff --git a/run_agent.py b/run_agent.py index c8388bd0ae2..8e1549925bd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -304,7 +304,8 @@ class IterationBudget: @property def used(self) -> int: - return self._used + with self._lock: + return self._used @property def remaining(self) -> int: diff --git a/tests/run_agent/test_iteration_budget_race.py b/tests/run_agent/test_iteration_budget_race.py new file mode 100644 index 00000000000..e8aa70fbf6f --- /dev/null +++ b/tests/run_agent/test_iteration_budget_race.py @@ -0,0 +1,109 @@ +"""Tests for IterationBudget thread safety. + +The `used` property must acquire the lock before reading `_used` to prevent +data races with concurrent `consume()` / `refund()` calls. +""" +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + + +def test_iteration_budget_used_is_thread_safe(): + """Iterating `used` while other threads consume/refund must not crash. + + Before the fix, `used` returned `_used` directly without holding the lock, + so a concurrent `consume()` could observe a partially-updated value or + cause the C-level `list.append` to raise a ValueError ("list size changed"). + """ + from run_agent import IterationBudget + + budget = IterationBudget(max_total=1000) + num_threads = 10 + operations_per_thread = 200 + + errors = [] + + def worker(consume: bool): + try: + for _ in range(operations_per_thread): + if consume: + budget.consume() + else: + budget.refund() + # Also read `used` to exercise the property + _ = budget.used + except Exception as exc: + errors.append(exc) + + with ThreadPoolExecutor(max_workers=num_threads * 2) as executor: + # Half the threads consume, half refund + futures = [] + for i in range(num_threads): + consume = i < num_threads // 2 + futures.append(executor.submit(worker, consume)) + futures.append(executor.submit(worker, consume)) + + for f in futures: + f.result() + + assert not errors, f"Thread safety violation: {errors}" + # Final value should be within expected bounds + assert 0 <= budget.used <= budget.max_total + + +def test_iteration_budget_consume_returns_false_when_exhausted(): + """consume() must return False once the budget is exhausted.""" + from run_agent import IterationBudget + + budget = IterationBudget(max_total=3) + assert budget.consume() is True + assert budget.consume() is True + assert budget.consume() is True + assert budget.consume() is False + + +def test_iteration_budget_refund_restores_consume(): + """refund() after consume() must allow one more consume().""" + from run_agent import IterationBudget + + budget = IterationBudget(max_total=2) + assert budget.consume() is True + assert budget.consume() is True + assert budget.consume() is False # exhausted + budget.refund() + assert budget.consume() is True + + +def test_iteration_budget_used_reflects_consume_and_refund(): + """used property must accurately reflect consume() and refund() calls.""" + from run_agent import IterationBudget + + budget = IterationBudget(max_total=10) + + assert budget.used == 0 + budget.consume() + assert budget.used == 1 + budget.consume() + assert budget.used == 2 + budget.refund() + assert budget.used == 1 + budget.refund() + assert budget.used == 0 + + +def test_iteration_budget_remaining(): + """remaining property must equal max_total - used.""" + from run_agent import IterationBudget + + budget = IterationBudget(max_total=5) + + assert budget.remaining == 5 + budget.consume() + assert budget.remaining == 4 + budget.consume() + budget.consume() + assert budget.remaining == 2 + budget.refund() + assert budget.remaining == 3 From c050ee6573248057b26dc8e2852fa58d051132eb Mon Sep 17 00:00:00 2001 From: Yoimex <yoimexex@gmail.com> Date: Sat, 25 Apr 2026 08:43:03 +0300 Subject: [PATCH 21/28] fix(file_ops): resolve search_files path/line collision for hyphenated numeric filenames --- .../tools/test_file_operations_edge_cases.py | 66 ++++++++++++++++++- tools/file_operations.py | 47 +++++++++---- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/tests/tools/test_file_operations_edge_cases.py b/tests/tools/test_file_operations_edge_cases.py index 8a4378d2fa0..a53450a8143 100644 --- a/tests/tools/test_file_operations_edge_cases.py +++ b/tests/tools/test_file_operations_edge_cases.py @@ -8,7 +8,7 @@ Covers: import pytest from unittest.mock import MagicMock, patch -from tools.file_operations import ShellFileOperations +from tools.file_operations import ShellFileOperations, _parse_search_context_line # ========================================================================= @@ -204,3 +204,67 @@ class TestPaginationBounds: rg_commands = [cmd for cmd in commands if cmd.startswith("rg --files")] assert rg_commands assert "| head -n 1" in rg_commands[0] + + +# ========================================================================= +# Search context parsing +# ========================================================================= + + +class TestSearchContextParsing: + def test_parse_search_context_line_prefers_rightmost_numeric_separator(self): + parsed = _parse_search_context_line("dir/file-12-name.py-8-context here") + + assert parsed == ("dir/file-12-name.py", 8, "context here") + + def test_search_with_rg_context_handles_filename_with_dash_digits(self): + env = MagicMock() + env.cwd = "/tmp" + ops = ShellFileOperations(env) + + with patch.object(ops, "_exec") as mock_exec: + mock_exec.return_value = MagicMock( + exit_code=0, + stdout="dir/file-12-name.py-8-context here\n", + ) + result = ops._search_with_rg( + "needle", + path=".", + file_glob=None, + limit=10, + offset=0, + output_mode="content", + context=1, + ) + + assert result.error is None + assert result.total_count == 1 + assert result.matches[0].path == "dir/file-12-name.py" + assert result.matches[0].line_number == 8 + assert result.matches[0].content == "context here" + + def test_search_with_grep_context_handles_filename_with_dash_digits(self): + env = MagicMock() + env.cwd = "/tmp" + ops = ShellFileOperations(env) + + with patch.object(ops, "_exec") as mock_exec: + mock_exec.return_value = MagicMock( + exit_code=0, + stdout="dir/file-12-name.py-8-context here\n", + ) + result = ops._search_with_grep( + "needle", + path=".", + file_glob=None, + limit=10, + offset=0, + output_mode="content", + context=1, + ) + + assert result.error is None + assert result.total_count == 1 + assert result.matches[0].path == "dir/file-12-name.py" + assert result.matches[0].line_number == 8 + assert result.matches[0].content == "context here" diff --git a/tools/file_operations.py b/tools/file_operations.py index 627fdf96785..6c6dd91c691 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -215,6 +215,31 @@ class ExecuteResult: exit_code: int = 0 +def _parse_search_context_line(line: str) -> tuple[str, int, str] | None: + """Parse grep/rg context output in ``path-line-content`` format. + + Context lines are ambiguous because filenames may legitimately contain + ``-<digits>-`` segments. Prefer the rightmost numeric separator so a path + like ``dir/file-12-name.py-8-context`` resolves to + ``dir/file-12-name.py`` line ``8`` instead of truncating at ``file``. + """ + if not line or line == "--": + return None + + match = None + for candidate in re.finditer(r'-(\d+)-', line): + match = candidate + + if match is None: + return None + + path = line[:match.start()] + if not path: + return None + + return path, int(match.group(1)), line[match.end():] + + # ============================================================================= # Abstract Interface # ============================================================================= @@ -1185,7 +1210,6 @@ class ShellFileOperations(FileOperations): # Note: on Windows, paths contain drive letters (e.g. C:\path), # so naive split(":") breaks. Use regex to handle both platforms. _match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$') - _ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$') matches = [] for line in result.stdout.strip().split('\n'): if not line or line == "--": @@ -1204,12 +1228,12 @@ class ShellFileOperations(FileOperations): # Try context line (dash-separated: file-line-content) # Only attempt if context was requested to avoid false positives if context > 0: - m = _ctx_re.match(line) - if m: + parsed = _parse_search_context_line(line) + if parsed: matches.append(SearchMatch( - path=(m.group(1) or '') + m.group(2), - line_number=int(m.group(3)), - content=m.group(4)[:500] + path=parsed[0], + line_number=parsed[1], + content=parsed[2][:500] )) total = len(matches) @@ -1284,7 +1308,6 @@ class ShellFileOperations(FileOperations): # Note: on Windows, paths contain drive letters (e.g. C:\path), # so naive split(":") breaks. Use regex to handle both platforms. _match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$') - _ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$') matches = [] for line in result.stdout.strip().split('\n'): if not line or line == "--": @@ -1300,12 +1323,12 @@ class ShellFileOperations(FileOperations): continue if context > 0: - m = _ctx_re.match(line) - if m: + parsed = _parse_search_context_line(line) + if parsed: matches.append(SearchMatch( - path=(m.group(1) or '') + m.group(2), - line_number=int(m.group(3)), - content=m.group(4)[:500] + path=parsed[0], + line_number=parsed[1], + content=parsed[2][:500] )) From eadf34633e038c595dcf615845b992a45443e380 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:17:22 -0700 Subject: [PATCH 22/28] fix(models): strip :cloud/-cloud suffix from models.dev Ollama Cloud IDs models.dev appends :cloud and -cloud suffixes to Ollama Cloud model IDs (e.g. kimi-k2.6:cloud, qwen3-coder:480b-cloud) that the live Ollama Cloud API does not use. Without normalisation, these suffixed IDs bypass the dedup check and appear alongside the correct clean IDs, causing 400/404 errors when users select them in /model or hermes model. Add _strip_ollama_cloud_suffix() and apply it to mdev entries before the dedup merge in fetch_ollama_cloud_models() so all model IDs stored in the disk cache use the canonical form the API accepts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- hermes_cli/models.py | 20 +++- .../hermes_cli/test_ollama_cloud_provider.py | 97 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b1630b3d837..816af027895 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2906,6 +2906,19 @@ def fetch_api_models( _OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour +def _strip_ollama_cloud_suffix(model_id: str) -> str: + """Strip :cloud / -cloud suffixes that models.dev appends to Ollama Cloud IDs. + + The live API uses clean IDs (e.g. 'kimi-k2.6') while models.dev sometimes + returns them as 'kimi-k2.6:cloud'. Normalising before the dedup merge + prevents duplicate entries in the merged model list. + """ + for suffix in (":cloud", "-cloud"): + if model_id.endswith(suffix): + return model_id[: -len(suffix)] + return model_id + + def _ollama_cloud_cache_path() -> Path: """Return the path for the Ollama Cloud model cache.""" from hermes_constants import get_hermes_home @@ -3001,9 +3014,10 @@ def fetch_ollama_cloud_models( seen.add(m) merged.append(m) for m in mdev_models: - if m and m not in seen: - seen.add(m) - merged.append(m) + normalized = _strip_ollama_cloud_suffix(m) + if normalized and normalized not in seen: + seen.add(normalized) + merged.append(normalized) if merged: _save_ollama_cloud_cache(merged) return merged diff --git a/tests/hermes_cli/test_ollama_cloud_provider.py b/tests/hermes_cli/test_ollama_cloud_provider.py index f3702a417e7..e40ba8ccc86 100644 --- a/tests/hermes_cli/test_ollama_cloud_provider.py +++ b/tests/hermes_cli/test_ollama_cloud_provider.py @@ -401,6 +401,103 @@ class TestOllamaCloudProvidersNew: assert pdef.transport == "openai_chat" +# ── Cloud Suffix Stripping ── + +class TestOllamaCloudSuffixStripping: + """models.dev appends :cloud / -cloud suffixes that the live API omits. + + fetch_ollama_cloud_models() must normalise these before the dedup merge so + users never see broken IDs like 'kimi-k2.6:cloud' in the model picker. + """ + + def test_strips_colon_cloud_suffix(self, tmp_path, monkeypatch): + """:cloud suffix from models.dev is stripped before merge.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": {"kimi-k2.6:cloud": {"tool_call": True}} + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert "kimi-k2.6" in result + assert "kimi-k2.6:cloud" not in result + + def test_strips_dash_cloud_suffix(self, tmp_path, monkeypatch): + """-cloud suffix from models.dev is stripped before merge.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": {"qwen3-coder:480b-cloud": {"tool_call": True}} + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert "qwen3-coder:480b" in result + assert "qwen3-coder:480b-cloud" not in result + + def test_no_duplicate_when_live_clean_and_mdev_suffixed(self, tmp_path, monkeypatch): + """Live API returns clean ID; mdev has :cloud variant — result has exactly one entry.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + mock_mdev = { + "ollama-cloud": { + "models": { + "kimi-k2.6:cloud": {"tool_call": True}, + "glm-5.1:cloud": {"tool_call": True}, + } + } + } + with patch("hermes_cli.models.fetch_api_models", return_value=["kimi-k2.6", "glm-5.1"]), \ + patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result.count("kimi-k2.6") == 1 + assert result.count("glm-5.1") == 1 + assert "kimi-k2.6:cloud" not in result + assert "glm-5.1:cloud" not in result + + def test_unsuffixed_model_id_unchanged(self, tmp_path, monkeypatch): + """Model IDs without :cloud / -cloud suffix are passed through unchanged.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": {"nemotron-3-nano:30b": {"tool_call": True}} + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert "nemotron-3-nano:30b" in result + + def test_strip_suffix_helper(self): + """Unit test for the _strip_ollama_cloud_suffix helper.""" + from hermes_cli.models import _strip_ollama_cloud_suffix + + assert _strip_ollama_cloud_suffix("kimi-k2.6:cloud") == "kimi-k2.6" + assert _strip_ollama_cloud_suffix("glm-5.1:cloud") == "glm-5.1" + assert _strip_ollama_cloud_suffix("qwen3-coder:480b-cloud") == "qwen3-coder:480b" + assert _strip_ollama_cloud_suffix("nemotron-3-nano:30b") == "nemotron-3-nano:30b" + assert _strip_ollama_cloud_suffix("") == "" + + # ── Auxiliary Model ── class TestOllamaCloudAuxiliary: From 9cda237bb16fe5cfd1185cf381a8db4ce396cf77 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:39:19 -0700 Subject: [PATCH 23/28] docs(cron): lead with agent-driven setup for no-agent mode (#19871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped no-agent docs introduced the feature via CLI first and mentioned the chat path as a two-line afterthought. That buries the actual value prop: the cronjob tool exposes no_agent directly to the agent, so a user can describe a watchdog in plain language and Hermes wires up the script + schedule + delivery without anyone opening an editor. Changes: * cron-script-only.md: promote 'Create One from Chat' above 'Create One from the CLI', flesh it out with a worked transcript (the actual tool calls the agent makes), add subsections covering 'what the agent decides for you' (when to pick no_agent=True vs LLM mode) and 'managing watchdogs from chat' (pause/resume/edit/ remove all agent-accessible). * user-guide/features/cron.md: - Add 'no-agent mode' to the top-level feature list with a cross- link, plus a sentence up top making it clear everything is agent-accessible through the cronjob tool. - Add 'The agent sets these up for you' subsection to the no-agent section showing the exact tool call shape. * automate-with-cron.md: tighten the existing tip box to mention the agent-driven path, not just CLI scheduling. No behavior change — docs only. --- website/docs/guides/automate-with-cron.md | 2 +- website/docs/guides/cron-script-only.md | 76 +++++++++++++++++++---- website/docs/user-guide/features/cron.md | 21 +++++++ 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md index b47dae9378b..46becd88574 100644 --- a/website/docs/guides/automate-with-cron.md +++ b/website/docs/guides/automate-with-cron.md @@ -15,7 +15,7 @@ Cron jobs run in fresh agent sessions with no memory of your current chat. Promp ::: :::tip Don't need the LLM? Use no-agent mode. -For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. +For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you. ::: --- diff --git a/website/docs/guides/cron-script-only.md b/website/docs/guides/cron-script-only.md index 67ab178d7a7..06fa2880067 100644 --- a/website/docs/guides/cron-script-only.md +++ b/website/docs/guides/cron-script-only.md @@ -41,8 +41,72 @@ Use no-agent mode for: Use a normal (LLM-driven) cron job when you need the agent to **decide** what to say — summarize a long document, pick interesting items from a feed, draft a human-friendly message. The no-agent path is for cases where the script's stdout already IS the message. +## Create One from Chat + +The real win of no-agent mode is that the agent itself can set up the watchdog for you — no editor, no shell, no remembering CLI flags. You describe what you want, Hermes writes the script, schedules it, and tells you when it'll fire. + +### Example transcript + +> **You:** ping me on telegram if RAM is over 85% every 5 minutes +> +> **Hermes:** *(writes `~/.hermes/scripts/memory-watchdog.sh`, then calls `cronjob(...)` with `no_agent=true`)* +> +> Set up. Runs every 5 min, alerts Telegram only when RAM is over 85%. Script: `memory-watchdog.sh`. Job ID: `abc123`. + +Under the hood, the agent makes two tool calls: + +```python +# 1. Write the check script +write_file( + path="~/.hermes/scripts/memory-watchdog.sh", + content='''#!/usr/bin/env bash +ram_pct=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}') +if [ "$ram_pct" -ge 85 ]; then + echo "RAM ${ram_pct}% on $(hostname)" +fi +# Empty stdout = silent tick; no message sent. +''', +) + +# 2. Schedule it — no_agent=True skips the LLM on every tick +cronjob( + action="create", + schedule="every 5m", + script="memory-watchdog.sh", + no_agent=True, + deliver="telegram", + name="memory-watchdog", +) +``` + +From that point on every tick is free: the scheduler runs the script, pipes its stdout to Telegram if non-empty, and never touches a model. + +### What the agent decides for you + +When you phrase a request like "alert me when X" or "every N minutes check Y and tell me if Z", Hermes' `cronjob` tool description tells it to reach for `no_agent=True` whenever the message content is fully determined by the script. It falls back to the normal LLM-driven path when the request needs reasoning (*"summarize the new issues"*, *"pick the most interesting headlines"*, *"draft a friendly reminder"*). + +You don't have to specify `--no-agent` yourself. Just describe the behavior. + +### Managing watchdogs from chat + +The agent can pause, resume, edit, and remove jobs the same way it creates them: + +> **You:** stop the memory watchdog for tonight +> +> **Hermes:** *(calls `cronjob(action='pause', job_id='abc123')`)* +> +> Paused. Resume with "turn it back on" or via `hermes cron resume abc123`. + +> **You:** change it to every 15 minutes +> +> **Hermes:** *(calls `cronjob(action='update', job_id='abc123', schedule='every 15m')`)* + +The full lifecycle (create / list / update / pause / resume / run-now / remove) is available to the agent without you learning any CLI commands. + ## Create One from the CLI +Prefer the shell? The CLI path gives you the same result with three commands: + ```bash # 1. Write your script cat > ~/.hermes/scripts/memory-watchdog.sh <<'EOF' @@ -70,18 +134,6 @@ hermes cron run <job_id> # fire it once to test That's the whole thing. No prompt, no skill, no model. -## Create One from Chat - -You can also ask the agent to set one up conversationally. The `cronjob` tool now accepts a `no_agent` parameter: - -> *"Ping me on Telegram if RAM is over 85%, every 5 minutes."* - -The agent will: - -1. Write the check script to `~/.hermes/scripts/` via `write_file`. -2. Call `cronjob(action='create', schedule='every 5m', script='memory-watchdog.sh', no_agent=true, deliver='telegram')`. - -This is the same scheduler the agent already uses for LLM-driven jobs; `no_agent=true` just picks the script-only code path. ## How Script Output Maps to Delivery diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index cd6b4652bae..dd151dece76 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -17,6 +17,9 @@ Cron jobs can: - attach zero, one, or multiple skills to a job - deliver results back to the origin chat, local files, or configured platform targets - run in fresh agent sessions with the normal static tool list +- run in **no-agent mode** — a script on a schedule, its stdout delivered verbatim, zero LLM involvement (see the [no-agent mode](#no-agent-mode-script-only-jobs) section below) + +All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required. :::warning Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops. @@ -308,6 +311,24 @@ Semantics: `.sh` / `.bash` files run under `/bin/bash`; anything else under the current Python interpreter (`sys.executable`). Scripts must live in `~/.hermes/scripts/` (same sandboxing rule as the pre-run script gate). +### The agent sets these up for you + +The `cronjob` tool's schema exposes `no_agent` to Hermes directly, so you can describe a watchdog in chat and let the agent wire it up: + +```text +Ping me on Telegram if RAM is over 85%, every 5 minutes. +``` + +Hermes will write the check script to `~/.hermes/scripts/` via `write_file`, then call: + +```python +cronjob(action="create", schedule="every 5m", + script="memory-watchdog.sh", no_agent=True, + deliver="telegram", name="memory-watchdog") +``` + +It picks `no_agent=True` automatically when the message content is fully determined by the script (watchdogs, threshold alerts, heartbeats). The same tool also lets the agent pause, resume, edit, and remove jobs — so the whole lifecycle is chat-driven without anyone touching the CLI. + See the [Script-Only Cron Jobs guide](/docs/guides/cron-script-only) for worked examples. ## Provider recovery From a919269eb5c533cef80a2852e82a68472f5ad15d Mon Sep 17 00:00:00 2001 From: Steven Chanin <steven_chanin@alum.mit.edu> Date: Sat, 25 Apr 2026 22:34:12 -0700 Subject: [PATCH 24/28] fix(skills/email/himalaya): document v1.2.0 folder.aliases syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled himalaya skill documented folder aliases using a stale TOML schema (`[accounts.NAME.folder.alias]`, singular) that himalaya v1.2.0 silently ignores. The TOML parses without error, but the alias resolver never reads the sub-section — every lookup then falls through to the canonical folder name. Source: in `pimalaya/core` (the `email-lib` crate himalaya v1.2.0 depends on, currently v0.27.0), `email/src/folder/config.rs` defines `FolderConfig { aliases: Option<HashMap<String, String>>, ... }` (plural, no `#[serde(rename)]`/`alias` aliases, no `deny_unknown_fields`), and `account/config/mod.rs::get_folder_alias` returns the input verbatim when no alias is found. So the singular `alias` key deserializes to nothing and lookups silently fall through. On Gmail (where `sent` resolves to `[Gmail]/Sent Mail`, not `Sent`) this means save-to-Sent fails *after* SMTP delivery already succeeded, and `himalaya message send` exits non-zero. Any caller (agent, script, user) that retries on that exit code will re-run the entire send — including SMTP — producing duplicate emails to recipients. Silent ignore + caller-level retry is significantly worse than a config that just doesn't work. This commit updates SKILL.md and references/configuration.md to the v1.2.0 `folder.aliases.X` syntax (plural, dotted keys, directly under the account section), adds a Gmail-specific block with the `[Gmail]/Sent Mail`-style mapping, and adds notes on the failure mode so future readers don't hit the same trap. SKILL.md version bumped 1.0.0 → 1.1.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- skills/email/himalaya/SKILL.md | 22 ++++++++- .../himalaya/references/configuration.md | 47 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/skills/email/himalaya/SKILL.md b/skills/email/himalaya/SKILL.md index b04a4270df8..58a23ba7d9c 100644 --- a/skills/email/himalaya/SKILL.md +++ b/skills/email/himalaya/SKILL.md @@ -1,7 +1,7 @@ --- name: himalaya description: "Himalaya CLI: IMAP/SMTP email from terminal." -version: 1.0.0 +version: 1.1.0 author: community license: MIT metadata: @@ -71,8 +71,28 @@ message.send.backend.encryption.type = "start-tls" message.send.backend.login = "you@example.com" message.send.backend.auth.type = "password" message.send.backend.auth.cmd = "pass show email/smtp" + +# Folder aliases (himalaya v1.2.0+ syntax). Required whenever the +# server's folder names don't match himalaya's canonical names +# (inbox/sent/drafts/trash). Gmail is the common case — see +# `references/configuration.md` for the `[Gmail]/Sent Mail` mapping. +folder.aliases.inbox = "INBOX" +folder.aliases.sent = "Sent" +folder.aliases.drafts = "Drafts" +folder.aliases.trash = "Trash" ``` +> **Heads up on the alias syntax.** Pre-v1.2.0 docs used a +> `[accounts.NAME.folder.alias]` sub-section (singular `alias`). +> v1.2.0 silently ignores that form — TOML parses fine, but the +> alias resolver never reads it, so every lookup falls through to +> the canonical name. On Gmail this means save-to-Sent fails *after* +> SMTP delivery succeeds, and `himalaya message send` exits non-zero. +> Any caller (agent, script, user) that retries on that exit code +> will re-run the entire send — including SMTP — producing duplicate +> emails to recipients. Always use `folder.aliases.X` (plural, dotted +> keys, directly under `[accounts.NAME]`). + ## Hermes Integration Notes - **Reading, listing, searching, moving, deleting** all work directly through the terminal tool diff --git a/skills/email/himalaya/references/configuration.md b/skills/email/himalaya/references/configuration.md index 005a657d529..5ccba6cbc32 100644 --- a/skills/email/himalaya/references/configuration.md +++ b/skills/email/himalaya/references/configuration.md @@ -27,6 +27,13 @@ message.send.backend.encryption.type = "start-tls" message.send.backend.login = "user@example.com" message.send.backend.auth.type = "password" message.send.backend.auth.raw = "your-password" + +# Folder aliases — required whenever server folder names differ +# from himalaya's canonical names. See "Folder Aliases" below. +folder.aliases.inbox = "INBOX" +folder.aliases.sent = "Sent" +folder.aliases.drafts = "Drafts" +folder.aliases.trash = "Trash" ``` ## Password Options @@ -75,6 +82,16 @@ message.send.backend.encryption.type = "start-tls" message.send.backend.login = "you@gmail.com" message.send.backend.auth.type = "password" message.send.backend.auth.cmd = "pass show google/app-password" + +# Gmail folder mapping. Without these, save-to-Sent fails after +# SMTP delivery succeeds (Gmail's Sent folder is `[Gmail]/Sent Mail`, +# not `Sent`), and `himalaya message send` exits non-zero. Any +# caller that retries on that error will re-run SMTP — duplicate +# emails to recipients. Always include this block for Gmail. +folder.aliases.inbox = "INBOX" +folder.aliases.sent = "[Gmail]/Sent Mail" +folder.aliases.drafts = "[Gmail]/Drafts" +folder.aliases.trash = "[Gmail]/Trash" ``` **Note:** Gmail requires an App Password if 2FA is enabled. @@ -107,16 +124,42 @@ message.send.backend.auth.cmd = "pass show icloud/app-password" ## Folder Aliases -Map custom folder names: +Map himalaya's canonical folder names (`inbox`, `sent`, `drafts`, +`trash`) to whatever the server actually calls them. Use the +v1.2.0 `folder.aliases.X` syntax (plural, dotted keys, directly +under `[accounts.NAME]`): ```toml -[accounts.default.folder.alias] +[accounts.default] +# ... other account config ... + +folder.aliases.inbox = "INBOX" +folder.aliases.sent = "Sent" +folder.aliases.drafts = "Drafts" +folder.aliases.trash = "Trash" +``` + +The equivalent TOML sub-section form also works in v1.2.0: + +```toml +[accounts.default.folder.aliases] inbox = "INBOX" sent = "Sent" drafts = "Drafts" trash = "Trash" ``` +> **Don't use the singular `alias` form.** Pre-v1.2.0 docs showed +> `[accounts.NAME.folder.alias]` (singular). v1.2.0 silently +> ignores that sub-section — TOML parses without error, but the +> alias resolver never reads it. Every lookup then falls through +> to the canonical name. On Gmail (where `sent` is actually +> `[Gmail]/Sent Mail`) this means save-to-Sent fails *after* SMTP +> delivery succeeds, and `himalaya message send` exits non-zero. +> Any caller (agent, script, user) that retries on that error +> code will re-run the send — including SMTP — producing duplicate +> emails to recipients. Always use `folder.aliases.X` (plural). + ## Multiple Accounts ```toml From 645a2f482de6cefcf8694a5f2f896889804d3096 Mon Sep 17 00:00:00 2001 From: Harry Riddle <ntconguit@gmail.com> Date: Mon, 27 Apr 2026 01:22:51 +0700 Subject: [PATCH 25/28] fix(cli): fix shortcut config conflict in hermes_cli --- cli.py | 93 +++++++++++++++++++++++++++++++++++++++++++- hermes_cli/config.py | 1 + 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 3b9f6af5311..96ecb8ecfd1 100644 --- a/cli.py +++ b/cli.py @@ -10483,7 +10483,98 @@ class HermesCLI: else: self._should_exit = True event.app.exit() - + + @kb.add('c-S-c') # Ctrl+Shift+C + def handle_ctrl_shift_c(event): + """Copy text to clipboard (terminal-native). + + This is a no-op at the application level. Terminal emulators + handle the actual copy operation when Ctrl+Shift+C is pressed. + This binding prevents Hermes from intercepting the keystroke + as an interrupt signal. + + On macOS the standard copy shortcut is Cmd+C (no Hermes binding + needed). On Linux/Windows Ctrl+Shift+C is the conventional + terminal copy shortcut. + """ + return # No-op — let the terminal perform native copy + + @kb.add('c-q') # Ctrl+Q + def handle_ctrl_q(event): + """Alternative interrupt/exit shortcut (Ctrl+Q). + + Behaves like Ctrl+C: cancels active prompts, interrupts the + running agent, or clears the input buffer. Does not support + the double-press 'force exit' feature of Ctrl+C. + """ + # Cancel active voice recording. + _should_cancel_voice = False + _recorder_ref = None + with cli_ref._voice_lock: + if cli_ref._voice_recording and cli_ref._voice_recorder: + _recorder_ref = cli_ref._voice_recorder + cli_ref._voice_recording = False + cli_ref._voice_continuous = False + _should_cancel_voice = True + if _should_cancel_voice: + _cprint(f"\n{_DIM}Recording cancelled.{_RST}") + threading.Thread( + target=_recorder_ref.cancel, daemon=True + ).start() + event.app.invalidate() + return + + # Cancel sudo prompt + if self._sudo_state: + self._sudo_state["response_queue"].put("") + self._sudo_state = None + event.app.invalidate() + return + + # Cancel secret prompt + if self._secret_state: + self._cancel_secret_capture() + event.app.current_buffer.reset() + event.app.invalidate() + return + + # Cancel approval prompt (deny) + if self._approval_state: + self._approval_state["response_queue"].put("deny") + self._approval_state = None + event.app.invalidate() + return + + # Cancel /model picker + if self._model_picker_state: + self._close_model_picker() + event.app.current_buffer.reset() + event.app.invalidate() + return + + # Cancel clarify prompt + if self._clarify_state: + self._clarify_state["response_queue"].put( + "The user cancelled. Use your best judgement to proceed." + ) + self._clarify_state = None + self._clarify_freetext = False + event.app.current_buffer.reset() + event.app.invalidate() + return + + if self._agent_running and self.agent: + print("\n⚡ Interrupting agent...") + self.agent.interrupt() + else: + if event.app.current_buffer.text or self._attached_images: + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() + else: + self._should_exit = True + event.app.exit() + @kb.add('c-d') def handle_ctrl_d(event): """Ctrl+D: delete char under cursor (standard readline behaviour). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0f34d985280..8cf33b90fe3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -809,6 +809,7 @@ DEFAULT_CONFIG = { "enabled": False, "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, + "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" }, # Web dashboard settings From 60b143e9dfca5f90e2ecb09391a7f1832ee592e1 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:32:41 -0700 Subject: [PATCH 26/28] fix(tui_gateway): guard sys.path against local package shadowing (#15989) When the TUI backend (tui_gateway/entry.py) is spawned by Node.js with the user's CWD containing a local utils/ directory, that directory shadows the installed utils module, causing ImportError in run_agent and hermes_cli. Strip '' and '.' from sys.path and prepend HERMES_PYTHON_SRC_ROOT (already set by hermes_cli before spawning the subprocess) so installed packages always win over CWD artifacts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- tests/tui_gateway/test_entry_sys_path.py | 101 +++++++++++++++++++++++ tui_gateway/entry.py | 15 +++- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/tui_gateway/test_entry_sys_path.py diff --git a/tests/tui_gateway/test_entry_sys_path.py b/tests/tui_gateway/test_entry_sys_path.py new file mode 100644 index 00000000000..f8741b18e4b --- /dev/null +++ b/tests/tui_gateway/test_entry_sys_path.py @@ -0,0 +1,101 @@ +"""Tests for tui_gateway/entry.py sys.path hardening (issue #15989). + +When the TUI backend is spawned by Node.js, the Python interpreter may have +'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD +to shadow the installed utils module. entry.py must sanitize sys.path before +any non-stdlib import is resolved. +""" + +import importlib +import os +import sys +from unittest.mock import patch + + +def _reload_entry_with_env(env_overrides: dict) -> None: + """Re-execute entry.py's module-level path setup under a controlled env.""" + # We only want to exercise the sys.path fixup block, not the signal/import + # machinery that follows. We do this by running the fixup code verbatim in + # a fresh copy of sys.path rather than importing the real module (which + # would trigger tui_gateway.server imports requiring heavy mocks). + original_path = sys.path[:] + original_env = {k: os.environ.get(k) for k in env_overrides} + try: + with patch.dict(os.environ, env_overrides, clear=False): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + return sys.path[:] + finally: + sys.path = original_path + for k, v in original_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def test_empty_string_and_dot_removed_from_sys_path(): + original = sys.path[:] + try: + sys.path.insert(0, "") + sys.path.insert(0, ".") + assert "" in sys.path + assert "." in sys.path + + # Run the entry.py fixup logic directly + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert "" not in sys.path + assert "." not in sys.path + finally: + sys.path = original + + +def test_hermes_src_root_inserted_at_front(): + original = sys.path[:] + try: + fake_root = "/fake/hermes/src" + with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert sys.path[0] == fake_root + finally: + sys.path = original + + +def test_src_root_not_duplicated_if_already_present(): + original = sys.path[:] + try: + fake_root = "/already/present" + sys.path.insert(0, fake_root) + count_before = sys.path.count(fake_root) + + with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert sys.path.count(fake_root) == count_before + finally: + sys.path = original + + +def test_no_src_root_env_does_not_crash(): + original = sys.path[:] + try: + env = {k: v for k, v in os.environ.items() if k != "HERMES_PYTHON_SRC_ROOT"} + with patch.dict(os.environ, {}, clear=True): + os.environ.update(env) + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + # No exception raised + finally: + sys.path = original diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index d3be53a6c4d..0fe87ca49c5 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -1,7 +1,18 @@ -import json import os -import signal import sys + +# Guard against a local utils/ (or other package) in CWD shadowing installed +# hermes modules. hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this +# subprocess; inserting it first ensures the installed packages win. +_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") +if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) +# Strip '' and '.' — both resolve to CWD at import time and can let a local +# directory shadow installed packages. +sys.path = [p for p in sys.path if p not in ("", ".")] + +import json +import signal import time import traceback From 81cd67829191beba42eb54589375670122e1b57b Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:15:09 -0700 Subject: [PATCH 27/28] fix(google-workspace): restore required_credential_files in SKILL.md (#16452) PR #9931 ("feat(google-workspace): add --from flag for custom sender display name") accidentally removed the required_credential_files frontmatter block that tells hermes to bind-mount google_token.json and google_client_secret.json into Docker and Modal remote terminals before running setup.py. Without this header the credential files are never registered in the session-scoped ContextVar, so get_credential_file_mounts() returns an empty list at container creation time and the OAuth files are invisible inside the sandbox. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- skills/productivity/google-workspace/SKILL.md | 7 +- .../test_google_workspace_credential_files.py | 102 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/skills/test_google_workspace_credential_files.py diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index be5c824d676..b141afe3973 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -1,9 +1,14 @@ --- name: google-workspace description: "Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python." -version: 1.0.0 +version: 1.0.1 author: Nous Research license: MIT +required_credential_files: + - path: google_token.json + description: Google OAuth2 token (created by setup script) + - path: google_client_secret.json + description: Google OAuth2 client credentials (downloaded from Google Cloud Console) metadata: hermes: tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth] diff --git a/tests/skills/test_google_workspace_credential_files.py b/tests/skills/test_google_workspace_credential_files.py new file mode 100644 index 00000000000..de59b2fe6e4 --- /dev/null +++ b/tests/skills/test_google_workspace_credential_files.py @@ -0,0 +1,102 @@ +"""Regression test: google-workspace SKILL.md must declare required_credential_files. + +PR #9931 accidentally removed the required_credential_files header, which broke +credential file mounting in Docker/Modal remote backends (#16452). This test +prevents the regression from silently reappearing. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +SKILL_MD = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/SKILL.md" +) + +_EXPECTED_PATHS = {"google_token.json", "google_client_secret.json"} + + +def _parse_frontmatter(content: str) -> dict: + from agent.skill_utils import parse_frontmatter + + fm, _ = parse_frontmatter(content) + return fm + + +class TestGoogleWorkspaceCredentialFiles: + def test_required_credential_files_present_in_skill_md(self): + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + entries = fm.get("required_credential_files") + assert entries, "required_credential_files missing from google-workspace SKILL.md" + assert isinstance(entries, list), "required_credential_files must be a list" + paths = { + (e["path"] if isinstance(e, dict) else e) + for e in entries + } + assert _EXPECTED_PATHS <= paths, ( + f"Missing entries in required_credential_files: {_EXPECTED_PATHS - paths}" + ) + + def test_entries_are_registered_when_files_exist(self, tmp_path): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "google_token.json").write_text("{}") + (hermes_home / "google_client_secret.json").write_text("{}") + + from tools.credential_files import ( + clear_credential_files, + get_credential_file_mounts, + register_credential_files, + ) + + clear_credential_files() + try: + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + entries = fm.get("required_credential_files", []) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files(entries) + + assert missing == [], f"Unexpected missing files: {missing}" + mounts = get_credential_file_mounts() + container_paths = {m["container_path"] for m in mounts} + assert "/root/.hermes/google_token.json" in container_paths + assert "/root/.hermes/google_client_secret.json" in container_paths + finally: + clear_credential_files() + + def test_missing_token_is_reported(self, tmp_path): + """google_token.json absent (first-time setup) — reported as missing, client secret still mounts.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "google_client_secret.json").write_text("{}") + + from tools.credential_files import ( + clear_credential_files, + get_credential_file_mounts, + register_credential_files, + ) + + clear_credential_files() + try: + content = SKILL_MD.read_text(encoding="utf-8") + fm = _parse_frontmatter(content) + entries = fm.get("required_credential_files", []) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + missing = register_credential_files(entries) + + assert "google_token.json" in missing + mounts = get_credential_file_mounts() + container_paths = {m["container_path"] for m in mounts} + assert "/root/.hermes/google_client_secret.json" in container_paths + assert "/root/.hermes/google_token.json" not in container_paths + finally: + clear_credential_files() From 8fabef9d358cdaa97408f4f00e0dc6e3511ae97d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 12:57:01 -0700 Subject: [PATCH 28/28] fix(docs): register cron-script-only guide in sidebar (#19893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #19709 added website/docs/guides/cron-script-only.md but never added the entry to website/sidebars.ts, which is explicitly enumerated (not autogenerated). Two consequences: 1. The guide didn't show up in the left-nav "Guides & Tutorials" list — users could only reach it via cross-links from other pages. 2. Landing on the guide page directly made the sidebar disappear entirely (Docusaurus treats unregistered docs as orphaned and renders them without their parent sidebar). Added 'guides/cron-script-only' next to 'guides/automate-with-cron' so it slots in alongside the other cron content. Verified with `npm run build`: no orphan warnings, no broken links, page builds with sidebar intact. No content change, docs only. --- website/sidebars.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/website/sidebars.ts b/website/sidebars.ts index 8ac1e33c878..c30fec6c527 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -168,6 +168,7 @@ const sidebars: SidebarsConfig = { 'guides/use-voice-mode-with-hermes', 'guides/build-a-hermes-plugin', 'guides/automate-with-cron', + 'guides/cron-script-only', 'guides/automation-templates', 'guides/cron-troubleshooting', 'guides/work-with-skills',