From 34d91f88f96b2c6ac5e2b36440e846b8a4fc5b3a Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sat, 27 Jun 2026 17:46:18 +0900 Subject: [PATCH 1/8] feat(gateway): add profile-based routing for inbound messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gateway.profile_routes config that routes specific Discord guilds/channels/threads (and other platforms) to different profiles. The routing engine uses hierarchical specificity matching (thread > channel > guild) with bounded LRU caching for forum post resolution. Routing result is stamped on source.profile by BasePlatformAdapter .build_source() at inbound time. When gateway.multiplex_profiles is on, the existing _profile_runtime_scope machinery picks up source.profile and runs the whole turn inside the profile's HERMES_HOME — so memory, skills, config, and secrets all resolve to that profile automatically. No new isolation code is added; this PR only adds the routing decision layer on top of the existing multiplexing infrastructure. Configuration: gateway: multiplex_profiles: true profile_routes: - name: server-default platform: discord guild_id: "GUILD_ID" profile: server-profile - name: special-channel platform: discord guild_id: "GUILD_ID" chat_id: "CHANNEL_ID" profile: channel-profile When multiplex_profiles is off, profile_routes is ignored (no behavior change for single-profile gateways). Tests: 29 unit tests covering specificity scoring, hierarchical matching, path-traversal validation, and config parsing. Co-Authored-By: Claude Opus 4.7 --- gateway/config.py | 22 +++ gateway/platforms/base.py | 40 ++++- gateway/profile_routing.py | 196 +++++++++++++++++++++++ gateway/run.py | 54 ++++++- hermes_constants.py | 44 ++++++ tests/gateway/test_profile_routing.py | 217 ++++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 gateway/profile_routing.py create mode 100644 tests/gateway/test_profile_routing.py diff --git a/gateway/config.py b/gateway/config.py index e1556b37d529..0b2290246bf2 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -553,6 +553,11 @@ class GatewayConfig: # fresh session exactly as if the reset policy had fired. 0 = disabled. session_store_max_age_days: int = 90 + # Profile-based routing: route specific guilds/channels/threads to + # different profiles. See gateway/profile_routing.py. Each entry is a + # dict with: name, platform, profile, and optional guild_id/chat_id/thread_id. + profile_routes: list = field(default_factory=list) + def get_connected_platforms(self) -> List[Platform]: """Return list of platforms that are enabled and configured.""" connected = [] @@ -657,6 +662,7 @@ def to_dict(self) -> Dict[str, Any]: "unauthorized_dm_behavior": self.unauthorized_dm_behavior, "streaming": self.streaming.to_dict(), "session_store_max_age_days": self.session_store_max_age_days, + "profile_routes": self.profile_routes, } @classmethod @@ -726,6 +732,10 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": except (TypeError, ValueError): session_store_max_age_days = 90 + # Parse profile routes (validated by gateway.profile_routing) + from gateway.profile_routing import parse_profile_routes + profile_routes = parse_profile_routes(data.get("profile_routes") or []) + return cls( platforms=platforms, default_reset_policy=default_policy, @@ -746,6 +756,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": unauthorized_dm_behavior=unauthorized_dm_behavior, streaming=StreamingConfig.from_dict(data.get("streaming", {})), session_store_max_age_days=session_store_max_age_days, + profile_routes=profile_routes, ) def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: @@ -855,6 +866,17 @@ def load_gateway_config() -> GatewayConfig: if "multiplex_profiles" in yaml_cfg: gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"] + # Profile-based routing rules: accept either top-level + # ``profile_routes`` or the nested ``gateway.profile_routes`` form + # (matching the multiplex_profiles parity above). + _pr = yaml_cfg.get("profile_routes") + if _pr is None: + _gw_section = yaml_cfg.get("gateway") + if isinstance(_gw_section, dict): + _pr = _gw_section.get("profile_routes") + if isinstance(_pr, list): + gw_data["profile_routes"] = _pr + gateway_section = yaml_cfg.get("gateway") if isinstance(gateway_section, dict) and "max_concurrent_sessions" in gateway_section: gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"] diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8dd9fc8fdd46..75bf3bc0872e 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -5133,10 +5133,47 @@ def build_source( message_id: Optional[str] = None, role_authorized: bool = False, ) -> SessionSource: - """Helper to build a SessionSource for this platform.""" + """Helper to build a SessionSource for this platform. + + When ``gateway.profile_routes`` is configured, the routing engine + resolves the matching profile from guild/chat/thread and stamps it on + ``source.profile``. Downstream code (``_resolve_profile_home_for_source`` + in run.py) reads that field to enter ``_profile_runtime_scope`` for + per-profile HERMES_HOME isolation. + """ # Normalize empty topic to None if chat_topic is not None and not chat_topic.strip(): chat_topic = None + + # Resolve profile from configured routes (None when no match / no routes) + profile = None + runner = getattr(self, "gateway_runner", None) + if runner is not None: + try: + profile = runner._profile_name_for_source( + SessionSource( + platform=self.platform, + chat_id=str(chat_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=str(user_id) if user_id else None, + user_name=user_name, + thread_id=str(thread_id) if thread_id else None, + chat_topic=chat_topic.strip() if chat_topic else None, + user_id_alt=user_id_alt, + chat_id_alt=chat_id_alt, + is_bot=is_bot, + guild_id=str(guild_id) if guild_id else None, + parent_chat_id=str(parent_chat_id) if parent_chat_id else None, + message_id=str(message_id) if message_id else None, + ) + ) + except Exception: + logger.warning( + "Profile resolution failed for %s/%s, defaulting to active profile", + self.platform, chat_id, exc_info=True, + ) + return SessionSource( platform=self.platform, chat_id=str(chat_id), @@ -5152,6 +5189,7 @@ def build_source( guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, message_id=str(message_id) if message_id else None, + profile=profile, role_authorized=role_authorized, ) diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py new file mode 100644 index 000000000000..836b50639c70 --- /dev/null +++ b/gateway/profile_routing.py @@ -0,0 +1,196 @@ +"""Profile-based routing for the gateway with hierarchical matching. + +Allows a single Hermes instance to route specific Discord guilds/channels/threads +to different profiles — each with their own model, tools, memory, and persona. + +Matching priority (most specific first): + 1. platform + chat_id + thread_id (exact thread) — specificity 8 + 2. platform + chat_id (channel route) — specificity 4 + 3. platform + guild_id (guild/server route) — specificity 2 + 4. No match → default profile + +Hierarchical matching: +For Discord forum channels, checks the full parent chain: +- Forum channel → Forum post → Comment +- Matches if any level of the hierarchy matches a configured route + +Configuration (config.yaml): + + gateway: + profile_routes: + - name: server-default + platform: discord + guild_id: "YOUR_GUILD_ID" + profile: server-profile + + - name: special-channel + platform: discord + guild_id: "YOUR_GUILD_ID" + chat_id: "YOUR_CHANNEL_ID" + profile: channel-profile + + - name: thread-route + platform: discord + chat_id: "YOUR_CHANNEL_ID" + thread_id: "YOUR_THREAD_ID" + profile: thread-profile +""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Set + +import logging + +logger = logging.getLogger(__name__) + + +# Bounded LRU cache for forum post to channel mappings. +# OrderedDict evicts least-recently-used entries when full. +_MAX_FORUM_CACHE = 10000 +_forum_post_cache: OrderedDict[str, str] = OrderedDict() # post_id -> channel_id + +def register_forum_post(post_id: str, channel_id: str) -> None: + """Register a forum post's parent channel for hierarchical matching.""" + _forum_post_cache[post_id] = channel_id + _forum_post_cache.move_to_end(post_id) + while len(_forum_post_cache) > _MAX_FORUM_CACHE: + _forum_post_cache.popitem(last=False) + logger.debug("Registered forum post %s -> channel %s", post_id, channel_id) + + +def resolve_forum_channel(post_id: str) -> Optional[str]: + """Get the parent channel ID for a forum post, if cached.""" + return _forum_post_cache.get(post_id) + + +@dataclass(frozen=True) +class ProfileRoute: + """A single routing rule that maps a platform scope to a profile.""" + + name: str + platform: str + profile: str + guild_id: Optional[str] = None + chat_id: Optional[str] = None + thread_id: Optional[str] = None + enabled: bool = True + + @property + def specificity(self) -> int: + """Higher value = more specific match.""" + s = 0 + if self.guild_id: + s += 2 + if self.chat_id: + s += 4 + if self.thread_id: + s += 8 + return s + + def matches( + self, + platform: str, + guild_id: Optional[str] = None, + chat_id: Optional[str] = None, + thread_id: Optional[str] = None, + parent_chat_id: Optional[str] = None, + ) -> bool: + """Return True if this route matches the given source fields. + + Supports hierarchical matching for Discord forums: + - Direct channel match: chat_id == route.chat_id + - Thread in channel: parent_chat_id == route.chat_id + - Forum post: parent_chat_id is the forum post, check if post belongs to route's channel + - Comment on forum post: parent_chat_id is the forum post, check hierarchy + """ + if not self.enabled: + return False + if self.platform != platform: + return False + if self.thread_id and self.thread_id != thread_id: + return False + + # Hierarchical chat_id matching + if self.chat_id: + # Direct match + if self.chat_id == chat_id: + return True + # Parent match (thread or direct child) + if self.chat_id == parent_chat_id: + return True + # Forum post hierarchy: check if parent_chat_id is a forum post + # that belongs to this channel + if parent_chat_id: + parent_channel = resolve_forum_channel(parent_chat_id) + if parent_channel and self.chat_id == parent_channel: + return True + + # If chat_id was specified but didn't match any level, fail + if self.chat_id: + return False + + if self.guild_id and self.guild_id != guild_id: + return False + return True + + +def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]: + """Parse profile_routes from config.yaml into ProfileRoute objects. + + Returns routes sorted by specificity (most specific first). + """ + if not raw: + return [] + routes: List[ProfileRoute] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + name = entry.get("name", "") + platform = entry.get("platform", "") + profile = entry.get("profile", "") + if not platform or not profile: + logger.warning( + "Skipping profile route %s: missing platform or profile", + name, + ) + continue + # Validate profile name to prevent path traversal + try: + from hermes_constants import validate_profile_name as _validate + profile = _validate(profile) + except (ValueError, ImportError): + logger.warning("Skipping profile route %s: invalid profile name %r", name, profile) + continue + routes.append( + ProfileRoute( + name=name, + platform=platform, + profile=profile, + guild_id=entry.get("guild_id"), + chat_id=entry.get("chat_id"), + thread_id=entry.get("thread_id"), + enabled=entry.get("enabled", True), + ) + ) + # Sort: most specific first so the first match wins. + routes.sort(key=lambda r: r.specificity, reverse=True) + logger.debug("Loaded %d profile routes (most-specific-first)", len(routes)) + return routes + + +def match_profile_route( + routes: List[ProfileRoute], + platform: str, + guild_id: Optional[str] = None, + chat_id: Optional[str] = None, + thread_id: Optional[str] = None, + parent_chat_id: Optional[str] = None, +) -> Optional[ProfileRoute]: + """Return the best-matching route, or None for no match.""" + for route in routes: + if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id): + return route + return None diff --git a/gateway/run.py b/gateway/run.py index a84d3ca6cf71..df625e22a081 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14995,16 +14995,62 @@ async def _run_agent( persist_user_timestamp=persist_user_timestamp, ) + def _profile_name_for_source(self, source: SessionSource) -> Optional[str]: + """Resolve the profile name for an inbound source via configured routes. + + Returns ``None`` when no routes are configured or no route matches. + Callers (``build_source``, ``_resolve_profile_home_for_source``) treat + ``None`` as "use the default/active profile". When + ``gateway.profile_routes`` is configured, the most specific matching + route wins (guild < channel < thread). See :mod:`gateway.profile_routing` + for matching rules. + """ + config = getattr(self, "config", None) + routes = getattr(config, "profile_routes", None) + if not routes: + return None + from gateway.profile_routing import match_profile_route + try: + matched = match_profile_route( + routes, + platform=source.platform.value, + guild_id=getattr(source, "guild_id", None), + chat_id=source.chat_id, + thread_id=getattr(source, "thread_id", None), + parent_chat_id=getattr(source, "parent_chat_id", None), + ) + except Exception: + logger.warning( + "Profile route matching failed for %s/%s, falling back to default", + source.platform, source.chat_id, exc_info=True, + ) + return None + if matched: + return matched.profile + logger.info( + "No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s", + source.platform.value, source.chat_id, + getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None), + ) + return None + def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path": """Resolve which profile's HERMES_HOME should serve this inbound source. - Prefers the profile the source was routed to (``source.profile`` — set - by the /p// URL prefix or a per-credential adapter), falling - back to the active profile (the multiplexer's own home). + Resolution order: + 1. ``source.profile`` — set by /p// URL prefix, per-credential + adapter ownership, OR profile_routes matching at ``build_source`` time. + 2. ``_profile_name_for_source`` — re-run routing here as a defensive + fallback for sources that bypass ``build_source``. + 3. The active profile (the multiplexer's own home). """ from hermes_cli.profiles import get_active_profile_name, get_profile_dir try: - name = (source.profile or "").strip() or get_active_profile_name() or "default" + name = (source.profile or "").strip() + if not name: + name = self._profile_name_for_source(source) + if not name: + name = get_active_profile_name() or "default" return get_profile_dir(name) except Exception: from hermes_constants import get_hermes_home diff --git a/hermes_constants.py b/hermes_constants.py index 4a0cccb9d561..7d3a19a42259 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -9,6 +9,7 @@ import sys import sysconfig from contextvars import ContextVar, Token +import re from pathlib import Path @@ -913,3 +914,46 @@ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" + +# ─── Profile Normalization ──────────────────────────────────────────────── + +# Standard (non-isolated) profile names. All three are treated identically +# for gating and filtering purposes. Named profiles (e.g. "ai-expert") +# are anything *not* in this tuple. +STANDARD_PROFILES: tuple[str, ...] = ("main", "default") + + +_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + + +def normalize_profile(name: str | None) -> str: + """Canonicalize a profile name. Never raises. + + Returns ``"main"`` for all standard/empty profiles so that downstream + code only needs to compare against a single value. Named profiles + are returned as-is (lowercased, stripped). + """ + if not name or name.strip().lower() in STANDARD_PROFILES: + return "main" + return name.strip().lower() + + +def validate_profile_name(name: str | None) -> str: + """Validate and canonicalize. Raises ValueError for invalid names. + + Use at config parse boundaries. normalize_profile() is the safe + runtime version that never raises. + """ + result = normalize_profile(name) + if result == "main": + return result + if not _VALID_PROFILE_RE.match(result): + raise ValueError( + f"Invalid profile name: {name!r} (must match [a-z0-9][a-z0-9_-]*)" + ) + return result + + +def is_standard_profile(name: str | None) -> bool: + """Return True for default/main/None/empty — the unscoped profile.""" + return not name or name.strip().lower() in STANDARD_PROFILES diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py new file mode 100644 index 000000000000..865ec065f7e0 --- /dev/null +++ b/tests/gateway/test_profile_routing.py @@ -0,0 +1,217 @@ +"""Tests for gateway/profile_routing.py — profile-based routing.""" + +import pytest +from gateway.profile_routing import ( + ProfileRoute, + parse_profile_routes, + match_profile_route, +) + + +class TestProfileRoute: + def test_specificity_thread(self): + r = ProfileRoute(name="t", platform="discord", profile="p", + guild_id="g", chat_id="c", thread_id="t") + assert r.specificity == 14 # 2 + 4 + 8 + + def test_specificity_channel(self): + r = ProfileRoute(name="c", platform="discord", profile="p", + guild_id="g", chat_id="c") + assert r.specificity == 6 # 2 + 4 + + def test_specificity_guild(self): + r = ProfileRoute(name="g", platform="discord", profile="p", + guild_id="g") + assert r.specificity == 2 + + def test_specificity_minimal(self): + r = ProfileRoute(name="m", platform="telegram", profile="p") + assert r.specificity == 0 + + def test_frozen(self): + r = ProfileRoute(name="x", platform="discord", profile="p") + with pytest.raises(AttributeError): + r.name = "y" + + +class TestProfileRouteMatching: + def test_exact_thread_match(self): + r = ProfileRoute(name="t", platform="discord", profile="trader", + guild_id="111", chat_id="222", thread_id="333") + assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333") + assert not r.matches("discord", guild_id="111", chat_id="222", thread_id="444") + + def test_channel_match(self): + r = ProfileRoute(name="c", platform="discord", profile="helper", + chat_id="222") + assert r.matches("discord", chat_id="222") + assert not r.matches("discord", chat_id="333") + assert not r.matches("telegram", chat_id="222") + + def test_guild_match(self): + r = ProfileRoute(name="g", platform="discord", profile="server", + guild_id="111") + assert r.matches("discord", guild_id="111") + assert not r.matches("discord", guild_id="222") + + def test_disabled_route_no_match(self): + r = ProfileRoute(name="d", platform="discord", profile="off", + guild_id="111", enabled=False) + assert not r.matches("discord", guild_id="111") + + def test_guild_route_matches_any_channel_in_guild(self): + r = ProfileRoute(name="g", platform="discord", profile="server", + guild_id="111") + assert r.matches("discord", guild_id="111", chat_id="222") + assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333") + + def test_extra_fields_ignored(self): + r = ProfileRoute(name="g", platform="discord", profile="server", + guild_id="111") + assert r.matches("discord", guild_id="111", chat_id="any") + + +class TestParseProfileRoutes: + def test_empty(self): + assert parse_profile_routes(None) == [] + assert parse_profile_routes([]) == [] + + def test_valid_routes_sorted_by_specificity(self): + raw = [ + {"name": "guild", "platform": "discord", "profile": "p", "guild_id": "1"}, + {"name": "thread", "platform": "discord", "profile": "p", + "guild_id": "1", "chat_id": "2", "thread_id": "3"}, + {"name": "channel", "platform": "discord", "profile": "p", "chat_id": "2"}, + ] + routes = parse_profile_routes(raw) + names = [r.name for r in routes] + assert names == ["thread", "channel", "guild"] + + def test_skips_invalid(self): + raw = [ + {"platform": "discord"}, + {"profile": "p"}, + "not a dict", + {"name": "ok", "platform": "telegram", "profile": "p"}, + ] + routes = parse_profile_routes(raw) + assert len(routes) == 1 + assert routes[0].name == "ok" + + def test_enabled_flag(self): + raw = [ + {"name": "off", "platform": "discord", "profile": "p", + "guild_id": "1", "enabled": False}, + {"name": "on", "platform": "discord", "profile": "p", "guild_id": "1"}, + ] + routes = parse_profile_routes(raw) + assert not routes[0].enabled + assert routes[1].enabled + + +class TestMatchProfileRoute: + def test_no_routes(self): + assert match_profile_route([], "discord") is None + + def test_returns_first_match(self): + routes = [ + ProfileRoute(name="thread", platform="discord", profile="trader", + guild_id="1", chat_id="2", thread_id="3"), + ProfileRoute(name="channel", platform="discord", profile="helper", + chat_id="2"), + ] + m = match_profile_route(routes, "discord", guild_id="1", chat_id="2", thread_id="3") + assert m is not None + assert m.profile == "trader" + + def test_falls_through_to_channel(self): + routes = [ + ProfileRoute(name="thread", platform="discord", profile="trader", + guild_id="1", chat_id="2", thread_id="3"), + ProfileRoute(name="channel", platform="discord", profile="helper", + chat_id="2"), + ] + m = match_profile_route(routes, "discord", guild_id="1", chat_id="2") + assert m is not None + assert m.profile == "helper" + + def test_no_match_returns_none(self): + routes = [ + ProfileRoute(name="r", platform="telegram", profile="p"), + ] + assert match_profile_route(routes, "discord") is None + + +class TestSessionKeyIntegration: + def test_default_profile_key(self): + from gateway.session import build_session_key, SessionSource, Platform + src = SessionSource(platform=Platform.DISCORD, chat_id="123", + chat_type="channel", user_id="456") + key = build_session_key(src) + assert key.startswith("agent:main:") + + def test_custom_profile_key(self): + from gateway.session import build_session_key, SessionSource, Platform + src = SessionSource(platform=Platform.DISCORD, chat_id="123", + chat_type="channel", user_id="456") + key = build_session_key(src, profile="trader") + assert key.startswith("agent:trader:") + assert key == "agent:trader:discord:channel:123:456" + + def test_isolated_sessions(self): + from gateway.session import build_session_key, SessionSource, Platform + src = SessionSource(platform=Platform.DISCORD, chat_id="123", + chat_type="channel", user_id="456") + key_default = build_session_key(src) + key_trader = build_session_key(src, profile="trader") + assert key_default != key_trader + + def test_dm_profile_scoped(self): + from gateway.session import build_session_key, SessionSource, Platform + src = SessionSource(platform=Platform.DISCORD, chat_id="999", + chat_type="dm", user_id="111") + key = build_session_key(src, profile="bot2") + assert key == "agent:bot2:discord:dm:999" + + + +class TestParentChatIdMatching: + """Thread messages carry thread_id as chat_id; parent_chat_id is the channel.""" + + def test_channel_route_matches_via_parent_chat_id(self): + r = ProfileRoute(name="ch", platform="discord", profile="trader", + chat_id="222") + assert r.matches("discord", chat_id="333", parent_chat_id="222") + + def test_channel_route_no_match_wrong_parent(self): + r = ProfileRoute(name="ch", platform="discord", profile="trader", + chat_id="222") + assert not r.matches("discord", chat_id="333", parent_chat_id="444") + + def test_match_profile_route_with_parent_chat_id(self): + routes = [ + ProfileRoute(name="ch", platform="discord", profile="trader", + chat_id="222"), + ] + m = match_profile_route(routes, "discord", chat_id="333", parent_chat_id="222") + assert m is not None + assert m.profile == "trader" + + def test_thread_id_does_not_match_parent_chat_id(self): + """thread_id only matches the actual thread_id, never parent_chat_id. + Discord snowflakes are globally unique, so thread_id != channel_id.""" + r = ProfileRoute(name="th", platform="discord", profile="helper", + thread_id="555") + assert r.matches("discord", thread_id="555") + assert not r.matches("discord", parent_chat_id="555") + + def test_no_parent_chat_id_still_works(self): + r = ProfileRoute(name="ch", platform="discord", profile="trader", + chat_id="222") + assert r.matches("discord", chat_id="222") + + def test_guild_route_matches_with_parent_chat_id(self): + """Guild routes should match regardless of chat_id or parent_chat_id.""" + r = ProfileRoute(name="g", platform="discord", profile="server", + guild_id="111") + assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="444") From e809cd9c70dedbdae231108a497efb99844fffde Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sat, 27 Jun 2026 18:49:19 +0900 Subject: [PATCH 2/8] fix(profile-routing): remove dead forum cache, warn on missing profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the initial routing PR after code review: 1. Remove dead forum-post hierarchy cache. The `_forum_post_cache`, `register_forum_post()`, and `resolve_forum_channel()` were never wired up — no caller in the codebase. Discord's adapter already sets `parent_chat_id` to the immediate parent (forum channel for a forum post), so the existing `self.chat_id == parent_chat_id` branch in `matches()` handles forum posts correctly without a cache. The hierarchical-resolution branch in `matches()` and the bounded-LRU infrastructure are removed. 2. Fix docstring specificity numbers (8 → 14, 4 → 6) and rewrite the "Hierarchical matching" section to describe the actual one-level parent_chat_id behavior. Removed unused `OrderedDict` and `Set` imports. 3. Warn loudly when a routed profile doesn't exist on disk. Previously, a typo in `profile_routes` (e.g. `crypto-tradr`) silently fell back to the global HERMES_HOME, causing the message to read the default profile's memory/credentials with no signal to the operator. Now emits a `logger.warning` with the profile name, source identifier, and the fallback reason. Bare-exception path also gets `exc_info`. Tests: - test_profile_routing.py: +2 tests verifying forum post matching via direct parent_chat_id (covers the case the removed cache was meant for). 31 total, all pass. - test_profile_resolution.py: NEW, 12 tests covering resolution order (source.profile > routing > active > default), missing-profile warning, exception handling, and routing consultation. All pass. Co-Authored-By: Claude Opus 4.7 --- gateway/profile_routing.py | 43 +--- gateway/run.py | 40 +++- tests/gateway/test_profile_resolution.py | 257 +++++++++++++++++++++++ tests/gateway/test_profile_routing.py | 29 +++ 4 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 tests/gateway/test_profile_resolution.py diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index 836b50639c70..205f36d0630c 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -4,15 +4,16 @@ to different profiles — each with their own model, tools, memory, and persona. Matching priority (most specific first): - 1. platform + chat_id + thread_id (exact thread) — specificity 8 - 2. platform + chat_id (channel route) — specificity 4 + 1. platform + chat_id + thread_id (exact thread) — specificity 14 + 2. platform + chat_id (channel route) — specificity 6 3. platform + guild_id (guild/server route) — specificity 2 4. No match → default profile -Hierarchical matching: -For Discord forum channels, checks the full parent chain: -- Forum channel → Forum post → Comment -- Matches if any level of the hierarchy matches a configured route +Parent-chain matching: +For Discord threads and forum posts, ``parent_chat_id`` carries the +direct parent (the channel for a thread, the forum channel for a post). +Routes keyed on a channel match both direct messages and messages in +any thread/post whose parent is that channel. Configuration (config.yaml): @@ -38,34 +39,14 @@ from __future__ import annotations -from collections import OrderedDict from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional import logging logger = logging.getLogger(__name__) -# Bounded LRU cache for forum post to channel mappings. -# OrderedDict evicts least-recently-used entries when full. -_MAX_FORUM_CACHE = 10000 -_forum_post_cache: OrderedDict[str, str] = OrderedDict() # post_id -> channel_id - -def register_forum_post(post_id: str, channel_id: str) -> None: - """Register a forum post's parent channel for hierarchical matching.""" - _forum_post_cache[post_id] = channel_id - _forum_post_cache.move_to_end(post_id) - while len(_forum_post_cache) > _MAX_FORUM_CACHE: - _forum_post_cache.popitem(last=False) - logger.debug("Registered forum post %s -> channel %s", post_id, channel_id) - - -def resolve_forum_channel(post_id: str) -> Optional[str]: - """Get the parent channel ID for a forum post, if cached.""" - return _forum_post_cache.get(post_id) - - @dataclass(frozen=True) class ProfileRoute: """A single routing rule that maps a platform scope to a profile.""" @@ -103,8 +84,6 @@ def matches( Supports hierarchical matching for Discord forums: - Direct channel match: chat_id == route.chat_id - Thread in channel: parent_chat_id == route.chat_id - - Forum post: parent_chat_id is the forum post, check if post belongs to route's channel - - Comment on forum post: parent_chat_id is the forum post, check hierarchy """ if not self.enabled: return False @@ -121,12 +100,6 @@ def matches( # Parent match (thread or direct child) if self.chat_id == parent_chat_id: return True - # Forum post hierarchy: check if parent_chat_id is a forum post - # that belongs to this channel - if parent_chat_id: - parent_channel = resolve_forum_channel(parent_chat_id) - if parent_channel and self.chat_id == parent_channel: - return True # If chat_id was specified but didn't match any level, fail if self.chat_id: diff --git a/gateway/run.py b/gateway/run.py index df625e22a081..2f7b74ac7cb7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15044,16 +15044,50 @@ def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path": fallback for sources that bypass ``build_source``. 3. The active profile (the multiplexer's own home). """ - from hermes_cli.profiles import get_active_profile_name, get_profile_dir + from hermes_cli.profiles import ( + get_active_profile_name, + get_profile_dir, + profile_exists, + ) + from hermes_constants import get_hermes_home + + # Track whether a profile was explicitly requested (vs. falling back to default) + explicit_profile = None try: name = (source.profile or "").strip() + if name: + explicit_profile = name # User explicitly set this profile if not name: name = self._profile_name_for_source(source) + if name: + explicit_profile = name # Routing explicitly set this profile if not name: name = get_active_profile_name() or "default" - return get_profile_dir(name) + + profile_dir = get_profile_dir(name) + # Warn if an explicit profile doesn't exist on disk + if explicit_profile and not profile_exists(name): + logger.warning( + "Profile %r does not exist for source %s/%s (guild_id=%s), " + "falling back to global HERMES_HOME", + explicit_profile, + source.platform.value, + source.chat_id, + getattr(source, "guild_id", None), + ) + return get_hermes_home() + return profile_dir except Exception: - from hermes_constants import get_hermes_home + # Catch normalization errors, path errors, etc. + logger.warning( + "Failed to resolve profile directory for source %s/%s (guild_id=%s), " + "falling back to global HERMES_HOME: %s", + source.platform.value, + source.chat_id, + getattr(source, "guild_id", None), + explicit_profile or "(no profile)", + exc_info=True, + ) return get_hermes_home() async def _run_agent_inner( diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py new file mode 100644 index 000000000000..da0ddf32bfb3 --- /dev/null +++ b/tests/gateway/test_profile_resolution.py @@ -0,0 +1,257 @@ +"""Tests for GatewayRunner._resolve_profile_home_for_source — profile resolution logic.""" + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from gateway.session import SessionSource +from gateway.run import GatewayRunner + + +@pytest.fixture +def mock_runner(): + """Create a minimal mock GatewayRunner with the methods we need.""" + runner = MagicMock(spec=GatewayRunner) + runner.config = MagicMock(profile_routes=[]) + # Bind the actual methods to the mock + runner._profile_name_for_source = GatewayRunner._profile_name_for_source.__get__(runner) + runner._resolve_profile_home_for_source = GatewayRunner._resolve_profile_home_for_source.__get__(runner) + return runner + + +@pytest.fixture +def discord_source(): + """Create a basic Discord SessionSource for testing.""" + return SessionSource( + platform=MagicMock(value="discord"), + chat_id="123456", + guild_id="789", + thread_id=None, + parent_chat_id=None, + ) + + +class TestResolutionOrder: + """Tests that profile resolution follows the correct priority order.""" + + def test_source_profile_wins_over_routing(self, mock_runner, discord_source): + """source.profile should be used even if routing would match.""" + discord_source.profile = "from-source" + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + with patch("hermes_cli.profiles.profile_exists", return_value=True): + mock_get_dir.return_value = Path("/hermes/profiles/from-source") + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes/profiles/from-source") + mock_get_dir.assert_called_once_with("from-source") + + def test_routing_wins_over_active_profile(self, mock_runner, discord_source): + """When source.profile is empty, routing should win over active profile.""" + discord_source.profile = None + + # Mock routing to return a profile + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + with patch("hermes_cli.profiles.profile_exists", return_value=True): + mock_get_dir.return_value = Path("/hermes/profiles/routed") + + # Manually set routing to return a profile + mock_runner._profile_name_for_source = MagicMock(return_value="routed") + + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes/profiles/routed") + mock_get_dir.assert_called_once_with("routed") + + def test_active_profile_fallback(self, mock_runner, discord_source): + """When source.profile and routing both return None, active profile is used.""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/active") + + # No routing match + mock_runner._profile_name_for_source = MagicMock(return_value=None) + + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes/profiles/active") + mock_get_dir.assert_called_once_with("active") + + def test_default_fallback_when_no_active(self, mock_runner, discord_source): + """When even active profile is None, 'default' is used.""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value=None): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes") + + mock_runner._profile_name_for_source = MagicMock(return_value=None) + + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes") + mock_get_dir.assert_called_once_with("default") + + +class TestMissingProfileWarning: + """Tests for warning when a profile doesn't exist on disk.""" + + def test_nonexistent_profile_warning(self, mock_runner, discord_source, caplog): + """When source.profile points to a nonexistent profile, log a WARNING.""" + discord_source.profile = "nonexistent" + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/nonexistent") + with patch("hermes_cli.profiles.profile_exists", return_value=False): + with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")): + with caplog.at_level(logging.WARNING): + result = mock_runner._resolve_profile_home_for_source(discord_source) + + # Should fall back to global HERMES_HOME + assert result == Path("/hermes") + + # Should have logged a warning + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" + assert "nonexistent" in caplog.records[0].message + assert "does not exist" in caplog.records[0].message + assert "discord" in caplog.records[0].message + assert "123456" in caplog.records[0].message + + def test_nonexistent_routing_profile_warning(self, mock_runner, discord_source, caplog): + """When routing returns a nonexistent profile, log a WARNING.""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/routed") + with patch("hermes_cli.profiles.profile_exists", return_value=False): + with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")): + # Routing returns a profile that doesn't exist + mock_runner._profile_name_for_source = MagicMock(return_value="routed") + + with caplog.at_level(logging.WARNING): + result = mock_runner._resolve_profile_home_for_source(discord_source) + + # Should fall back to global HERMES_HOME + assert result == Path("/hermes") + + # Should have logged a warning + assert len(caplog.records) == 1 + assert "routed" in caplog.records[0].message + + def test_empty_source_profile_no_warning(self, mock_runner, discord_source, caplog): + """When source.profile is empty, silent fallback to active profile (no warning).""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/active") + with patch("hermes_cli.profiles.profile_exists", return_value=True): + with caplog.at_level(logging.WARNING): + mock_runner._profile_name_for_source = MagicMock(return_value=None) + + result = mock_runner._resolve_profile_home_for_source(discord_source) + + # Should use active profile + assert result == Path("/hermes/profiles/active") + + # No warnings (active profile exists) + assert not any(r.levelname == "WARNING" for r in caplog.records) + + def test_existing_profile_no_warning(self, mock_runner, discord_source, caplog): + """When the profile exists, no warning should be logged.""" + discord_source.profile = "existing" + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/existing") + with patch("hermes_cli.profiles.profile_exists", return_value=True): + with caplog.at_level(logging.WARNING): + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes/profiles/existing") + + # No warnings + assert not any(r.levelname == "WARNING" for r in caplog.records) + + +class TestExceptionHandling: + """Tests for exception handling in profile resolution.""" + + def test_get_profile_dir_exception_logs_warning(self, mock_runner, discord_source, caplog): + """When get_profile_dir raises an exception, log a WARNING with context.""" + discord_source.profile = "bad-profile" + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir", side_effect=ValueError("Invalid profile name")): + with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")): + with caplog.at_level(logging.WARNING): + result = mock_runner._resolve_profile_home_for_source(discord_source) + + # Should fall back to global HERMES_HOME + assert result == Path("/hermes") + + # Should have logged a warning with exception info + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" + assert "bad-profile" in caplog.records[0].message + assert "Failed to resolve profile directory" in caplog.records[0].message + + def test_exception_with_no_profile_name(self, mock_runner, discord_source, caplog): + """Exception when no profile was set should still log a warning.""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value=None): + with patch("hermes_cli.profiles.get_profile_dir", side_effect=RuntimeError("Filesystem error")): + with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")): + mock_runner._profile_name_for_source = MagicMock(return_value=None) + + with caplog.at_level(logging.WARNING): + result = mock_runner._resolve_profile_home_for_source(discord_source) + + assert result == Path("/hermes") + + # Warning should mention "(no profile)" + assert "(no profile)" in caplog.records[0].message + + +class TestRoutingConsultation: + """Tests that _profile_name_for_source is consulted when source.profile is empty.""" + + def test_routing_consulted_when_source_profile_empty(self, mock_runner, discord_source): + """_profile_name_for_source should be called when source.profile is empty.""" + discord_source.profile = None + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/routed") + + mock_runner._profile_name_for_source = MagicMock(return_value="routed") + + mock_runner._resolve_profile_home_for_source(discord_source) + + # Should have called routing + mock_runner._profile_name_for_source.assert_called_once_with(discord_source) + + def test_routing_not_consulted_when_source_profile_set(self, mock_runner, discord_source): + """_profile_name_for_source should NOT be called when source.profile is set.""" + discord_source.profile = "from-source" + + with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"): + with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir: + mock_get_dir.return_value = Path("/hermes/profiles/from-source") + + mock_runner._profile_name_for_source = MagicMock(return_value="routed") + + mock_runner._resolve_profile_home_for_source(discord_source) + + # Should NOT have called routing + mock_runner._profile_name_for_source.assert_not_called() diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 865ec065f7e0..4df37a196955 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -215,3 +215,32 @@ def test_guild_route_matches_with_parent_chat_id(self): r = ProfileRoute(name="g", platform="discord", profile="server", guild_id="111") assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="444") + + +class TestForumPostMatching: + """Test that forum posts match via parent_chat_id (direct parent).""" + + def test_forum_channel_route_matches_forum_post(self): + """A route on a forum channel should match comments on posts in that forum. + + In Discord, forum posts (threads) have parent_chat_id = forum channel ID. + No cache is needed — the parent relationship is direct. + """ + r = ProfileRoute(name="forum", platform="discord", profile="forum_profile", + chat_id="forum_channel_123") + # A comment on a forum post: chat_id=post_thread_id, parent_chat_id=forum_channel_id + assert r.matches("discord", chat_id="post_thread_456", parent_chat_id="forum_channel_123") + + def test_forum_post_comment_matches_channel_not_thread_id(self): + """Verify that thread_id matching is distinct from parent_chat_id matching.""" + routes = [ + ProfileRoute(name="forum", platform="discord", profile="forum_profile", + chat_id="forum_channel_123"), + ProfileRoute(name="post", platform="discord", profile="post_profile", + thread_id="post_thread_456"), + ] + # A comment on the forum post should match the forum channel route, not the thread route + m = match_profile_route(routes, "discord", chat_id="post_thread_456", + parent_chat_id="forum_channel_123") + assert m is not None + assert m.profile == "forum_profile" From df1f48b0e8fa1492c8a84ad10b422797ce9b1c20 Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sun, 28 Jun 2026 05:51:04 +0900 Subject: [PATCH 3/8] refactor(profiles): drop dead helpers from hermes_constants STANDARD_PROFILES, normalize_profile, validate_profile_name, and is_standard_profile in hermes_constants were superseded by hermes_cli.profiles.{normalize_profile_name, validate_profile_name} but never removed. profile_routing.py is updated to import from the canonical location; the old helpers are deleted. Lazy import inside parse_profile_routes avoids the circular dependency at module load time (hermes_constants -> hermes_cli -> hermes_constants). Co-Authored-By: Claude Opus 4.7 --- gateway/profile_routing.py | 11 +++++++--- hermes_constants.py | 44 -------------------------------------- 2 files changed, 8 insertions(+), 47 deletions(-) diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index 205f36d0630c..b2884858fe52 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -130,10 +130,15 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou name, ) continue - # Validate profile name to prevent path traversal + # Validate profile name to prevent path traversal. Lazy import avoids a + # circular dependency at module load time. try: - from hermes_constants import validate_profile_name as _validate - profile = _validate(profile) + from hermes_cli.profiles import ( + normalize_profile_name, + validate_profile_name, + ) + profile = normalize_profile_name(profile) + validate_profile_name(profile) except (ValueError, ImportError): logger.warning("Skipping profile route %s: invalid profile name %r", name, profile) continue diff --git a/hermes_constants.py b/hermes_constants.py index 7d3a19a42259..4a0cccb9d561 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -9,7 +9,6 @@ import sys import sysconfig from contextvars import ContextVar, Token -import re from pathlib import Path @@ -914,46 +913,3 @@ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" - -# ─── Profile Normalization ──────────────────────────────────────────────── - -# Standard (non-isolated) profile names. All three are treated identically -# for gating and filtering purposes. Named profiles (e.g. "ai-expert") -# are anything *not* in this tuple. -STANDARD_PROFILES: tuple[str, ...] = ("main", "default") - - -_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") - - -def normalize_profile(name: str | None) -> str: - """Canonicalize a profile name. Never raises. - - Returns ``"main"`` for all standard/empty profiles so that downstream - code only needs to compare against a single value. Named profiles - are returned as-is (lowercased, stripped). - """ - if not name or name.strip().lower() in STANDARD_PROFILES: - return "main" - return name.strip().lower() - - -def validate_profile_name(name: str | None) -> str: - """Validate and canonicalize. Raises ValueError for invalid names. - - Use at config parse boundaries. normalize_profile() is the safe - runtime version that never raises. - """ - result = normalize_profile(name) - if result == "main": - return result - if not _VALID_PROFILE_RE.match(result): - raise ValueError( - f"Invalid profile name: {name!r} (must match [a-z0-9][a-z0-9_-]*)" - ) - return result - - -def is_standard_profile(name: str | None) -> bool: - """Return True for default/main/None/empty — the unscoped profile.""" - return not name or name.strip().lower() in STANDARD_PROFILES From 83146eee7cc006972c8cd5bbf63bef443bf37eca Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sun, 28 Jun 2026 05:51:16 +0900 Subject: [PATCH 4/8] fix(gateway): read adapter token from config for fingerprint check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _adapter_credential_fingerprint only looked at adapter.token directly, but Discord (and similar) adapters store the bot token on their config sub-object, not on self. Every Discord adapter in a multiplexed gateway therefore returned None, the same-token conflict check was silently skipped, and N adapters all polled the same bot token — producing a per-message race where whichever adapter won the GIL answered the user. Adds a config-token fallback (token, then bot_token) so the check actually fires for config-backed adapters. Direct adapter.token still takes precedence when both exist. Tests cover: config-backed token produces a fingerprint, distinct tokens produce distinct fingerprints, direct token wins over config, config without token attributes returns None. Co-Authored-By: Claude Opus 4.7 --- gateway/run.py | 14 +++++ .../test_multiplex_adapter_registry.py | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 2f7b74ac7cb7..41d404f79dc7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7536,6 +7536,20 @@ def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]: if isinstance(val, str) and val.strip(): token = val.strip() break + # Many adapters (e.g. Discord) store the token on their `config` + # sub-object rather than directly on the adapter. Without this lookup + # those adapters all return None here, the same-token conflict check + # is silently skipped, and every profile's adapter for that platform + # starts polling the same bot token — producing a per-message race + # for which adapter answers. See test_reads_config_token. + if not token: + cfg = getattr(adapter, "config", None) + if cfg is not None: + for attr in ("token", "bot_token"): + val = getattr(cfg, attr, None) + if isinstance(val, str) and val.strip(): + token = val.strip() + break if not token: return None import hashlib diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 7ecca64dfee0..dffb7d4aaac2 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -33,6 +33,58 @@ def __init__(self): assert GatewayRunner._adapter_credential_fingerprint(_AltAdapter()) is not None + def test_reads_config_token(self): + """Adapters like Discord store token on `config`, not on self. + + Without the config-token fallback, every Discord adapter in a + multiplexed gateway returns None here and the same-token conflict + check is silently skipped — N adapters start polling the same bot + token and race on every inbound message. + """ + class _Config: + token = "discord-bot-token" + class _ConfigBackedAdapter: + config = _Config() + fp = GatewayRunner._adapter_credential_fingerprint(_ConfigBackedAdapter()) + assert fp is not None + assert "discord-bot-token" not in fp + assert len(fp) == 16 + + def test_distinct_config_tokens_distinct_fp(self): + class _CfgA: + token = "tok-A" + class _CfgB: + token = "tok-B" + class _A: + config = _CfgA() + class _B: + config = _CfgB() + a = GatewayRunner._adapter_credential_fingerprint(_A()) + b = GatewayRunner._adapter_credential_fingerprint(_B()) + assert a is not None and b is not None + assert a != b + + def test_direct_token_takes_precedence_over_config(self): + """If both `adapter.token` and `adapter.config.token` exist, direct wins.""" + class _Cfg: + token = "from-config" + class _Both: + token = "from-direct" + config = _Cfg() + fp = GatewayRunner._adapter_credential_fingerprint(_Both()) + import hashlib + expected = hashlib.sha256(b"hermes-mux:from-direct").hexdigest()[:16] + assert fp == expected + + def test_config_without_token_returns_none(self): + """config present but no token attribute → None (no false positive).""" + class _Cfg: + pass + class _Adapter: + config = _Cfg() + assert GatewayRunner._adapter_credential_fingerprint(_Adapter()) is None + + class TestProfileMessageHandler: @pytest.mark.asyncio async def test_stamps_profile_on_unstamped_source(self): From cafc63545baf17fd6401a39697b7283e8d3a53d0 Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sun, 28 Jun 2026 05:51:35 +0900 Subject: [PATCH 5/8] fix(config): honor gateway.multiplex_profiles nested form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_gateway_config only forwarded the top-level multiplex_profiles key, ignoring the nested gateway.multiplex_profiles form. The latter is what `hermes config set gateway.multiplex_profiles true` writes, so users who ran that command got multiplex_profiles=False silently — no warning, no fallback, profile_routes just stopped matching. Loader now checks the top-level key first, falls back to the nested gateway section, and only then defaults to False. Same precedence is applied to other nested-form keys (profile_routes already did this). Tests cover: top-level honored, nested honored (regression test for the silent-fallback bug), default False, top-level overrides nested. Co-Authored-By: Claude Opus 4.7 --- gateway/config.py | 16 ++++--- tests/gateway/test_config.py | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 0b2290246bf2..668712e02b1d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -859,12 +859,16 @@ def load_gateway_config() -> GatewayConfig: if "thread_sessions_per_user" in yaml_cfg: gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"] - # Multiplexing flag: accept both the top-level key and the nested - # gateway.multiplex_profiles form (from_dict resolves the nested - # fallback, but surface the top-level key here for parity with the - # other session-scope flags above). - if "multiplex_profiles" in yaml_cfg: - gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"] + # Multiplexing flag: accept either top-level ``multiplex_profiles`` + # or the nested ``gateway.multiplex_profiles`` form (the latter is + # what ``hermes config set gateway.multiplex_profiles true`` writes). + _mp = yaml_cfg.get("multiplex_profiles") + if _mp is None: + _gw_section = yaml_cfg.get("gateway") + if isinstance(_gw_section, dict): + _mp = _gw_section.get("multiplex_profiles") + if _mp is not None: + gw_data["multiplex_profiles"] = _mp # Profile-based routing rules: accept either top-level # ``profile_routes`` or the nested ``gateway.profile_routes`` form diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 79bccc100ca5..3bd450389de6 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -1084,3 +1084,93 @@ def test_existing_platform_configs_accept_home_channel_env_overrides(self): home = config.platforms[platform].home_channel assert home is not None, f"{platform.value}: home_channel should not be None" assert (home.chat_id, home.name) == expected, platform.value + + +class TestMultiplexProfilesConfig: + """Tests for parsing multiplex_profiles (top-level and nested forms).""" + + def test_multiplex_profiles_top_level(self, tmp_path, monkeypatch): + """Top-level multiplex_profiles is honored.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "multiplex_profiles: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.multiplex_profiles is True + + def test_multiplex_profiles_nested_under_gateway(self, tmp_path, monkeypatch): + """gateway.multiplex_profiles (the form written by `hermes config set + gateway.multiplex_profiles true`) must be honored. Regression test for + the silent-fallback bug where the loader only forwarded the top-level + key, so users who wrote it under gateway: got multiplex_profiles=False + with no warning.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "gateway:\n multiplex_profiles: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.multiplex_profiles is True, ( + "gateway.multiplex_profiles: true was silently ignored — " + "loader only forwarded the top-level form" + ) + + def test_multiplex_profiles_default_false(self, tmp_path, monkeypatch): + """Default is False when neither form is present.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.multiplex_profiles is False + + def test_multiplex_profiles_top_level_overrides_nested(self, tmp_path, monkeypatch): + """When both forms are present, top-level wins (matches profile_routes + and other parity bridges in load_gateway_config).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "multiplex_profiles: true\n" + "gateway:\n multiplex_profiles: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.multiplex_profiles is True + + def test_multiplex_profiles_explicit_top_level_false_not_consulting_nested( + self, tmp_path, monkeypatch + ): + """Lock in the `is None` vs `is False` distinction: when top-level is + explicitly false, the loader must forward False WITHOUT consulting the + nested form (so a stale `gateway.multiplex_profiles: true` cannot + silently re-enable multiplexing). Guards against a future regression + that flips the check to `not _mp`.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "multiplex_profiles: false\n" + "gateway:\n multiplex_profiles: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.multiplex_profiles is False, ( + "Explicit top-level false was overridden by nested true — " + "loader must respect top-level precedence when key is present" + ) From 0c1bc2d29b4702aa3d3520d05b3cc4258bd921f2 Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sun, 28 Jun 2026 05:51:49 +0900 Subject: [PATCH 6/8] fix(session): persist profile_name and route batch key by profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups observed after deploying profile routing: 1. sessions.profile_name was NULL even when the agent ran inside the routed profile scope. _insert_session_row never wrote it, get_or_create_session / reset_session never passed it through, and the agent-side _ensure_db_session fallback had no way to read it. - Declare profile_name TEXT in SCHEMA_SQL so _reconcile_columns auto-adds it on existing DBs. - _insert_session_row takes profile_name and writes it. - SessionStore passes source.profile (or old_entry.origin.profile on reset) into db_create_kwargs. - _ensure_db_session reads the active profile via get_active_profile_name() inside _profile_runtime_scope. 2. DiscordAdapter._text_batch_key called build_session_key without profile=, so the batch key always landed in agent:main even when the routed profile differed — diverging from the agent session key namespace (agent:crypto-trader, agent:ai-expert, ...). Pass event.source.profile through so both namespaces agree. Live verification (jth-server-2, 2026-06-28): a test message in a routed #coin thread produced agent:crypto-trader:discord:thread:... in the batch log and profile_name=crypto-trader in the sessions row. Default-routed chat still produced agent:main / NULL. Co-Authored-By: Claude Opus 4.7 --- gateway/session.py | 2 ++ hermes_state.py | 7 +++++-- plugins/platforms/discord/adapter.py | 10 +++++++++- run_agent.py | 8 ++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/gateway/session.py b/gateway/session.py index f79e371d8043..85a365da8b65 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1103,6 +1103,7 @@ def get_or_create_session( "session_id": session_id, "source": source.platform.value, "user_id": source.user_id, + "profile_name": source.profile, } # SQLite operations outside the lock @@ -1329,6 +1330,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) -> "session_id": session_id, "source": old_entry.platform.value if old_entry.platform else "unknown", "user_id": old_entry.origin.user_id if old_entry.origin else None, + "profile_name": old_entry.origin.profile if old_entry.origin else None, } if self._db and db_end_session_id: diff --git a/hermes_state.py b/hermes_state.py index a7938f7167f4..cb16601f694c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -632,6 +632,7 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A handoff_state TEXT, handoff_platform TEXT, handoff_error TEXT, + profile_name TEXT, rewind_count INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) @@ -1435,13 +1436,14 @@ def _insert_session_row( user_id: str = None, parent_session_id: str = None, cwd: str = None, + profile_name: str = None, ) -> None: """Shared INSERT OR IGNORE for session rows.""" def _do(conn): conn.execute( """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config, - system_prompt, parent_session_id, cwd, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + system_prompt, parent_session_id, cwd, profile_name, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, source, @@ -1451,6 +1453,7 @@ def _do(conn): system_prompt, parent_session_id, cwd, + profile_name, time.time(), ), ) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 3d5369be522f..56e0cb404173 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5618,12 +5618,20 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool = # ------------------------------------------------------------------ def _text_batch_key(self, event: MessageEvent) -> str: - """Session-scoped key for text message batching.""" + """Session-scoped key for text message batching. + + Passes ``event.source.profile`` through so routed messages batch + under the same namespace the agent run will use (e.g. + ``agent:crypto-trader`` instead of ``agent:main``). Without this, + the batch key would always land in ``agent:main`` even when the + routed profile differs. + """ from gateway.session import build_session_key return build_session_key( event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + profile=event.source.profile, ) def _enqueue_text_event(self, event: MessageEvent) -> None: diff --git a/run_agent.py b/run_agent.py index 8026b024e71f..1f47e2ee99da 100644 --- a/run_agent.py +++ b/run_agent.py @@ -528,6 +528,13 @@ def _ensure_db_session(self) -> None: return source = _session_source_for_agent(self.platform) try: + try: + from hermes_cli.profiles import get_active_profile_name + _profile_for_session = get_active_profile_name() + if _profile_for_session == "default": + _profile_for_session = None + except Exception: + _profile_for_session = None self._session_db.create_session( session_id=self.session_id, source=source, @@ -537,6 +544,7 @@ def _ensure_db_session(self) -> None: user_id=None, parent_session_id=self._parent_session_id, cwd=_launch_cwd_for_session(source), + profile_name=_profile_for_session, ) self._session_db_created = True except Exception as e: From a774f5111b45e98bd308fe886897535cb6b67f39 Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Mon, 13 Jul 2026 22:57:20 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(gateway):=20profile=20routing=20?= =?UTF-8?q?=E2=80=94=20conjunctive=20matching=20+=20universal=20gateway=5F?= =?UTF-8?q?runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses hermes-sweeper review on #20096. Problem 1 (profile_routing.py): route matching returned True on a chat_id hit before the guild_id constraint was consulted, so a route declaring both guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND) semantics — every declared discriminator must hold; hierarchical parent_chat_id matching is preserved. Added a regression test for the guild+chat case. Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter pre-declared the attribute, and only Discord did — so build_source never called _profile_name_for_source for Telegram/Feishu/Slack/etc., despite the platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made the plugin-registry injection unconditional, so profile routing now reaches every platform. Added non-Discord (Telegram) resolution coverage and an injection-inheritance test. Also adds docs/profile-routing.md documenting gateway.profile_routes (matching rules, specificity, profile isolation) — requested in review. Co-Authored-By: Claude --- docs/profile-routing.md | 115 +++++++++++++++++++++++ gateway/platforms/base.py | 10 ++ gateway/profile_routing.py | 22 ++--- gateway/run.py | 14 +-- tests/gateway/test_profile_resolution.py | 76 +++++++++++++++ tests/gateway/test_profile_routing.py | 15 +++ 6 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 docs/profile-routing.md diff --git a/docs/profile-routing.md b/docs/profile-routing.md new file mode 100644 index 000000000000..52c6934b47ab --- /dev/null +++ b/docs/profile-routing.md @@ -0,0 +1,115 @@ +# Profile-Based Routing for Inbound Messages + +> **Audience:** Gateway operators and contributors +> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py` +> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md` + +## Overview + +By default a single gateway run uses one profile (memory, persona, tools). **Profile-based +routing** lets one gateway instance serve **multiple isolated profiles**, selecting which +profile handles an inbound message based on *where the message came from* — the platform, +server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`). + +This is the inbound counterpart to multiplexing: instead of running N gateways, run one +gateway and route per-community / per-channel / per-thread to a dedicated profile. Each +profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools). + +Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every +adapter — not just Discord. + +## Configuring routes + +Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested +`gateway.profile_routes` forms are accepted (the nested form is what +`hermes config set gateway.profile_routes ...` writes). + +```yaml +profile_routes: + # Route an entire Discord server (guild) to one profile. + - name: server-default + platform: discord + guild_id: "1234567890" + profile: server-profile + + # Override a specific channel within that server with a different profile. + - name: support-channel + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + profile: support-profile + + # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only). + - name: tg-group + platform: telegram + chat_id: "-1001234567890" + profile: tg-profile + + # Route a single Discord thread. + - name: standup-thread + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + thread_id: "1111111111" + profile: standup +``` + +### Fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Human-readable route identifier (used in logs). | +| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … | +| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/`). | +| `guild_id` | no | Server/guild (Discord). | +| `chat_id` | no | Channel/group/DM id. | +| `thread_id` | no | Thread id within a channel. | +| `enabled` | no | Default `true`; set `false` to disable a route without removing it. | + +## Matching rules + +A route matches an inbound source when **every discriminator the route declares is satisfied** +(conjunctive / AND). A field the route leaves unset is ignored. + +- **`platform`** must equal the source platform exactly. +- **`thread_id`** (if set) must equal the source thread id. +- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a + channel matches the channel's route (hierarchical match for Discord forums/threads). +- **`guild_id`** (if set) must equal the source guild. + +> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match +> alone does not satisfy a guild constraint — this is intentional and tested. + +When multiple routes match, the **most specific** one wins. Specificity is additive: + +| Discriminator | Weight | +|---|---| +| `thread_id` | 8 | +| `chat_id` | 4 | +| `guild_id` | 2 | +| (platform only) | 1 | + +So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server. +If no route matches, the message uses the default/active profile. + +## How it works at runtime + +1. An inbound message arrives at a platform adapter. +2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every + adapter carries a back-reference to the running `GatewayRunner` + (`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the + target profile via `_profile_name_for_source`. +3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and + stamps `source.profile` with the winning route's profile (or leaves it unset). +4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory + (`source.profile` → active profile → `default`) and the session is scoped per-profile, so + each routed community gets isolated memory and conversation state. + +Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`), +every platform goes through this path — not just Discord. + +## Migration / coexistence with multiplexing + +`profile_routes` is independent of `gateway.multiplex_profiles`. Multiplexing splits the +gateway across model credentials; profile routing splits conversation state across profiles. +They compose: you may multiplex credentials while also routing channels to distinct profiles. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 3a252b0e363c..e342daf184d7 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2345,6 +2345,16 @@ class BasePlatformAdapter(ABC): # generic seam; Slack is merely the first consumer). supports_inchannel_continuable: bool = False + # Back-reference to the running ``GatewayRunner``, injected by + # ``gateway/run.py`` after the adapter is created. Adapters consume it via + # ``getattr(self, "gateway_runner", None)`` for cross-platform delivery and + # — critically — for inbound profile routing: ``build_source`` resolves the + # target profile through ``runner._profile_name_for_source(...)``. Declaring + # it on the base (rather than only on adapters that happen to pre-declare + # it) means EVERY platform adapter receives the injection, so profile + # routing is platform-generic instead of Discord-only. + gateway_runner = None # type: ignore[assignment] # set by gateway/run.py + def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index b2884858fe52..c0ec0acc2bf9 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -80,10 +80,14 @@ def matches( parent_chat_id: Optional[str] = None, ) -> bool: """Return True if this route matches the given source fields. - - Supports hierarchical matching for Discord forums: + + All configured discriminators are matched conjunctively (AND): every + discriminator that the route declares must hold. ``chat_id`` supports + hierarchical matching for Discord forums/threads: - Direct channel match: chat_id == route.chat_id - Thread in channel: parent_chat_id == route.chat_id + A route declaring both ``guild_id`` and ``chat_id`` requires both to + match (a chat match alone does not satisfy a guild constraint). """ if not self.enabled: return False @@ -91,20 +95,8 @@ def matches( return False if self.thread_id and self.thread_id != thread_id: return False - - # Hierarchical chat_id matching - if self.chat_id: - # Direct match - if self.chat_id == chat_id: - return True - # Parent match (thread or direct child) - if self.chat_id == parent_chat_id: - return True - - # If chat_id was specified but didn't match any level, fail - if self.chat_id: + if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id: return False - if self.guild_id and self.guild_id != guild_id: return False return True diff --git a/gateway/run.py b/gateway/run.py index 0d64a7f36a45..16d335945642 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8730,12 +8730,14 @@ def _create_adapter( if platform_registry.is_registered(platform.value): adapter = platform_registry.create_adapter(platform.value, config) if adapter is not None: - # Adapters that need a back-reference to the gateway runner - # (e.g. for cross-platform admin alerts) declare a - # ``gateway_runner`` attribute. Inject it after creation so - # plugin adapters don't need a custom factory signature. - if hasattr(adapter, "gateway_runner"): - adapter.gateway_runner = self + # Inject a back-reference to the gateway runner so every + # adapter can (a) deliver cross-platform admin alerts and + # (b) resolve inbound profile routing through + # ``runner._profile_name_for_source``. Unconditional: + # ``BasePlatformAdapter`` declares ``gateway_runner``, so + # this reaches ALL platforms (not just the ones that + # pre-declared it), making profile routing platform-generic. + adapter.gateway_runner = self return adapter # Registered but failed to instantiate — don't silently fall # through to built-ins (there are none for plugin platforms). diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index da0ddf32bfb3..0658593ece8f 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -8,6 +8,7 @@ from gateway.session import SessionSource from gateway.run import GatewayRunner +from gateway.profile_routing import ProfileRoute @pytest.fixture @@ -33,6 +34,22 @@ def discord_source(): ) +@pytest.fixture +def telegram_source(): + """Create a basic Telegram SessionSource for testing. + + Telegram (like Slack/Feishu/etc.) has no ``guild_id`` — only ``chat_id``. + Used to prove profile routing is platform-generic, not Discord-only. + """ + return SessionSource( + platform=MagicMock(value="telegram"), + chat_id="-1001234567890", + guild_id=None, + thread_id=None, + parent_chat_id=None, + ) + + class TestResolutionOrder: """Tests that profile resolution follows the correct priority order.""" @@ -255,3 +272,62 @@ def test_routing_not_consulted_when_source_profile_set(self, mock_runner, discor # Should NOT have called routing mock_runner._profile_name_for_source.assert_not_called() + + +class TestNonDiscordProfileRouting: + """Profile routing must be platform-generic, not Discord-only. + + Regression coverage for the ``gateway_runner`` injection gap: previously + only Discord's adapter pre-declared ``gateway_runner``, so only Discord + ever had ``build_source`` call ``_profile_name_for_source``. Telegram / + Feishu / Slack / etc. silently fell through to the default profile. These + tests pin the resolution half for a non-Discord platform (Telegram). + """ + + def test_telegram_route_resolves(self, mock_runner, telegram_source): + """A configured Telegram route resolves to its profile via the real + ``_profile_name_for_source`` (bound onto the mock runner).""" + mock_runner.config.profile_routes = [ + ProfileRoute(name="tg", platform="telegram", profile="tg-profile", + chat_id="-1001234567890"), + ] + telegram_source.profile = None + + assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile" + + def test_telegram_no_route_returns_none(self, mock_runner, telegram_source): + """With no matching Telegram route, resolution returns None (caller + falls back to the default/active profile).""" + mock_runner.config.profile_routes = [ + ProfileRoute(name="dc", platform="discord", profile="dc-profile", + chat_id="123456"), + ] + telegram_source.profile = None + + assert mock_runner._profile_name_for_source(telegram_source) is None + + +class TestGatewayRunnerInjection: + """``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's + unconditional injection reaches every platform adapter — the foundation + that makes the routing in TestNonDiscordProfileRouting reachable at runtime. + """ + + def test_base_adapter_declares_gateway_runner(self): + from gateway.platforms.base import BasePlatformAdapter + + # Class-level attribute exists and defaults to None. + assert hasattr(BasePlatformAdapter, "gateway_runner") + assert BasePlatformAdapter.gateway_runner is None + + def test_subclass_inherits_gateway_runner(self): + from gateway.platforms.base import BasePlatformAdapter + + class _ToyAdapter(BasePlatformAdapter): + pass + + # No manual declaration — yet the attribute is inherited from the base, + # so the gateway's ``adapter.gateway_runner = self`` injection reaches + # every adapter, not just the ones that pre-declared it (Discord). + assert hasattr(_ToyAdapter, "gateway_runner") + assert _ToyAdapter.gateway_runner is None diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 4df37a196955..abb0c7bcc8ad 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -70,6 +70,21 @@ def test_extra_fields_ignored(self): guild_id="111") assert r.matches("discord", guild_id="111", chat_id="any") + def test_guild_and_chat_are_conjunctive(self): + # A route declaring BOTH guild_id and chat_id requires both to match. + # Regression guard: previously chat_id was checked first and returned + # True before guild_id was ever consulted. + r = ProfileRoute(name="gc", platform="discord", profile="scoped", + guild_id="111", chat_id="222") + # Both match (direct channel) -> match + assert r.matches("discord", guild_id="111", chat_id="222") + # Both match via parent (thread inside the channel) -> match + assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="222") + # chat matches but guild differs -> NO match (the bug this guards) + assert not r.matches("discord", guild_id="999", chat_id="222") + # guild matches but chat differs -> NO match + assert not r.matches("discord", guild_id="111", chat_id="333") + class TestParseProfileRoutes: def test_empty(self): From 149056e0867c12f8dc6969eb3965369bf510d63b Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Tue, 14 Jul 2026 10:04:02 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test(gateway):=20adapter=E2=86=92session-ke?= =?UTF-8?q?y=20integration=20for=20Discord=20+=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on #20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude --- tests/gateway/test_profile_resolution.py | 90 +++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index 0658593ece8f..0678dd5c8e8d 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -6,9 +6,11 @@ import pytest -from gateway.session import SessionSource +from gateway.session import SessionSource, build_session_key from gateway.run import GatewayRunner from gateway.profile_routing import ProfileRoute +from gateway.config import Platform +from gateway.platforms.base import BasePlatformAdapter @pytest.fixture @@ -331,3 +333,89 @@ class _ToyAdapter(BasePlatformAdapter): # every adapter, not just the ones that pre-declared it (Discord). assert hasattr(_ToyAdapter, "gateway_runner") assert _ToyAdapter.gateway_runner is None + + +# A concrete adapter we can instantiate without the full platform stack. +# ``build_source`` only reads ``self.platform`` and ``self.gateway_runner``, so a +# bare instance with those two attrs exercises the real BasePlatformAdapter +# method end-to-end. Clearing ``__abstractmethods__`` lets ``__new__`` bypass +# the ABC instantiation guard without stubbing connect/send/get_chat_info/… +class _StubAdapter(BasePlatformAdapter): + pass + + +_StubAdapter.__abstractmethods__ = frozenset() # type: ignore[attr-defined] + + +def _stub_adapter(platform: Platform, runner) -> "_StubAdapter": + a = _StubAdapter.__new__(_StubAdapter) + a.platform = platform + a.gateway_runner = runner + return a + + +class TestAdapterToSessionKeyIntegration: + """Adapter -> ``source.profile`` -> session-key integration coverage. + + The review asked for integration coverage for Discord AND a non-Discord + platform. These drive a concrete adapter's real ``build_source`` + (BasePlatformAdapter) with an injected ``gateway_runner``, assert the + matched route's profile is stamped on the source, and that the resulting + session key is profile-scoped (``agent::...`` rather than the + shared ``agent:main:...``). The Telegram case is the bug-#2 regression: + pre-fix it never received ``gateway_runner`` and fell through to default. + """ + + @staticmethod + def _routes(): + return [ + ProfileRoute(name="dc", platform="discord", profile="coder", + guild_id="111", chat_id="222"), + ProfileRoute(name="tg", platform="telegram", profile="ops", + chat_id="-1001234567890"), + ] + + def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner): + mock_runner.config.profile_routes = self._routes() + adapter = _stub_adapter(Platform.DISCORD, mock_runner) + + source = adapter.build_source( + chat_id="222", chat_type="group", guild_id="111", user_id="u1", + ) + assert source.profile == "coder" + + key = build_session_key(source, profile=source.profile) + assert key.startswith("agent:coder:"), key + # A default-profile key would land in agent:main — must differ. + assert key != build_session_key(source, profile=None) + + def test_telegram_adapter_stamps_profile_and_scopes_key(self, mock_runner): + """Non-Discord platform (bug #2). The adapter now receives + ``gateway_runner``, so ``build_source`` stamps the profile and the + session key is isolated under ``agent:ops:`` instead of ``agent:main:``.""" + mock_runner.config.profile_routes = self._routes() + adapter = _stub_adapter(Platform.TELEGRAM, mock_runner) + + source = adapter.build_source( + chat_id="-1001234567890", chat_type="group", user_id="u1", + ) + assert source.profile == "ops" + + key = build_session_key(source, profile=source.profile) + assert key.startswith("agent:ops:"), key + assert key != build_session_key(source, profile=None) + + def test_adapter_without_runner_falls_back_to_default_namespace(self, mock_runner): + """Regression anchor: with no ``gateway_runner`` injected (the pre-fix + state for non-Discord adapters), ``build_source`` leaves ``profile=None`` + and the session key is the shared ``agent:main:`` namespace — no + per-profile isolation. This is the silent fallback the fix removes for + non-Discord platforms.""" + adapter = _stub_adapter(Platform.TELEGRAM, runner=None) + + source = adapter.build_source( + chat_id="-1001234567890", chat_type="group", user_id="u1", + ) + assert source.profile is None + key = build_session_key(source, profile=source.profile) + assert key.startswith("agent:main:"), key