diff --git a/agent/title_generator.py b/agent/title_generator.py index a7f1e158e1a6..73db795a15ea 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -130,6 +130,65 @@ def auto_title_session( logger.debug("Failed to set auto-generated title: %s", e) +def maybe_retitle_session( + session_db, + session_id: str, + user_message: str, + assistant_response: str, + conversation_history: list, + failure_callback: Optional[FailureCallback] = None, + main_runtime: dict = None, + title_callback: Optional[TitleCallback] = None, + every_n_turns: int = 6, +) -> None: + """Periodically re-evaluate a session's title to keep it relevant as the + conversation evolves. Fires every ``every_n_turns`` user turns AFTER the + initial auto-title (so first-turn handling stays exclusively with + :func:`maybe_auto_title`). + + Cheap path: + - Only runs every Nth turn. + - Only generates if conversation_history has at least 3 user messages. + - Compares to the existing title; if the new title differs meaningfully, + it's saved and the callback fires (which drives the thread rename). + """ + if not session_db or not session_id or not user_message or not assistant_response: + return + user_msg_count = sum(1 for m in (conversation_history or []) if m.get("role") == "user") + # First-turn is handled by maybe_auto_title; only act on 3rd+ user turns. + if user_msg_count < 3: + return + if every_n_turns <= 0 or (user_msg_count % every_n_turns) != 0: + return + + def _runner(): + try: + existing = session_db.get_session_title(session_id) or "" + except Exception: + return + new_title = generate_title( + user_message, assistant_response, + failure_callback=failure_callback, main_runtime=main_runtime, + ) + if not new_title: + return + new_title = new_title.strip() + if not new_title or new_title.lower() == existing.strip().lower(): + return + try: + session_db.set_session_title(session_id, new_title) + except Exception: + return + if title_callback is not None: + try: + title_callback(new_title) + except Exception: + logger.debug("Retitle callback failed", exc_info=True) + + thread = threading.Thread(target=_runner, daemon=True, name="retitle") + thread.start() + + def maybe_auto_title( session_db, session_id: str, diff --git a/cli.py b/cli.py index 241d41e9fcd6..e023d6cdfe7b 100644 --- a/cli.py +++ b/cli.py @@ -436,7 +436,24 @@ def load_cli_config() -> Dict[str, Any]: # only used as a FALLBACK when model.provider / model.base_url # is not already set — never as an override. The canonical # location is model.provider (written by `hermes model`). - if not defaults["model"].get("provider"): + # + # Special case: when `model:` is written as a STRING (short + # form — see every profile config in profiles/*/config.yaml), + # the dict has no provider slot at all, so model.provider stays + # at the hardcoded "auto" default and the truthy check below + # short-circuits. That leaves the root-level `provider:` — + # the user's only way to specify provider in the short form — + # silently dropped. Every kanban worker subprocess then hits + # AuthError on the primary provider and falls through to the + # configured fallback chain (regression flooding profile + # error logs throughout May 2026 with thousands of "Primary + # provider auth failed" warnings). + _model_was_string = isinstance(file_config.get("model"), str) + _cur_provider = (defaults["model"].get("provider") or "").strip().lower() + _provider_unset = (not _cur_provider) or ( + _model_was_string and _cur_provider == "auto" + ) + if _provider_unset: root_provider = file_config.get("provider") if root_provider: defaults["model"]["provider"] = root_provider diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index c6bdc38c3b92..11ce5f95ed36 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -947,6 +947,8 @@ class MessageEvent: # Reply context reply_to_message_id: Optional[str] = None reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection) + reply_to_channel_id: Optional[str] = None # Channel where the replied-to message lives (may differ from current chat — e.g. user replied in a parent channel from inside a thread, or vice versa) + reply_to_author: Optional[str] = None # Display name of the replied-to message author # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, # Discord channel_skill_bindings). A single name or ordered list. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index a3904630fa96..53007998d088 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -4476,7 +4476,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: if not is_thread and not isinstance(message.channel, discord.DMChannel): no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "") no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} - skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel + skip_thread = bool(channel_ids & no_thread_channels) auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"} is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: @@ -4696,10 +4696,29 @@ async def _handle_message(self, message: DiscordMessage) -> None: reply_to_id = None reply_to_text = None + reply_to_channel_id = None + reply_to_author = None if message.reference: reply_to_id = str(message.reference.message_id) + # message.reference.channel_id is the channel the referenced message + # lives in. When the user replies to a parent-channel message from + # inside a thread (or vice-versa), this differs from message.channel.id. + ref_chan = getattr(message.reference, "channel_id", None) + if ref_chan: + reply_to_channel_id = str(ref_chan) if message.reference.resolved: reply_to_text = getattr(message.reference.resolved, "content", None) or None + ref_author = getattr(message.reference.resolved, "author", None) + if ref_author is not None: + reply_to_author = ( + getattr(ref_author, "display_name", None) + or getattr(ref_author, "global_name", None) + or getattr(ref_author, "name", None) + ) + # Fall back to the current channel when Discord didn't set channel_id + # on the reference (older payloads). + if reply_to_id and not reply_to_channel_id: + reply_to_channel_id = _chan_id or None event = MessageEvent( text=event_text, @@ -4711,6 +4730,8 @@ async def _handle_message(self, message: DiscordMessage) -> None: media_types=media_types, reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, + reply_to_channel_id=reply_to_channel_id, + reply_to_author=reply_to_author, timestamp=message.created_at, auto_skill=_skills, channel_prompt=_channel_prompt, diff --git a/gateway/run.py b/gateway/run.py index f9a282a413fb..576723581a28 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1746,6 +1746,21 @@ def _is_telegram_topic_lane(self, source: SessionSource) -> bool: return False return True + def _is_discord_thread_lane(self, source: SessionSource) -> bool: + """True for a Discord thread (auto-created or otherwise) that we can rename. + + Used to decide whether session-title auto-generation should also drive a + live thread rename. DMs and plain text channels are excluded — only + actual thread surfaces. + """ + if source.platform != Platform.DISCORD: + return False + if source.chat_type != "thread": + return False + if not source.chat_id or not source.thread_id: + return False + return True + _TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0 def _should_send_telegram_lobby_reminder(self, source: SessionSource) -> bool: @@ -7030,8 +7045,33 @@ async def _prepare_inbound_message_text( # is referencing. History can contain the same or similar text # multiple times, and without an explicit pointer the agent has to # guess (or answer for both subjects). Token overhead is minimal. - reply_snippet = event.reply_to_text[:500] - message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + # + # Cap the inline quote at 1500 chars to bound prompt cost. If the + # quoted message is longer (or the user wants surrounding context), + # the agent can call discord.fetch_messages with around= + # to pull the full message and its neighbors. + INLINE_REPLY_QUOTE_CAP = 1500 + full_quote = event.reply_to_text + reply_snippet = full_quote[:INLINE_REPLY_QUOTE_CAP] + truncated = len(full_quote) > INLINE_REPLY_QUOTE_CAP + + author = getattr(event, "reply_to_author", None) + ref_chan = getattr(event, "reply_to_channel_id", None) or source.chat_id + ref_msg = event.reply_to_message_id + author_part = f" by {author}" if author else "" + trunc_note = ( + f" (truncated from {len(full_quote)} chars — call discord(action='fetch_messages', " + f"channel_id='{ref_chan}', around='{ref_msg}', limit=1) for the full message)" + if truncated else "" + ) + pointer = ( + f"[Replying to message {ref_msg}{author_part} " + f"in channel {ref_chan}{trunc_note}: \"{reply_snippet}\"]\n" + f"[If you need more surrounding context, call " + f"discord(action='fetch_messages', channel_id='{ref_chan}', " + f"around='{ref_msg}', limit=20).]\n\n" + ) + message_text = f"{pointer}{message_text}" if "@" in message_text: try: @@ -11423,6 +11463,119 @@ def _log_rename_failure(fut) -> None: future.add_done_callback(_log_rename_failure) + # ------------------------------------------------------------------ + # Discord thread auto-rename (parallel to the Telegram-topic path) + # ------------------------------------------------------------------ + + _DISCORD_THREAD_NAME_MAX = 100 # Discord hard limit + _DISCORD_RENAME_DEDUPE_TTL_S = 30.0 # ignore identical follow-up renames + + def _sanitize_discord_thread_name(self, title: str) -> str: + cleaned = (title or "").strip().replace("\n", " ").replace("\r", " ") + # Collapse runs of whitespace. + cleaned = " ".join(cleaned.split()) + if not cleaned: + return cleaned + return cleaned[: self._DISCORD_THREAD_NAME_MAX] + + async def _rename_discord_thread_for_session_title( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Best-effort rename of a Discord thread when Hermes (re)titles a session.""" + if not self._is_discord_thread_lane(source): + return + adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + if adapter is None: + return + client = getattr(adapter, "_client", None) + if client is None: + return + new_name = self._sanitize_discord_thread_name(title) + if not new_name: + return + + try: + thread_id_int = int(source.thread_id) + except (TypeError, ValueError): + return + + try: + channel = client.get_channel(thread_id_int) + if channel is None: + channel = await client.fetch_channel(thread_id_int) + if channel is None: + return + current_name = getattr(channel, "name", None) + if current_name == new_name: + return + edit = getattr(channel, "edit", None) + if not callable(edit): + return + await edit(name=new_name, reason="Hermes auto-title") + logger.debug( + "Renamed Discord thread %s: %r -> %r", + source.thread_id, + current_name, + new_name, + ) + except Exception: + logger.debug("Failed to rename Discord thread for auto-title", exc_info=True) + + def _schedule_discord_thread_rename( + self, + source: SessionSource, + session_id: str, + title: str, + ) -> None: + """Schedule a Discord thread rename from the auto-title background thread.""" + if not title or not self._is_discord_thread_lane(source): + return + + # Dedupe identical rename requests within a short window so the + # periodic re-title path doesn't spam Discord's rate limiter when + # the title hasn't actually changed. + if not hasattr(self, "_discord_thread_rename_cache"): + self._discord_thread_rename_cache = {} + cache_key = f"{source.chat_id}:{source.thread_id}" + normalized = self._sanitize_discord_thread_name(title) + import time as _time + now = _time.monotonic() + prev = self._discord_thread_rename_cache.get(cache_key) + if prev and prev[0] == normalized and (now - prev[1]) < self._DISCORD_RENAME_DEDUPE_TTL_S: + return + self._discord_thread_rename_cache[cache_key] = (normalized, now) + + 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 = safe_schedule_threadsafe( + self._rename_discord_thread_for_session_title(copied_source, session_id, title), + loop, + logger=logger, + log_message="Discord thread title rename failed to schedule", + ) + if future is None: + return + + def _log_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Discord thread title rename failed", exc_info=True) + + future.add_done_callback(_log_rename_failure) + + _TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0 def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: @@ -15860,6 +16013,12 @@ def _approval_notify_sync(approval_data: dict) -> None: effective_session_id, title, ) + elif self._is_discord_thread_lane(source): + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_thread_rename( + source, + effective_session_id, + title, + ) maybe_auto_title( self._session_db, effective_session_id, @@ -15868,6 +16027,22 @@ def _approval_notify_sync(approval_data: dict) -> None: all_msgs, **maybe_auto_title_kwargs, ) + # Periodic re-title — fires only after the conversation + # has accumulated enough turns. Reuses the same callback + # so Discord threads (and Telegram topics) get renamed + # whenever the topic genuinely drifts. + try: + from agent.title_generator import maybe_retitle_session + maybe_retitle_session( + self._session_db, + effective_session_id, + message, + final_response, + all_msgs, + **maybe_auto_title_kwargs, + ) + except Exception: + pass except Exception: pass diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a865bcaf8be2..ad77da6c727f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2185,7 +2185,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) Environment="USER={username}" Environment="LOGNAME={username}" Environment="PATH={sane_path}" -Environment="VIRTUAL_ENV={venv_dir}" Environment="HERMES_HOME={hermes_home}" Restart=always RestartSec=5 @@ -2220,7 +2219,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) ExecStart={python_path} -m hermes_cli.main{f" {profile_arg}" if profile_arg else ""} gateway run --replace WorkingDirectory={working_dir} Environment="PATH={sane_path}" -Environment="VIRTUAL_ENV={venv_dir}" Environment="HERMES_HOME={hermes_home}" Restart=always RestartSec=5 diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 8417d64e746a..f24912727510 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -430,7 +430,8 @@ def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch): assert cfg["model"]["provider"] == "openrouter" def test_root_provider_ignored_when_default_model_provider_exists(self, tmp_path, monkeypatch): - """Even when model.provider is the default 'auto', root-level provider is ignored.""" + """Even when model.provider is the default 'auto', root-level provider is ignored + when `model:` is written in the dict form (the canonical "place provider here" shape).""" import yaml hermes_home = tmp_path / ".hermes" @@ -453,6 +454,35 @@ def test_root_provider_ignored_when_default_model_provider_exists(self, tmp_path # Root-level "opencode-go" must NOT leak through assert cfg["model"]["provider"] != "opencode-go" + def test_root_provider_honored_when_model_is_string(self, tmp_path, monkeypatch): + """When `model:` is a STRING (short form), the dict has no provider slot — + the root-level `provider:` is the user's only way to specify it and must be honored. + + Regression: every profile config in profiles/*/config.yaml uses the short form + plus a root-level `provider: copilot`. Pre-fix, model.provider stayed at the + hardcoded "auto" default and the legacy fallback short-circuited (because "auto" + is truthy), causing every kanban worker subprocess to hit AuthError on the + primary provider and fall through to the fallback chain. + """ + import yaml + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config_path = hermes_home / "config.yaml" + config_path.write_text(yaml.safe_dump({ + "model": "claude-opus-4.7-1m-internal", # short form + "provider": "copilot", # root-level (only place it can go) + })) + + import cli + monkeypatch.setattr(cli, "_hermes_home", hermes_home) + cfg = cli.load_cli_config() + + assert cfg["model"]["default"] == "claude-opus-4.7-1m-internal" + assert cfg["model"]["provider"] == "copilot" + def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch): """Classic CLI must expose terminal.vercel_runtime to terminal_tool.py.""" import yaml diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 6fb012ff8072..5fc5f32c022c 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1304,7 +1304,13 @@ def test_systemd_unit_uses_dot_venv_when_detected(self, tmp_path, monkeypatch): unit = gateway_cli.generate_systemd_unit(system=False) - assert f"VIRTUAL_ENV={dot_venv}" in unit + # VIRTUAL_ENV must NOT be set on the gateway unit — it leaks into + # every subprocess and causes `uv`/`pip`/`poetry` from agent terminal + # tool calls (in any user project) to rebuild the Hermes venv against + # that project's pyproject.toml, wiping all Hermes deps. The gateway + # invokes python by absolute path, so sys.prefix resolves correctly + # without VIRTUAL_ENV being set. + assert "VIRTUAL_ENV" not in unit assert f"{dot_venv}/bin" in unit # Must NOT contain a hardcoded /venv/ path assert "/venv/" not in unit or "/.venv/" in unit diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index c31ae6f08bb9..9d155d34e5bf 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1139,3 +1139,156 @@ def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path): out = kt._handle_complete({"task_id": tid, "summary": "orchestrator close"}) d = json.loads(out) assert d.get("ok") is True and d.get("task_id") == tid + + +# --------------------------------------------------------------------------- +# Origin auto-subscribe on kanban_create +# +# When a task is created inside a gateway-routed agent run, the originating +# conversation (Discord thread, Telegram topic, Slack thread, etc.) should +# be auto-subscribed so terminal events phone home to that exact thread — +# no cron, no skill, no manual /kanban notify-subscribe step. +# +# When a kanban worker fans out child cards, the children should inherit +# the parent's subscriptions so the originating conversation stays the +# manager of the whole subtree. +# --------------------------------------------------------------------------- + +def _create_and_get_subs(monkeypatch=None, **create_kwargs): + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + out = kt._handle_create({ + "title": create_kwargs.pop("title", "child"), + "assignee": create_kwargs.pop("assignee", "test-worker"), + **create_kwargs, + }) + d = json.loads(out) + assert d.get("ok") is True, out + tid = d["task_id"] + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, tid) + finally: + conn.close() + return tid, subs + + +def test_create_auto_subscribes_live_discord_thread(monkeypatch, worker_env): + """Live gateway session sets HERMES_SESSION_* → new task gets a + notify_sub pointing at the exact (platform, chat, thread).""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "1501836547777888346") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "1505284820404539452") + + _, subs = _create_and_get_subs(title="discord-origin") + assert len(subs) == 1 + s = subs[0] + assert s["platform"] == "discord" + assert str(s["chat_id"]) == "1501836547777888346" + assert str(s["thread_id"]) == "1505284820404539452" + + +def test_create_auto_subscribes_channel_root_when_no_thread(monkeypatch, worker_env): + """No thread (channel-root message) still subscribes, with empty thread_id.""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "999") + monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) + + _, subs = _create_and_get_subs(title="channel-root") + assert len(subs) == 1 + assert subs[0]["platform"] == "discord" + assert str(subs[0]["chat_id"]) == "999" + assert str(subs[0]["thread_id"] or "") == "" + + +def test_create_no_subscribe_when_no_session_and_no_parent(monkeypatch, tmp_path): + """CLI usage with no live session and no HERMES_KANBAN_TASK → no sub. + Silent is correct — there is no originating conversation to notify.""" + home = tmp_path / ".hermes"; home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_PROFILE", "test-worker") + from pathlib import Path as _Path + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + for v in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_THREAD_ID"): + monkeypatch.delenv(v, raising=False) + + from hermes_cli import kanban_db as kb + kb._INITIALIZED_PATHS.clear() + kb.init_db() + + _, subs = _create_and_get_subs(title="silent") + assert subs == [] + + +def test_create_inherits_parent_subscriptions_on_worker_fanout(monkeypatch, worker_env): + """Worker (HERMES_KANBAN_TASK set) calling kanban_create with no live + session → child inherits parent's subs. The originating conversation + stays the manager of the whole subtree.""" + parent_tid = worker_env # fixture set HERMES_KANBAN_TASK to this + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + kb.add_notify_sub( + conn, task_id=parent_tid, platform="discord", + chat_id="111", thread_id="222", user_id="333", + notifier_profile="test-worker", + ) + finally: + conn.close() + + # No live session vars — fall back to parent inheritance. + for v in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_THREAD_ID"): + monkeypatch.delenv(v, raising=False) + + _, child_subs = _create_and_get_subs(title="child-of-worker") + assert len(child_subs) == 1 + s = child_subs[0] + assert s["platform"] == "discord" + assert str(s["chat_id"]) == "111" + assert str(s["thread_id"]) == "222" + + +def test_create_live_session_wins_over_parent_inheritance(monkeypatch, worker_env): + """When both signals exist, live session wins — operator may have + moved the conversation to a different thread mid-flight, and the + new thread is now the manager.""" + parent_tid = worker_env + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + kb.add_notify_sub( + conn, task_id=parent_tid, platform="discord", + chat_id="OLD_CHAT", thread_id="OLD_THREAD", + ) + finally: + conn.close() + + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "NEW_CHAT") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "NEW_THREAD") + + _, subs = _create_and_get_subs(title="live-wins") + assert len(subs) == 1 + assert str(subs[0]["chat_id"]) == "NEW_CHAT" + assert str(subs[0]["thread_id"]) == "NEW_THREAD" + + +def test_create_subscribe_is_idempotent_on_duplicate(monkeypatch, worker_env): + """Re-creating an effectively-identical sub (same task/platform/chat/ + thread) is a no-op at the DB layer — UNIQUE constraint protects us.""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "C") + monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "T") + tid, subs1 = _create_and_get_subs(title="once") + # Manually re-trigger the helper to simulate a retry. + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + kt._auto_subscribe_origin(kb, conn, tid) + subs2 = kb.list_notify_subs(conn, tid) + finally: + conn.close() + assert len(subs1) == 1 + assert len(subs2) == 1 diff --git a/tools/discord_tool.py b/tools/discord_tool.py index 1da43ac9140e..da4c7a0515e6 100644 --- a/tools/discord_tool.py +++ b/tools/discord_tool.py @@ -351,17 +351,26 @@ def _search_members(token: str, guild_id: str, query: str, limit: int = 20, **_k def _fetch_messages( token: str, channel_id: str, limit: int = 50, before: Optional[str] = None, after: Optional[str] = None, + around: Optional[str] = None, **_kwargs: Any, ) -> str: - """Fetch recent messages from a channel.""" + """Fetch recent messages from a channel. + + Anchors are mutually exclusive per Discord's API. ``around`` returns + messages centered on a specific snowflake (handy when the agent has a + replied-to ``message_id`` and wants surrounding context). + """ try: limit = int(limit) except (TypeError, ValueError): limit = 50 params: Dict[str, str] = {"limit": str(min(limit, 100))} - if before: + # Discord rejects combinations; prefer the most specific anchor. + if around: + params["around"] = around + elif before: params["before"] = before - if after: + elif after: params["after"] = after messages = _discord_request("GET", f"/channels/{channel_id}/messages", token, params=params) result = [] @@ -505,7 +514,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg ("list_roles", "(guild_id)", "roles sorted by position"), ("member_info", "(guild_id, user_id)", "lookup a specific member"), ("search_members", "(guild_id, query)", "find members by name prefix"), - ("fetch_messages", "(channel_id)", "recent messages; optional before/after snowflakes"), + ("fetch_messages", "(channel_id)", "recent messages; optional around/before/after snowflakes"), ("list_pins", "(channel_id)", "pinned messages in a channel"), ("pin_message", "(channel_id, message_id)", "pin a message"), ("unpin_message", "(channel_id, message_id)", "unpin a message"), @@ -707,6 +716,10 @@ def _build_schema( "type": "string", "description": "Snowflake ID for forward pagination (fetch_messages).", }, + "around": { + "type": "string", + "description": "Snowflake ID to anchor a fetch_messages call. Returns ~limit/2 messages before and after this message_id. Use this to pull context around a replied-to message — pass the reply_to message_id from the user's message.", + }, "auto_archive_duration": { "type": "integer", "enum": [60, 1440, 4320, 10080], diff --git a/tools/environments/local.py b/tools/environments/local.py index 3b9d65449faa..fca32c0dafbd 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -163,6 +163,21 @@ def _build_provider_env_blocklist() -> frozenset: "VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID", + # Venv / package-manager activation markers. If these leak into a + # terminal subprocess, `uv sync`, `uv run --active`, `pip install`, + # and `poetry install` treat Hermes' own venv as the active install + # target and rebuild it against whatever pyproject.toml the agent's + # cwd happens to contain — wiping every Hermes runtime dependency. + # The systemd unit no longer sets VIRTUAL_ENV (defense layer 1); + # this blocklist catches the case where the user invokes Hermes + # interactively with VIRTUAL_ENV already set in their shell. + "VIRTUAL_ENV", + "VIRTUAL_ENV_PROMPT", + "UV_PROJECT_ENVIRONMENT", + "POETRY_ACTIVE", + "PIPENV_ACTIVE", + "CONDA_PREFIX", + "CONDA_DEFAULT_ENV", }) return frozenset(blocked) diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index fab0a68c92ba..d49da9eabe72 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -551,11 +551,118 @@ def _handle_comment(args: dict, **kw) -> str: return tool_error(f"kanban_comment: {e}") +def _resolve_origin_subscriptions(kb, conn) -> list[dict[str, Any]]: + """Resolve the (platform, chat_id, thread_id, user_id) tuples that + represent the *originating conversation* for a new kanban task. + + Priority order: + + 1. **Live session context** — when a gateway-routed agent run is + active, ``gateway.session_context`` exposes the platform / chat / + thread the user is currently in via contextvars (mirrored into + ``HERMES_SESSION_*`` env vars for legacy callers). This is the + Discord thread / Telegram topic / Slack thread the user actually + sent the message in. Returns at most one subscription. + + 2. **Parent task inheritance** — when a kanban worker is fanning out + child cards (``HERMES_KANBAN_TASK`` set), copy the parent task's + current ``kanban_notify_subs`` so the entire subtree phones home + to whichever conversation originally spawned the work. Returns + all of the parent's subscriptions. + + Returns an empty list when neither source yields anything (e.g. raw + CLI usage with no live session and no parent task) — in which case + no notify subscription is created and the task simply runs silently. + """ + # --- 1. Live session (gateway-routed agent run) ---------------------- + platform = "" + chat_id = "" + thread_id = "" + user_id: Optional[str] = None + try: + from gateway.session_context import get_session_env + platform = (get_session_env("HERMES_SESSION_PLATFORM") or "").strip().lower() + chat_id = (get_session_env("HERMES_SESSION_CHAT_ID") or "").strip() + thread_id = (get_session_env("HERMES_SESSION_THREAD_ID") or "").strip() + user_id = (get_session_env("HERMES_SESSION_USER_ID") or "").strip() or None + except Exception: + # Fall back to bare env vars (CLI / cron contexts). + platform = (os.environ.get("HERMES_SESSION_PLATFORM") or "").strip().lower() + chat_id = (os.environ.get("HERMES_SESSION_CHAT_ID") or "").strip() + thread_id = (os.environ.get("HERMES_SESSION_THREAD_ID") or "").strip() + user_id = (os.environ.get("HERMES_SESSION_USER_ID") or "").strip() or None + + if platform and chat_id: + return [{ + "platform": platform, + "chat_id": chat_id, + "thread_id": thread_id or "", + "user_id": user_id, + }] + + # --- 2. Parent task inheritance (kanban worker fan-out) -------------- + parent_tid = os.environ.get("HERMES_KANBAN_TASK") + if not parent_tid: + return [] + try: + parent_subs = kb.list_notify_subs(conn, parent_tid) + except Exception as exc: + logger.debug("kanban auto-subscribe: parent lookup failed: %s", exc) + return [] + inherited: list[dict[str, Any]] = [] + for sub in parent_subs: + plat = (sub.get("platform") or "").strip().lower() + cid = str(sub.get("chat_id") or "").strip() + if not plat or not cid: + continue + inherited.append({ + "platform": plat, + "chat_id": cid, + "thread_id": str(sub.get("thread_id") or "").strip(), + "user_id": sub.get("user_id") or None, + }) + return inherited + + +def _auto_subscribe_origin(kb, conn, task_id: str) -> None: + """Best-effort: subscribe the originating conversation(s) to *task_id*. + + Never raises — a failed auto-subscribe must not break task creation. + The kanban_notify_subs UNIQUE constraint (task/platform/chat/thread) + makes this idempotent, so retries / duplicate origins are safe. + """ + subs = _resolve_origin_subscriptions(kb, conn) + if not subs: + return + notifier_profile = os.environ.get("HERMES_PROFILE") or None + for s in subs: + try: + kb.add_notify_sub( + conn, + task_id=task_id, + platform=s["platform"], + chat_id=s["chat_id"], + thread_id=s["thread_id"] or None, + user_id=s.get("user_id"), + notifier_profile=notifier_profile, + ) + except Exception as exc: + logger.debug( + "kanban auto-subscribe failed for task=%s platform=%s chat=%s: %s", + task_id, s.get("platform"), s.get("chat_id"), exc, + ) + + def _handle_create(args: dict, **kw) -> str: """Create a child task. Orchestrator workers use this to fan out. ``parents`` can be a list of task ids; dependency-gated promotion works as usual. + + On successful create, auto-subscribes the originating conversation + (live gateway session, else parent task's subs) to terminal events + via :func:`_auto_subscribe_origin`. Failures there are swallowed — + the task is still created. """ title = args.get("title") if not title or not str(title).strip(): @@ -614,6 +721,18 @@ def _handle_create(args: dict, **kw) -> str: created_by=os.environ.get("HERMES_PROFILE") or "worker", ) new_task = kb.get_task(conn, new_tid) + # Auto-subscribe the originating conversation (Discord thread, + # Telegram topic, Slack thread, etc.) so terminal events + # (completed/blocked/gave_up) land back in the chat that + # spawned the task. Two sources, in priority order: + # 1. The live session contextvars (HERMES_SESSION_*) — set + # when a gateway-routed agent run produced this task. + # 2. The parent task's existing subscriptions — set when a + # kanban worker (HERMES_KANBAN_TASK in env) is fanning + # out child cards. The thread that owns the parent owns + # the children too. This makes the originating + # conversation the durable manager of its subtree. + _auto_subscribe_origin(kb, conn, new_tid) return _ok( task_id=new_tid, status=new_task.status if new_task else None,