{"repo":"NousResearch/hermes-agent","public_id":"4321","display_label":"Gateway profile-based message routing feature","summary":"These members propose adding profile-based routing at the gateway level, allowing inbound messages to be deterministically directed via a default or hub-and-spoke attendant profile model. The work appears to span slash-command support and general inbound message routing.","subsystem":"agent","counts":{"members":29,"open_issues":6,"open_issues_distinct":6,"open_prs":2,"closed_issues":12,"merged_prs":1,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64835},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-15T16:50:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.083743842364532},"fix_edges":[{"issue_number":4321,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4622,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5195,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8339,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9514,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":10143,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":18423,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19809,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24913,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33548,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57871,"pr_number":64835,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":4321,"title":"[Feature] Map Telegram forum topics to different agent profiles under a single bot token","url":null,"state":"open"},{"kind":"issue","number":4622,"title":"Feature: Per-Topic Role/Skill Binding for Telegram Group Forum Topics","url":null,"state":"closed"},{"kind":"issue","number":7517,"title":"Feature Request: Native Multi-Agent Support","url":null,"state":"closed"},{"kind":"issue","number":9514,"title":"Feature: Single-Daemon Multi-Agent with Per-Topic Workspace & Memory Isolation","url":null,"state":"open"},{"kind":"issue","number":10030,"title":"Profiles support multiple user gateway : telegram weixin etc？？？","url":null,"state":"closed"},{"kind":"issue","number":10143,"title":"[Feature] Topic-to-Profile routing: dispatch messages to different profiles based on Telegram topic/thread","url":null,"state":"closed"},{"kind":"issue","number":18423,"title":"[Feature]: Profile routing for Discord","url":null,"state":"closed"},{"kind":"issue","number":19809,"title":"[Feature]: Per-channel profile routing for Discord (single bot, single gateway)","url":null,"state":"closed"},{"kind":"issue","number":23735,"title":"Design discussion: multi-profile deployments in a single gateway process","url":null,"state":"closed"},{"kind":"issue","number":24913,"title":"[Feature]: one gateway serves multiple agents — switch via `/profile <name>` or `@<name>`","url":null,"state":"open"},{"kind":"issue","number":29535,"title":"Gateway: route chat/thread messages to Hermes profiles in a single gateway process","url":null,"state":"closed"},{"kind":"issue","number":34819,"title":"Feature: Telegram forum topic identity routing — each topic acts as a different agent","url":null,"state":"closed"},{"kind":"issue","number":40173,"title":"feat(telegram): channel_profiles — route Telegram chats to Hermes profiles in one gateway","url":null,"state":"closed"},{"kind":"issue","number":52860,"title":"[Feature]: Gateway slash-command profile routing via a default attendant profile","url":null,"state":"open"},{"kind":"issue","number":57871,"title":"Support shared-token chat/topic routes for multiplexed gateway profiles","url":null,"state":"closed"},{"kind":"issue","number":57872,"title":"Support routing one bot token across multiple profile homes","url":null,"state":"closed"},{"kind":"issue","number":65018,"title":"feat: Gateway-Level Profile Router (Hub-and-Spoke) for deterministic message routing","url":null,"state":"open"},{"kind":"issue","number":65936,"title":"feat(gateway): /profile <name> should switch profiles in Telegram (and other messaging platforms)","url":null,"state":"open"},{"kind":"pr","number":4625,"title":"feat(telegram): per-topic skill binding and auto-discovery for group forums","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":5489,"title":"feat(telegram): per-topic skill binding and auto-discovery for group forums","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"1cae9ac62","number":0},{"kind":"commit","value":"5505dbbf4","number":0},{"kind":"commit","value":"ef73babea","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":18510,"title":"feat(telegram): route forum topics to Hermes profiles","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20018,"title":"feat: Profile-based channel routing for single-gateway multi-agent","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":24914,"title":"feat: One gateway for multiple agent profiles.","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f538470cf4a","number":0},{"kind":"commit","value":"d5d02eabb03","number":0},{"kind":"pr","value":"#64835","number":64835},{"kind":"pr","value":"#69441","number":69441}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":36872,"title":"Route messages to per-profile workers behind one bot token","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64835","number":64835}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":47027,"title":"feat(orchestration): AgentContext, RoutingTable, ContextPool — CICS-model for single-daemon multi-agent","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":53048,"title":"feat(gateway): per-topic profile isolation for Telegram forum topics","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#64835","number":64835},{"kind":"pr","value":"#20096","number":20096}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":57873,"title":"feat(gateway): route shared bot chats to profiles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64835","number":64835},{"kind":"pr","value":"#20096","number":20096}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":64835,"title":"feat(gateway): profile-based routing for inbound messages (salvage #20096)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["docs/profile-routing.md","gateway/config.py","gateway/platforms/base.py","gateway/platforms/weixin.py","gateway/profile_routing.py","gateway/run.py","gateway/session.py","hermes_state.py","plugins/platforms/discord/adapter.py","plugins/platforms/feishu/adapter.py","plugins/platforms/matrix/adapter.py","plugins/platforms/telegram/adapter.py","plugins/platforms/wecom/adapter.py","plugins/platforms/whatsapp/adapter.py","run_agent.py","scripts/release.py","tests/gateway/test_config.py","tests/gateway/test_multiplex_adapter_registry.py","tests/gateway/test_profile_resolution.py","tests/gateway/test_profile_routing.py","website/docs/user-guide/multi-profile-gateways.md"],"fix_hunks":[{"file":"docs/profile-routing.md","hunks":["@@ -0,0 +1,117 @@\n+# Profile-Based Routing for Inbound Messages\n+\n+> **Audience:** Gateway operators and contributors\n+> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py`\n+> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md`\n+\n+## Overview\n+\n+By default a single gateway run uses one profile (memory, persona, tools). **Profile-based\n+routing** lets one gateway instance serve **multiple isolated profiles**, selecting which\n+profile handles an inbound message based on *where the message came from* — the platform,\n+server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`).\n+\n+This is the inbound counterpart to multiplexing: instead of running N gateways, run one\n+gateway and route per-community / per-channel / per-thread to a dedicated profile. Each\n+profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools).\n+\n+Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every\n+adapter — not just Discord.\n+\n+## Configuring routes\n+\n+Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested\n+`gateway.profile_routes` forms are accepted (the nested form is what\n+`hermes config set gateway.profile_routes ...` writes).\n+\n+```yaml\n+profile_routes:\n+  # Route an entire Discord server (guild) to one profile.\n+  - name: server-default\n+    platform: discord\n+    guild_id: \"1234567890\"\n+    profile: server-profile\n+\n+  # Override a specific channel within that server with a different profile.\n+  - name: support-channel\n+    platform: discord\n+    guild_id: \"1234567890\"\n+    chat_id: \"9876543210\"\n+    profile: support-profile\n+\n+  # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).\n+  - name: tg-group\n+    platform: telegram\n+    chat_id: \"-1001234567890\"\n+    profile: tg-profile\n+\n+  # Route a single Discord thread.\n+  - name: standup-thread\n+    platform: discord\n+    guild_id: \"1234567890\"\n+    chat_id: \"9876543210\"\n+    thread_id: \"1111111111\"\n+    profile: standup\n+```\n+\n+### Fields\n+\n+| Field | Required | Description |\n+|---|---|---|\n+| `name` | yes | Human-readable route identifier (used in logs). |\n+| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … |\n+| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/<name>`). |\n+| `guild_id` | no | Server/guild (Discord). |\n+| `chat_id` | no | Channel/group/DM id. |\n+| `thread_id` | no | Thread id within a channel. |\n+| `enabled` | no | Default `true`; set `false` to disable a route without removing it. |\n+\n+## Matching rules\n+\n+A route matches an inbound source when **every discriminator the route declares is satisfied**\n+(conjunctive / AND). A field the route leaves unset is ignored.\n+\n+- **`platform`** must equal the source platform exactly.\n+- **`thread_id`** (if set) must equal the source thread id.\n+- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a\n+  channel matches the channel's route (hierarchical match for Discord forums/threads).\n+- **`guild_id`** (if set) must equal the source guild.\n+\n+> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match\n+> alone does not satisfy a guild constraint — this is intentional and tested.\n+\n+When multiple routes match, the **most specific** one wins. Specificity is additive:\n+\n+| Discriminator | Weight |\n+|---|---|\n+| `thread_id` | 8 |\n+| `chat_id` | 4 |\n+| `guild_id` | 2 |\n+| (platform only) | 0 |\n+\n+So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server.\n+If no route matches, the message uses the default/active profile.\n+\n+## How it works at runtime\n+\n+1. An inbound message arrives at a platform adapter.\n+2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every\n+   adapter carries a back-reference to the running `GatewayRunner`\n+   (`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the\n+   target profile via `_profile_name_for_source`.\n+3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and\n+   stamps `source.profile` with the winning route's profile (or leaves it unset).\n+4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory\n+   (`source.profile` → active profile → `default`) and the session is scoped per-profile, so\n+   each routed community gets isolated memory and conversation state.\n+\n+Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),\n+every platform goes through this path — not just Discord.\n+\n+## Relationship to multiplexing\n+\n+`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what\n+activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and\n+profile-namespaced session keys); routing is the decision layer that picks *which*\n+profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes`\n+is ignored entirely — behavior is byte-identical to a single-profile gateway."]},{"file":"gateway/config.py","hunks":["@@ -12,7 +12,7 @@\n import os\n import json\n from pathlib import Path\n-from dataclasses import dataclass, field\n+from dataclasses import asdict, dataclass, field, is_dataclass\n from typing import Dict, List, Optional, Any, Callable\n from enum import Enum\n ","@@ -721,6 +721,11 @@ class GatewayConfig:\n     # fresh session exactly as if the reset policy had fired.  0 = disabled.\n     session_store_max_age_days: int = 90\n \n+    # Profile-based routing: route specific guilds/channels/threads to\n+    # different profiles. See gateway/profile_routing.py. Each entry is a\n+    # dict with: name, platform, profile, and optional guild_id/chat_id/thread_id.\n+    profile_routes: list = field(default_factory=list)\n+\n     def get_connected_platforms(self) -> List[Platform]:\n         \"\"\"Return list of platforms that are enabled and configured.\"\"\"\n         connected = []","@@ -827,6 +832,10 @@ def to_dict(self) -> Dict[str, Any]:\n             \"unauthorized_dm_behavior\": self.unauthorized_dm_behavior,\n             \"streaming\": self.streaming.to_dict(),\n             \"session_store_max_age_days\": self.session_store_max_age_days,\n+            \"profile_routes\": [\n+                asdict(r) if is_dataclass(r) and not isinstance(r, type) else r\n+                for r in self.profile_routes\n+            ],\n         }\n     \n     @classmethod","@@ -919,6 +928,10 @@ def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n         except (TypeError, ValueError):\n             session_store_max_age_days = 90\n \n+        # Parse profile routes (validated by gateway.profile_routing)\n+        from gateway.profile_routing import parse_profile_routes\n+        profile_routes = parse_profile_routes(data.get(\"profile_routes\") or [])\n+\n         return cls(\n             platforms=platforms,\n             default_reset_policy=default_policy,","@@ -941,6 +954,7 @@ def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n             unauthorized_dm_behavior=unauthorized_dm_behavior,\n             streaming=StreamingConfig.from_dict(data.get(\"streaming\", {})),\n             session_store_max_age_days=session_store_max_age_days,\n+            profile_routes=profile_routes,\n         )\n \n     def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:","@@ -1053,6 +1067,17 @@ def load_gateway_config() -> GatewayConfig:\n             if \"multiplex_profiles\" in yaml_cfg:\n                 gw_data[\"multiplex_profiles\"] = yaml_cfg[\"multiplex_profiles\"]\n \n+            # Profile-based routing rules: accept either top-level\n+            # ``profile_routes`` or the nested ``gateway.profile_routes`` form\n+            # (matching the multiplex_profiles parity above).\n+            _pr = yaml_cfg.get(\"profile_routes\")\n+            if _pr is None:\n+                _gw_section = yaml_cfg.get(\"gateway\")\n+                if isinstance(_gw_section, dict):\n+                    _pr = _gw_section.get(\"profile_routes\")\n+            if isinstance(_pr, list):\n+                gw_data[\"profile_routes\"] = _pr\n+\n             gateway_section = yaml_cfg.get(\"gateway\")\n             if isinstance(gateway_section, dict):\n                 if \"multiplex_profiles\" in gateway_section and \"multiplex_profiles\" not in gw_data:"]},{"file":"gateway/platforms/base.py","hunks":["@@ -2353,6 +2353,16 @@ class BasePlatformAdapter(ABC):\n     # generic seam; Slack is merely the first consumer).\n     supports_inchannel_continuable: bool = False\n \n+    # Back-reference to the running ``GatewayRunner``, injected by\n+    # ``gateway/run.py`` after the adapter is created. Adapters consume it via\n+    # ``getattr(self, \"gateway_runner\", None)`` for cross-platform delivery and\n+    # — critically — for inbound profile routing: ``build_source`` resolves the\n+    # target profile through ``runner._profile_name_for_source(...)``. Declaring\n+    # it on the base (rather than only on adapters that happen to pre-declare\n+    # it) means EVERY platform adapter receives the injection, so profile\n+    # routing is platform-generic instead of Discord-only.\n+    gateway_runner = None  # type: ignore[assignment]  # set by gateway/run.py\n+\n     def __init__(self, config: PlatformConfig, platform: Platform):\n         self.config = config\n         self.platform = platform","@@ -5507,10 +5517,47 @@ def build_source(\n         auto_thread_created: bool = False,\n         auto_thread_initial_name: Optional[str] = None,\n     ) -> SessionSource:\n-        \"\"\"Helper to build a SessionSource for this platform.\"\"\"\n+        \"\"\"Helper to build a SessionSource for this platform.\n+\n+        When ``gateway.profile_routes`` is configured, the routing engine\n+        resolves the matching profile from guild/chat/thread and stamps it on\n+        ``source.profile``. Downstream code (``_resolve_profile_home_for_source``\n+        in run.py) reads that field to enter ``_profile_runtime_scope`` for\n+        per-profile HERMES_HOME isolation.\n+        \"\"\"\n         # Normalize empty topic to None\n         if chat_topic is not None and not chat_topic.strip():\n             chat_topic = None\n+\n+        # Resolve profile from configured routes (None when no match / no routes)\n+        profile = None\n+        runner = getattr(self, \"gateway_runner\", None)\n+        if runner is not None:\n+            try:\n+                profile = runner._profile_name_for_source(\n+                    SessionSource(\n+                        platform=self.platform,\n+                        chat_id=str(chat_id),\n+                        chat_name=chat_name,\n+                        chat_type=chat_type,\n+                        user_id=str(user_id) if user_id else None,\n+                        user_name=user_name,\n+                        thread_id=str(thread_id) if thread_id else None,\n+                        chat_topic=chat_topic.strip() if chat_topic else None,\n+                        user_id_alt=user_id_alt,\n+                        chat_id_alt=chat_id_alt,\n+                        is_bot=is_bot,\n+                        guild_id=str(guild_id) if guild_id else None,\n+                        parent_chat_id=str(parent_chat_id) if parent_chat_id else None,\n+                        message_id=str(message_id) if message_id else None,\n+                    )\n+                )\n+            except Exception:\n+                logger.warning(\n+                    \"Profile resolution failed for %s/%s, defaulting to active profile\",\n+                    self.platform, chat_id, exc_info=True,\n+                )\n+\n         return SessionSource(\n             platform=self.platform,\n             chat_id=str(chat_id),","@@ -5527,6 +5574,7 @@ def build_source(\n             guild_id=str(guild_id) if guild_id else None,\n             parent_chat_id=str(parent_chat_id) if parent_chat_id else None,\n             message_id=str(message_id) if message_id else None,\n+            profile=profile,\n             role_authorized=role_authorized,\n             auto_thread_created=auto_thread_created,\n             auto_thread_initial_name=auto_thread_initial_name,"]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -1515,6 +1515,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"gateway/profile_routing.py","hunks":["@@ -0,0 +1,166 @@\n+\"\"\"Profile-based routing for the gateway with hierarchical matching.\n+\n+Allows a single Hermes instance to route specific Discord guilds/channels/threads\n+to different profiles — each with their own model, tools, memory, and persona.\n+\n+Matching priority (most specific first):\n+  1. platform + chat_id + thread_id (exact thread)  — specificity 14\n+  2. platform + chat_id (channel route)             — specificity 6\n+  3. platform + guild_id (guild/server route)       — specificity 2\n+  4. No match                                       → default profile\n+\n+Parent-chain matching:\n+For Discord threads and forum posts, ``parent_chat_id`` carries the\n+direct parent (the channel for a thread, the forum channel for a post).\n+Routes keyed on a channel match both direct messages and messages in\n+any thread/post whose parent is that channel.\n+\n+Configuration (config.yaml):\n+\n+    gateway:\n+      profile_routes:\n+        - name: server-default\n+          platform: discord\n+          guild_id: \"YOUR_GUILD_ID\"\n+          profile: server-profile\n+\n+        - name: special-channel\n+          platform: discord\n+          guild_id: \"YOUR_GUILD_ID\"\n+          chat_id: \"YOUR_CHANNEL_ID\"\n+          profile: channel-profile\n+\n+        - name: thread-route\n+          platform: discord\n+          chat_id: \"YOUR_CHANNEL_ID\"\n+          thread_id: \"YOUR_THREAD_ID\"\n+          profile: thread-profile\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from dataclasses import dataclass\n+from typing import Any, Dict, List, Optional\n+\n+import logging\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+@dataclass(frozen=True)\n+class ProfileRoute:\n+    \"\"\"A single routing rule that maps a platform scope to a profile.\"\"\"\n+\n+    name: str\n+    platform: str\n+    profile: str\n+    guild_id: Optional[str] = None\n+    chat_id: Optional[str] = None\n+    thread_id: Optional[str] = None\n+    enabled: bool = True\n+\n+    @property\n+    def specificity(self) -> int:\n+        \"\"\"Higher value = more specific match.\"\"\"\n+        s = 0\n+        if self.guild_id:\n+            s += 2\n+        if self.chat_id:\n+            s += 4\n+        if self.thread_id:\n+            s += 8\n+        return s\n+\n+    def matches(\n+        self,\n+        platform: str,\n+        guild_id: Optional[str] = None,\n+        chat_id: Optional[str] = None,\n+        thread_id: Optional[str] = None,\n+        parent_chat_id: Optional[str] = None,\n+    ) -> bool:\n+        \"\"\"Return True if this route matches the given source fields.\n+\n+        All configured discriminators are matched conjunctively (AND): every\n+        discriminator that the route declares must hold. ``chat_id`` supports\n+        hierarchical matching for Discord forums/threads:\n+        - Direct channel match: chat_id == route.chat_id\n+        - Thread in channel: parent_chat_id == route.chat_id\n+        A route declaring both ``guild_id`` and ``chat_id`` requires both to\n+        match (a chat match alone does not satisfy a guild constraint).\n+        \"\"\"\n+        if not self.enabled:\n+            return False\n+        if self.platform != platform:\n+            return False\n+        if self.thread_id and self.thread_id != thread_id:\n+            return False\n+        if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id:\n+            return False\n+        if self.guild_id and self.guild_id != guild_id:\n+            return False\n+        return True\n+\n+\n+def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:\n+    \"\"\"Parse profile_routes from config.yaml into ProfileRoute objects.\n+\n+    Returns routes sorted by specificity (most specific first).\n+    \"\"\"\n+    if not raw:\n+        return []\n+    routes: List[ProfileRoute] = []\n+    for entry in raw:\n+        if not isinstance(entry, dict):\n+            continue\n+        name = entry.get(\"name\", \"\")\n+        platform = entry.get(\"platform\", \"\")\n+        profile = entry.get(\"profile\", \"\")\n+        if not platform or not profile:\n+            logger.warning(\n+                \"Skipping profile route %s: missing platform or profile\",\n+                name,\n+            )\n+            continue\n+        # Validate profile name to prevent path traversal. Lazy import avoids a\n+        # circular dependency at module load time.\n+        try:\n+            from hermes_cli.profiles import (\n+                normalize_profile_name,\n+                validate_profile_name,\n+            )\n+            profile = normalize_profile_name(profile)\n+            validate_profile_name(profile)\n+        except (ValueError, ImportError):\n+            logger.warning(\"Skipping profile route %s: invalid profile name %r\", name, profile)\n+            continue\n+        routes.append(\n+            ProfileRoute(\n+                name=name,\n+                platform=platform,\n+                profile=profile,\n+                guild_id=entry.get(\"guild_id\"),\n+                chat_id=entry.get(\"chat_id\"),\n+                thread_id=entry.get(\"thread_id\"),\n+                enabled=entry.get(\"enabled\", True),\n+            )\n+        )\n+    # Sort: most specific first so the first match wins.\n+    routes.sort(key=lambda r: r.specificity, reverse=True)\n+    logger.debug(\"Loaded %d profile routes (most-specific-first)\", len(routes))\n+    return routes\n+\n+\n+def match_profile_route(\n+    routes: List[ProfileRoute],\n+    platform: str,\n+    guild_id: Optional[str] = None,\n+    chat_id: Optional[str] = None,\n+    thread_id: Optional[str] = None,\n+    parent_chat_id: Optional[str] = None,\n+) -> Optional[ProfileRoute]:\n+    \"\"\"Return the best-matching route, or None for no match.\"\"\"\n+    for route in routes:\n+        if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):\n+            return route\n+    return None"]},{"file":"gateway/run.py","hunks":["@@ -8759,6 +8759,20 @@ def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]:\n             if isinstance(val, str) and val.strip():\n                 token = [redacted:secret-assignment]\n                 break\n+        # Many adapters (e.g. Discord) store the token on their `config`\n+        # sub-object rather than directly on the adapter. Without this lookup\n+        # those adapters all return None here, the same-token conflict check\n+        # is silently skipped, and every profile's adapter for that platform\n+        # starts polling the same bot token — producing a per-message race\n+        # for which adapter answers. See test_reads_config_token.\n+        if not token:\n+            cfg = getattr(adapter, \"config\", None)\n+            if cfg is not None:\n+                for attr in (\"token\", \"bot_token\"):\n+                    val = getattr(cfg, attr, None)\n+                    if isinstance(val, str) and val.strip():\n+                        token = [redacted:secret-assignment]\n+                        break\n         if not token:\n             config = getattr(adapter, \"config\", None)\n             val = getattr(config, \"token\", None)","@@ -8795,12 +8809,14 @@ def _create_adapter(\n             if platform_registry.is_registered(platform.value):\n                 adapter = platform_registry.create_adapter(platform.value, config)\n                 if adapter is not None:\n-                    # Adapters that need a back-reference to the gateway runner\n-                    # (e.g. for cross-platform admin alerts) declare a\n-                    # ``gateway_runner`` attribute. Inject it after creation so\n-                    # plugin adapters don't need a custom factory signature.\n-                    if hasattr(adapter, \"gateway_runner\"):\n-                        adapter.gateway_runner = self\n+                    # Inject a back-reference to the gateway runner so every\n+                    # adapter can (a) deliver cross-platform admin alerts and\n+                    # (b) resolve inbound profile routing through\n+                    # ``runner._profile_name_for_source``. Unconditional:\n+                    # ``BasePlatformAdapter`` declares ``gateway_runner``, so\n+                    # this reaches ALL platforms (not just the ones that\n+                    # pre-declared it), making profile routing platform-generic.\n+                    adapter.gateway_runner = self\n                     return adapter\n                 # Registered but failed to instantiate — don't silently fall\n                 # through to built-ins (there are none for plugin platforms).","@@ -17246,19 +17262,108 @@ async def _run_agent(\n                 persist_user_timestamp=persist_user_timestamp,\n             )\n \n+    def _profile_name_for_source(self, source: SessionSource) -> Optional[str]:\n+        \"\"\"Resolve the profile name for an inbound source via configured routes.\n+\n+        Returns ``None`` when multiplexing is off, no routes are configured, or\n+        no route matches. Callers (``build_source``,\n+        ``_resolve_profile_home_for_source``) treat ``None`` as \"use the\n+        default/active profile\". When ``gateway.profile_routes`` is configured,\n+        the most specific matching route wins (guild < channel < thread). See\n+        :mod:`gateway.profile_routing` for matching rules.\n+\n+        Gated on ``gateway.multiplex_profiles``: routing stamps\n+        ``source.profile``, which selects the session-key namespace and batch\n+        keys — but the profile-scoped agent run only activates under\n+        multiplexing. Without this gate, a configured route with multiplexing\n+        off would namespace batch/session keys by profile while the agent\n+        still runs in ``agent:main``, splitting the two out of agreement.\n+        \"\"\"\n+        config = getattr(self, \"config\", None)\n+        if not getattr(config, \"multiplex_profiles\", False):\n+            return None\n+        routes = getattr(config, \"profile_routes\", None)\n+        if not routes:\n+            return None\n+        from gateway.profile_routing import match_profile_route\n+        try:\n+            matched = match_profile_route(\n+                routes,\n+                platform=source.platform.value,\n+                guild_id=getattr(source, \"guild_id\", None),\n+                chat_id=source.chat_id,\n+                thread_id=getattr(source, \"thread_id\", None),\n+                parent_chat_id=getattr(source, \"parent_chat_id\", None),\n+            )\n+        except Exception:\n+            logger.warning(\n+                \"Profile route matching failed for %s/%s, falling back to default\",\n+                source.platform, source.chat_id, exc_info=True,\n+            )\n+            return None\n+        if matched:\n+            return matched.profile\n+        logger.debug(\n+            \"No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s\",\n+            source.platform.value, source.chat_id,\n+            getattr(source, \"thread_id\", None), getattr(source, \"parent_chat_id\", None),\n+        )\n+        return None\n+\n     def _resolve_profile_home_for_source(self, source: SessionSource) -> \"Path\":\n         \"\"\"Resolve which profile's HERMES_HOME should serve this inbound source.\n \n-        Prefers the profile the source was routed to (``source.profile`` — set\n-        by the /p/<profile>/ URL prefix or a per-credential adapter), falling\n-        back to the active profile (the multiplexer's own home).\n+        Resolution order:\n+          1. ``source.profile`` — set by /p/<profile>/ URL prefix, per-credential\n+             adapter ownership, OR profile_routes matching at ``build_source`` time.\n+          2. ``_profile_name_for_source`` — re-run routing here as a defensive\n+             fallback for sources that bypass ``build_source``.\n+          3. The active profile (the multiplexer's own home).\n         \"\"\"\n-        from hermes_cli.profiles import get_active_profile_name, get_profile_dir\n+        from hermes_cli.profiles import (\n+            get_active_profile_name,\n+            get_profile_dir,\n+            profile_exists,\n+        )\n+        from hermes_constants import get_hermes_home\n+        \n+        # Track whether a profile was explicitly requested (vs. falling back to default)\n+        explicit_profile = None\n         try:\n-            name = (source.profile or \"\").strip() or get_active_profile_name() or \"default\"\n-            return get_profile_dir(name)\n+            name = (source.profile or \"\").strip()\n+            if name:\n+                explicit_profile = name  # User explicitly set this profile\n+            if not name:\n+                name = self._profile_name_for_source(source)\n+                if name:\n+                    explicit_profile = name  # Routing explicitly set this profile\n+            if not name:\n+                name = get_active_profile_name() or \"default\"\n+            \n+            profile_dir = get_profile_dir(name)\n+            # Warn if an explicit profile doesn't exist on disk\n+            if explicit_profile and not profile_exists(name):\n+                logger.warning(\n+                    \"Profile %r does not exist for source %s/%s (guild_id=%s), \"\n+                    \"falling back to global HERMES_HOME\",\n+                    explicit_profile,\n+                    source.platform.value,\n+                    source.chat_id,\n+                    getattr(source, \"guild_id\", None),\n+                )\n+                return get_hermes_home()\n+            return profile_dir\n         except Exception:\n-            from hermes_constants import get_hermes_home\n+            # Catch normalization errors, path errors, etc.\n+            logger.warning(\n+                \"Failed to resolve profile directory for source %s/%s (guild_id=%s), \"\n+                \"falling back to global HERMES_HOME: %s\",\n+                source.platform.value,\n+                source.chat_id,\n+                getattr(source, \"guild_id\", None),\n+                explicit_profile or \"(no profile)\",\n+                exc_info=True,\n+            )\n             return get_hermes_home()\n \n     async def _run_agent_inner("]},{"file":"gateway/session.py","hunks":["@@ -1993,6 +1993,7 @@ def _get_or_create_session_impl(\n                     \"chat_id\": source.chat_id,\n                     \"chat_type\": source.chat_type,\n                     \"thread_id\": source.thread_id,\n+                    \"profile_name\": source.profile,\n                 }\n \n         if _needs_save:","@@ -2273,6 +2274,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->\n                 \"chat_id\": old_entry.origin.chat_id if old_entry.origin else None,\n                 \"chat_type\": old_entry.origin.chat_type if old_entry.origin else None,\n                 \"thread_id\": old_entry.origin.thread_id if old_entry.origin else None,\n+                \"profile_name\": old_entry.origin.profile if old_entry.origin else None,\n             }\n \n         if self._db and db_end_session_id:"]},{"file":"hermes_state.py","hunks":["@@ -791,6 +791,7 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A\n     compression_failure_cooldown_until REAL,\n     compression_failure_error TEXT,\n     compression_fallback_streak INTEGER NOT NULL DEFAULT 0,\n+    profile_name TEXT,\n     rewind_count INTEGER NOT NULL DEFAULT 0,\n     archived INTEGER NOT NULL DEFAULT 0,\n     FOREIGN KEY (parent_session_id) REFERENCES sessions(id)","@@ -1760,6 +1761,7 @@ def _insert_session_row(\n         thread_id: str = None,\n         parent_session_id: str = None,\n         cwd: str = None,\n+        profile_name: str = None,\n     ) -> None:\n         \"\"\"Insert a session row, enriching NULL metadata on conflict.\n ","@@ -1783,9 +1785,9 @@ def _do(conn):\n             conn.execute(\n                 \"\"\"INSERT INTO sessions (\n                    id, source, user_id, session_key, chat_id, chat_type, thread_id,\n-                   model, model_config, system_prompt, parent_session_id, cwd, started_at\n+                   model, model_config, system_prompt, parent_session_id, cwd, profile_name, started_at\n                 )\n-                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n                    ON CONFLICT(id) DO UPDATE SET\n                        model = COALESCE(sessions.model, excluded.model),\n                        model_config = COALESCE(sessions.model_config, excluded.model_config),","@@ -1795,7 +1797,8 @@ def _do(conn):\n                        chat_type = COALESCE(sessions.chat_type, excluded.chat_type),\n                        thread_id = COALESCE(sessions.thread_id, excluded.thread_id),\n                        parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),\n-                       cwd = COALESCE(sessions.cwd, excluded.cwd)\"\"\",\n+                       cwd = COALESCE(sessions.cwd, excluded.cwd),\n+                       profile_name = COALESCE(sessions.profile_name, excluded.profile_name)\"\"\",\n                 (\n                     session_id,\n                     source,","@@ -1809,6 +1812,7 @@ def _do(conn):\n                     system_prompt,\n                     parent_session_id,\n                     cwd,\n+                    profile_name,\n                     time.time(),\n                 ),\n             )"]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -6612,12 +6612,20 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =\n     # ------------------------------------------------------------------\n \n     def _text_batch_key(self, event: MessageEvent) -> str:\n-        \"\"\"Session-scoped key for text message batching.\"\"\"\n+        \"\"\"Session-scoped key for text message batching.\n+\n+        Passes ``event.source.profile`` through so routed messages batch\n+        under the same namespace the agent run will use (e.g.\n+        ``agent:crypto-trader`` instead of ``agent:main``). Without this,\n+        the batch key would always land in ``agent:main`` even when the\n+        routed profile differs.\n+        \"\"\"\n         from gateway.session import build_session_key\n         return build_session_key(\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"plugins/platforms/feishu/adapter.py","hunks":["@@ -3633,6 +3633,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     @staticmethod"]},{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -3455,6 +3455,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             thread_sessions_per_user=self.config.extra.get(\n                 \"thread_sessions_per_user\", False\n             ),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -7925,6 +7925,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"plugins/platforms/wecom/adapter.py","hunks":["@@ -578,6 +578,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -1278,6 +1278,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:\n             event.source,\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            profile=event.source.profile,\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:"]},{"file":"run_agent.py","hunks":["@@ -599,6 +599,13 @@ def _ensure_db_session(self) -> None:\n             return\n         source = _session_source_for_agent(self.platform)\n         try:\n+            try:\n+                from hermes_cli.profiles import get_active_profile_name\n+                _profile_for_session = get_active_profile_name()\n+                if _profile_for_session == \"default\":\n+                    _profile_for_session = None\n+            except Exception:\n+                _profile_for_session = None\n             self._session_db.create_session(\n                 session_id=self.session_id,\n                 source=source,","@@ -608,6 +615,7 @@ def _ensure_db_session(self) -> None:\n                 user_id=None,\n                 parent_session_id=self._parent_session_id,\n                 cwd=_launch_cwd_for_session(source),\n+                profile_name=_profile_for_session,\n             )\n             self._session_db_created = True\n         except Exception as e:"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"Burgunthy\",  # PR #20096 salvage (gateway: profile-based routing for inbound messages)\n     \"[redacted:email]\": \"webtecnica\",  # PR #63360 salvage (nous: restore inference-api base_url)\n     \"[redacted:email]\": \"minchang\",  # PR #42231 salvage (providers: add Upstage Solar)\n     \"[redacted:email]\": \"neo-claw-bot\",  # PR #58465 salvage (moa: drop empty user turns from advisory view)"]},{"file":"tests/gateway/test_config.py","hunks":["@@ -1467,3 +1467,93 @@ def test_resolver_tristate(self, monkeypatch):\n         for noise in (\"\", \"   \", \"maybe\", \"2\"):\n             monkeypatch.setenv(\"GATEWAY_MULTIPLEX_PROFILES\", noise)\n             assert _env_multiplex_profiles_override() is None, repr(noise)\n+\n+\n+class TestMultiplexProfilesConfig:\n+    \"\"\"Tests for parsing multiplex_profiles (top-level and nested forms).\"\"\"\n+\n+    def test_multiplex_profiles_top_level(self, tmp_path, monkeypatch):\n+        \"\"\"Top-level multiplex_profiles is honored.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"config.yaml\").write_text(\n+            \"multiplex_profiles: true\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.multiplex_profiles is True\n+\n+    def test_multiplex_profiles_nested_under_gateway(self, tmp_path, monkeypatch):\n+        \"\"\"gateway.multiplex_profiles (the form written by `hermes config set\n+        gateway.multiplex_profiles true`) must be honored. Regression test for\n+        the silent-fallback bug where the loader only forwarded the top-level\n+        key, so users who wrote it under gateway: got multiplex_profiles=False\n+        with no warning.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"config.yaml\").write_text(\n+            \"gateway:\\n  multiplex_profiles: true\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.multiplex_profiles is True, (\n+            \"gateway.multiplex_profiles: true was silently ignored — \"\n+            \"loader only forwarded the top-level form\"\n+        )\n+\n+    def test_multiplex_profiles_default_false(self, tmp_path, monkeypatch):\n+        \"\"\"Default is False when neither form is present.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"config.yaml\").write_text(\"\", encoding=\"utf-8\")\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.multiplex_profiles is False\n+\n+    def test_multiplex_profiles_top_level_overrides_nested(self, tmp_path, monkeypatch):\n+        \"\"\"When both forms are present, top-level wins (matches profile_routes\n+        and other parity bridges in load_gateway_config).\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"config.yaml\").write_text(\n+            \"multiplex_profiles: true\\n\"\n+            \"gateway:\\n  multiplex_profiles: false\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.multiplex_profiles is True\n+\n+    def test_multiplex_profiles_explicit_top_level_false_not_consulting_nested(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Lock in the `is None` vs `is False` distinction: when top-level is\n+        explicitly false, the loader must forward False WITHOUT consulting the\n+        nested form (so a stale `gateway.multiplex_profiles: true` cannot\n+        silently re-enable multiplexing). Guards against a future regression\n+        that flips the check to `not _mp`.\"\"\"\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        (hermes_home / \"config.yaml\").write_text(\n+            \"multiplex_profiles: false\\n\"\n+            \"gateway:\\n  multiplex_profiles: true\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.multiplex_profiles is False, (\n+            \"Explicit top-level false was overridden by nested true — \"\n+            \"loader must respect top-level precedence when key is present\"\n+        )"]},{"file":"tests/gateway/test_multiplex_adapter_registry.py","hunks":["@@ -45,6 +45,58 @@ class _Config:\n         assert \"config-token\" not in fp\n \n \n+    def test_reads_config_token(self):\n+        \"\"\"Adapters like Discord store token on `config`, not on self.\n+\n+        Without the config-token fallback, every Discord adapter in a\n+        multiplexed gateway returns None here and the same-token conflict\n+        check is silently skipped — N adapters start polling the same bot\n+        token and race on every inbound message.\n+        \"\"\"\n+        class _Config:\n+            token = \"[redacted:secret-assignment]\"\n+        class _ConfigBackedAdapter:\n+            config = _Config()\n+        fp = GatewayRunner._adapter_credential_fingerprint(_ConfigBackedAdapter())\n+        assert fp is not None\n+        assert \"discord-bot-token\" not in fp\n+        assert len(fp) == 16\n+\n+    def test_distinct_config_tokens_distinct_fp(self):\n+        class _CfgA:\n+            token = \"tok-A\"\n+        class _CfgB:\n+            token = \"tok-B\"\n+        class _A:\n+            config = _CfgA()\n+        class _B:\n+            config = _CfgB()\n+        a = GatewayRunner._adapter_credential_fingerprint(_A())\n+        b = GatewayRunner._adapter_credential_fingerprint(_B())\n+        assert a is not None and b is not None\n+        assert a != b\n+\n+    def test_direct_token_takes_precedence_over_config(self):\n+        \"\"\"If both `adapter.token` and `adapter.config.token` exist, direct wins.\"\"\"\n+        class _Cfg:\n+            token = \"[redacted:secret-assignment]\"\n+        class _Both:\n+            token = \"[redacted:secret-assignment]\"\n+            config = _Cfg()\n+        fp = GatewayRunner._adapter_credential_fingerprint(_Both())\n+        import hashlib\n+        expected = hashlib.sha256(b\"hermes-mux:from-direct\").hexdigest()[:16]\n+        assert fp == expected\n+\n+    def test_config_without_token_returns_none(self):\n+        \"\"\"config present but no token attribute → None (no false positive).\"\"\"\n+        class _Cfg:\n+            pass\n+        class _Adapter:\n+            config = _Cfg()\n+        assert GatewayRunner._adapter_credential_fingerprint(_Adapter()) is None\n+\n+\n class TestProfileMessageHandler:\n     @pytest.mark.asyncio\n     async def test_stamps_profile_on_unstamped_source(self):"]},{"file":"tests/gateway/test_profile_resolution.py","hunks":["@@ -0,0 +1,471 @@\n+\"\"\"Tests for GatewayRunner._resolve_profile_home_for_source — profile resolution logic.\"\"\"\n+\n+import logging\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from gateway.session import SessionSource, build_session_key\n+from gateway.run import GatewayRunner\n+from gateway.profile_routing import ProfileRoute\n+from gateway.config import Platform\n+from gateway.platforms.base import BasePlatformAdapter\n+\n+\n[redacted:email]\n+def mock_runner():\n+    \"\"\"Create a minimal mock GatewayRunner with the methods we need.\"\"\"\n+    runner = MagicMock(spec=GatewayRunner)\n+    runner.config = MagicMock(profile_routes=[])\n+    # Bind the actual methods to the mock\n+    runner._profile_name_for_source = GatewayRunner._profile_name_for_source.__get__(runner)\n+    runner._resolve_profile_home_for_source = GatewayRunner._resolve_profile_home_for_source.__get__(runner)\n+    return runner\n+\n+\n[redacted:email]\n+def discord_source():\n+    \"\"\"Create a basic Discord SessionSource for testing.\"\"\"\n+    return SessionSource(\n+        platform=MagicMock(value=\"discord\"),\n+        chat_id=\"123456\",\n+        guild_id=\"789\",\n+        thread_id=None,\n+        parent_chat_id=None,\n+    )\n+\n+\n[redacted:email]\n+def telegram_source():\n+    \"\"\"Create a basic Telegram SessionSource for testing.\n+\n+    Telegram (like Slack/Feishu/etc.) has no ``guild_id`` — only ``chat_id``.\n+    Used to prove profile routing is platform-generic, not Discord-only.\n+    \"\"\"\n+    return SessionSource(\n+        platform=MagicMock(value=\"telegram\"),\n+        chat_id=\"-1001234567890\",\n+        guild_id=None,\n+        thread_id=None,\n+        parent_chat_id=None,\n+    )\n+\n+\n+class TestResolutionOrder:\n+    \"\"\"Tests that profile resolution follows the correct priority order.\"\"\"\n+    \n+    def test_source_profile_wins_over_routing(self, mock_runner, discord_source):\n+        \"\"\"source.profile should be used even if routing would match.\"\"\"\n+        discord_source.profile = \"from-source\"\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=True):\n+                    mock_get_dir.return_value = Path(\"/hermes/profiles/from-source\")\n+                    result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                    \n+                    assert result == Path(\"/hermes/profiles/from-source\")\n+                    mock_get_dir.assert_called_once_with(\"from-source\")\n+    \n+    def test_routing_wins_over_active_profile(self, mock_runner, discord_source):\n+        \"\"\"When source.profile is empty, routing should win over active profile.\"\"\"\n+        discord_source.profile = None\n+        \n+        # Mock routing to return a profile\n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=True):\n+                    mock_get_dir.return_value = Path(\"/hermes/profiles/routed\")\n+                    \n+                    # Manually set routing to return a profile\n+                    mock_runner._profile_name_for_source = MagicMock(return_value=\"routed\")\n+                    \n+                    result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                    \n+                    assert result == Path(\"/hermes/profiles/routed\")\n+                    mock_get_dir.assert_called_once_with(\"routed\")\n+    \n+    def test_active_profile_fallback(self, mock_runner, discord_source):\n+        \"\"\"When source.profile and routing both return None, active profile is used.\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/active\")\n+                \n+                # No routing match\n+                mock_runner._profile_name_for_source = MagicMock(return_value=None)\n+                \n+                result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                \n+                assert result == Path(\"/hermes/profiles/active\")\n+                mock_get_dir.assert_called_once_with(\"active\")\n+    \n+    def test_default_fallback_when_no_active(self, mock_runner, discord_source):\n+        \"\"\"When even active profile is None, 'default' is used.\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=None):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes\")\n+                \n+                mock_runner._profile_name_for_source = MagicMock(return_value=None)\n+                \n+                result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                \n+                assert result == Path(\"/hermes\")\n+                mock_get_dir.assert_called_once_with(\"default\")\n+\n+\n+class TestMissingProfileWarning:\n+    \"\"\"Tests for warning when a profile doesn't exist on disk.\"\"\"\n+    \n+    def test_nonexistent_profile_warning(self, mock_runner, discord_source, caplog):\n+        \"\"\"When source.profile points to a nonexistent profile, log a WARNING.\"\"\"\n+        discord_source.profile = \"nonexistent\"\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/nonexistent\")\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=False):\n+                    with patch(\"hermes_constants.get_hermes_home\", return_value=Path(\"/hermes\")):\n+                        with caplog.at_level(logging.WARNING):\n+                            result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                            \n+                            # Should fall back to global HERMES_HOME\n+                            assert result == Path(\"/hermes\")\n+                            \n+                            # Should have logged a warning\n+                            assert len(caplog.records) == 1\n+                            assert caplog.records[0].levelname == \"WARNING\"\n+                            assert \"nonexistent\" in caplog.records[0].message\n+                            assert \"does not exist\" in caplog.records[0].message\n+                            assert \"discord\" in caplog.records[0].message\n+                            assert \"123456\" in caplog.records[0].message\n+    \n+    def test_nonexistent_routing_profile_warning(self, mock_runner, discord_source, caplog):\n+        \"\"\"When routing returns a nonexistent profile, log a WARNING.\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/routed\")\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=False):\n+                    with patch(\"hermes_constants.get_hermes_home\", return_value=Path(\"/hermes\")):\n+                        # Routing returns a profile that doesn't exist\n+                        mock_runner._profile_name_for_source = MagicMock(return_value=\"routed\")\n+                        \n+                        with caplog.at_level(logging.WARNING):\n+                            result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                            \n+                            # Should fall back to global HERMES_HOME\n+                            assert result == Path(\"/hermes\")\n+                            \n+                            # Should have logged a warning\n+                            assert len(caplog.records) == 1\n+                            assert \"routed\" in caplog.records[0].message\n+    \n+    def test_empty_source_profile_no_warning(self, mock_runner, discord_source, caplog):\n+        \"\"\"When source.profile is empty, silent fallback to active profile (no warning).\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/active\")\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=True):\n+                    with caplog.at_level(logging.WARNING):\n+                        mock_runner._profile_name_for_source = MagicMock(return_value=None)\n+                        \n+                        result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                        \n+                        # Should use active profile\n+                        assert result == Path(\"/hermes/profiles/active\")\n+                        \n+                        # No warnings (active profile exists)\n+                        assert not any(r.levelname == \"WARNING\" for r in caplog.records)\n+    \n+    def test_existing_profile_no_warning(self, mock_runner, discord_source, caplog):\n+        \"\"\"When the profile exists, no warning should be logged.\"\"\"\n+        discord_source.profile = \"existing\"\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/existing\")\n+                with patch(\"hermes_cli.profiles.profile_exists\", return_value=True):\n+                    with caplog.at_level(logging.WARNING):\n+                        result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                        \n+                        assert result == Path(\"/hermes/profiles/existing\")\n+                        \n+                        # No warnings\n+                        assert not any(r.levelname == \"WARNING\" for r in caplog.records)\n+\n+\n+class TestExceptionHandling:\n+    \"\"\"Tests for exception handling in profile resolution.\"\"\"\n+    \n+    def test_get_profile_dir_exception_logs_warning(self, mock_runner, discord_source, caplog):\n+        \"\"\"When get_profile_dir raises an exception, log a WARNING with context.\"\"\"\n+        discord_source.profile = \"bad-profile\"\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\", side_effect=ValueError(\"Invalid profile name\")):\n+                with patch(\"hermes_constants.get_hermes_home\", return_value=Path(\"/hermes\")):\n+                    with caplog.at_level(logging.WARNING):\n+                        result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                        \n+                        # Should fall back to global HERMES_HOME\n+                        assert result == Path(\"/hermes\")\n+                        \n+                        # Should have logged a warning with exception info\n+                        assert len(caplog.records) == 1\n+                        assert caplog.records[0].levelname == \"WARNING\"\n+                        assert \"bad-profile\" in caplog.records[0].message\n+                        assert \"Failed to resolve profile directory\" in caplog.records[0].message\n+    \n+    def test_exception_with_no_profile_name(self, mock_runner, discord_source, caplog):\n+        \"\"\"Exception when no profile was set should still log a warning.\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=None):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\", side_effect=RuntimeError(\"Filesystem error\")):\n+                with patch(\"hermes_constants.get_hermes_home\", return_value=Path(\"/hermes\")):\n+                    mock_runner._profile_name_for_source = MagicMock(return_value=None)\n+                    \n+                    with caplog.at_level(logging.WARNING):\n+                        result = mock_runner._resolve_profile_home_for_source(discord_source)\n+                        \n+                        assert result == Path(\"/hermes\")\n+                        \n+                        # Warning should mention \"(no profile)\"\n+                        assert \"(no profile)\" in caplog.records[0].message\n+\n+\n+class TestRoutingConsultation:\n+    \"\"\"Tests that _profile_name_for_source is consulted when source.profile is empty.\"\"\"\n+    \n+    def test_routing_consulted_when_source_profile_empty(self, mock_runner, discord_source):\n+        \"\"\"_profile_name_for_source should be called when source.profile is empty.\"\"\"\n+        discord_source.profile = None\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/routed\")\n+                \n+                mock_runner._profile_name_for_source = MagicMock(return_value=\"routed\")\n+                \n+                mock_runner._resolve_profile_home_for_source(discord_source)\n+                \n+                # Should have called routing\n+                mock_runner._profile_name_for_source.assert_called_once_with(discord_source)\n+    \n+    def test_routing_not_consulted_when_source_profile_set(self, mock_runner, discord_source):\n+        \"\"\"_profile_name_for_source should NOT be called when source.profile is set.\"\"\"\n+        discord_source.profile = \"from-source\"\n+        \n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"active\"):\n+            with patch(\"hermes_cli.profiles.get_profile_dir\") as mock_get_dir:\n+                mock_get_dir.return_value = Path(\"/hermes/profiles/from-source\")\n+                \n+                mock_runner._profile_name_for_source = MagicMock(return_value=\"routed\")\n+                \n+                mock_runner._resolve_profile_home_for_source(discord_source)\n+                \n+                # Should NOT have called routing\n+                mock_runner._profile_name_for_source.assert_not_called()\n+\n+\n+class TestNonDiscordProfileRouting:\n+    \"\"\"Profile routing must be platform-generic, not Discord-only.\n+\n+    Regression coverage for the ``gateway_runner`` injection gap: previously\n+    only Discord's adapter pre-declared ``gateway_runner``, so only Discord\n+    ever had ``build_source`` call ``_profile_name_for_source``. Telegram /\n+    Feishu / Slack / etc. silently fell through to the default profile. These\n+    tests pin the resolution half for a non-Discord platform (Telegram).\n+    \"\"\"\n+\n+    def test_telegram_route_resolves(self, mock_runner, telegram_source):\n+        \"\"\"A configured Telegram route resolves to its profile via the real\n+        ``_profile_name_for_source`` (bound onto the mock runner).\"\"\"\n+        mock_runner.config.profile_routes = [\n+            ProfileRoute(name=\"tg\", platform=\"telegram\", profile=\"tg-profile\",\n+                         chat_id=\"-1001234567890\"),\n+        ]\n+        telegram_source.profile = None\n+\n+        assert mock_runner._profile_name_for_source(telegram_source) == \"tg-profile\"\n+\n+    def test_telegram_no_route_returns_none(self, mock_runner, telegram_source):\n+        \"\"\"With no matching Telegram route, resolution returns None (caller\n+        falls back to the default/active profile).\"\"\"\n+        mock_runner.config.profile_routes = [\n+            ProfileRoute(name=\"dc\", platform=\"discord\", profile=\"dc-profile\",\n+                         chat_id=\"123456\"),\n+        ]\n+        telegram_source.profile = None\n+\n+        assert mock_runner._profile_name_for_source(telegram_source) is None\n+\n+\n+class TestGatewayRunnerInjection:\n+    \"\"\"``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's\n+    unconditional injection reaches every platform adapter — the foundation\n+    that makes the routing in TestNonDiscordProfileRouting reachable at runtime.\n+    \"\"\"\n+\n+    def test_base_adapter_declares_gateway_runner(self):\n+        from gateway.platforms.base import BasePlatformAdapter\n+\n+        # Class-level attribute exists and defaults to None.\n+        assert hasattr(BasePlatformAdapter, \"gateway_runner\")\n+        assert BasePlatformAdapter.gateway_runner is None\n+\n+    def test_subclass_inherits_gateway_runner(self):\n+        from gateway.platforms.base import BasePlatformAdapter\n+\n+        class _ToyAdapter(BasePlatformAdapter):\n+            pass\n+\n+        # No manual declaration — yet the attribute is inherited from the base,\n+        # so the gateway's ``adapter.gateway_runner = self`` injection reaches\n+        # every adapter, not just the ones that pre-declared it (Discord).\n+        assert hasattr(_ToyAdapter, \"gateway_runner\")\n+        assert _ToyAdapter.gateway_runner is None\n+\n+\n+# A concrete adapter we can instantiate without the full platform stack.\n+# ``build_source`` only reads ``self.platform`` and ``self.gateway_runner``, so a\n+# bare instance with those two attrs exercises the real BasePlatformAdapter\n+# method end-to-end. Clearing ``__abstractmethods__`` lets ``__new__`` bypass\n+# the ABC instantiation guard without stubbing connect/send/get_chat_info/…\n+class _StubAdapter(BasePlatformAdapter):\n+    pass\n+\n+\n+_StubAdapter.__abstractmethods__ = frozenset()  # type: ignore[attr-defined]\n+\n+\n+def _stub_adapter(platform: Platform, runner) -> \"_StubAdapter\":\n+    a = _StubAdapter.__new__(_StubAdapter)\n+    a.platform = platform\n+    a.gateway_runner = runner\n+    return a\n+\n+\n+class TestAdapterToSessionKeyIntegration:\n+    \"\"\"Adapter -> ``source.profile`` -> session-key integration coverage.\n+\n+    The review asked for integration coverage for Discord AND a non-Discord\n+    platform. These drive a concrete adapter's real ``build_source``\n+    (BasePlatformAdapter) with an injected ``gateway_runner``, assert the\n+    matched route's profile is stamped on the source, and that the resulting\n+    session key is profile-scoped (``agent:<profile>:...`` rather than the\n+    shared ``agent:main:...``). The Telegram case is the bug-#2 regression:\n+    pre-fix it never received ``gateway_runner`` and fell through to default.\n+    \"\"\"\n+\n+    @staticmethod\n+    def _routes():\n+        return [\n+            ProfileRoute(name=\"dc\", platform=\"discord\", profile=\"coder\",\n+                         guild_id=\"111\", chat_id=\"222\"),\n+            ProfileRoute(name=\"tg\", platform=\"telegram\", profile=\"ops\",\n+                         chat_id=\"-1001234567890\"),\n+        ]\n+\n+    def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner):\n+        mock_runner.config.profile_routes = self._routes()\n+        adapter = _stub_adapter(Platform.DISCORD, mock_runner)\n+\n+        source = adapter.build_source(\n+            chat_id=\"222\", chat_type=\"group\", guild_id=\"111\", user_id=\"u1\",\n+        )\n+        assert source.profile == \"coder\"\n+\n+        key = build_session_key(source, profile=source.profile)\n+        assert key.startswith(\"agent:coder:\"), key\n+        # A default-profile key would land in agent:main — must differ.\n+        assert key != build_session_key(source, profile=None)\n+\n+    def test_telegram_adapter_stamps_profile_and_scopes_key(self, mock_runner):\n+        \"\"\"Non-Discord platform (bug #2). The adapter now receives\n+        ``gateway_runner``, so ``build_source`` stamps the profile and the\n+        session key is isolated under ``agent:ops:`` instead of ``agent:main:``.\"\"\"\n+        mock_runner.config.profile_routes = self._routes()\n+        adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)\n+\n+        source = adapter.build_source(\n+            chat_id=\"-1001234567890\", chat_type=\"group\", user_id=\"u1\",\n+        )\n+        assert source.profile == \"ops\"\n+\n+        key = build_session_key(source, profile=source.profile)\n+        assert key.startswith(\"agent:ops:\"), key\n+        assert key != build_session_key(source, profile=None)\n+\n+    def test_adapter_without_runner_falls_back_to_default_namespace(self, mock_runner):\n+        \"\"\"Regression anchor: with no ``gateway_runner`` injected (the pre-fix\n+        state for non-Discord adapters), ``build_source`` leaves ``profile=None``\n+        and the session key is the shared ``agent:main:`` namespace — no\n+        per-profile isolation. This is the silent fallback the fix removes for\n+        non-Discord platforms.\"\"\"\n+        adapter = _stub_adapter(Platform.TELEGRAM, runner=None)\n+\n+        source = adapter.build_source(\n+            chat_id=\"-1001234567890\", chat_type=\"group\", user_id=\"u1\",\n+        )\n+        assert source.profile is None\n+        key = build_session_key(source, profile=source.profile)\n+        assert key.startswith(\"agent:main:\"), key\n+\n+\n+class TestMultiplexGate:\n+    \"\"\"``profile_routes`` only activates under ``gateway.multiplex_profiles``.\n+\n+    Routing stamps ``source.profile``, which namespaces session/batch keys —\n+    but the profile-scoped agent run (``_profile_runtime_scope``) only engages\n+    when multiplexing is on. Without the gate, a configured route with\n+    multiplexing off would split batch/session keys into ``agent:<profile>``\n+    while the agent still served the turn from ``agent:main``'s home.\n+    \"\"\"\n+\n+    def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):\n+        mock_runner.config.multiplex_profiles = False\n+        mock_runner.config.profile_routes = [\n+            ProfileRoute(name=\"dc\", platform=\"discord\", profile=\"coder\",\n+                         guild_id=\"789\", chat_id=\"123456\"),\n+        ]\n+        discord_source.profile = None\n+\n+        assert mock_runner._profile_name_for_source(discord_source) is None\n+\n+    def test_routes_active_when_multiplex_on(self, mock_runner, discord_source):\n+        mock_runner.config.multiplex_profiles = True\n+        mock_runner.config.profile_routes = [\n+            ProfileRoute(name=\"dc\", platform=\"discord\", profile=\"coder\",\n+                         guild_id=\"789\", chat_id=\"123456\"),\n+        ]\n+        discord_source.profile = None\n+\n+        assert mock_runner._profile_name_for_source(discord_source) == \"coder\"\n+\n+    def test_build_source_leaves_profile_none_when_multiplex_off(self, mock_runner):\n+        \"\"\"End-to-end through the real adapter ``build_source``: with routes\n+        configured but multiplexing off, no profile is stamped and the session\n+        key stays in the legacy ``agent:main`` namespace — byte-identical to a\n+        gateway with no routes at all.\"\"\"\n+        mock_runner.config.multiplex_profiles = False\n+        mock_runner.config.profile_routes = [\n+            ProfileRoute(name=\"dc\", platform=\"discord\", profile=\"coder\",\n+                         guild_id=\"111\", chat_id=\"222\"),\n+        ]\n+        adapter = _stub_adapter(Platform.DISCORD, mock_runner)\n+\n+        source = adapter.build_source(\n+            chat_id=\"222\", chat_type=\"group\", guild_id=\"111\", user_id=\"u1\",\n+        )\n+        assert source.profile is None\n+        key = build_session_key(source, profile=source.profile)\n+        assert key.startswith(\"agent:main:\"), key"]},{"file":"tests/gateway/test_profile_routing.py","hunks":["@@ -0,0 +1,261 @@\n+\"\"\"Tests for gateway/profile_routing.py — profile-based routing.\"\"\"\n+\n+import pytest\n+from gateway.profile_routing import (\n+    ProfileRoute,\n+    parse_profile_routes,\n+    match_profile_route,\n+)\n+\n+\n+class TestProfileRoute:\n+    def test_specificity_thread(self):\n+        r = ProfileRoute(name=\"t\", platform=\"discord\", profile=\"p\",\n+                         guild_id=\"g\", chat_id=\"c\", thread_id=\"t\")\n+        assert r.specificity == 14  # 2 + 4 + 8\n+\n+    def test_specificity_channel(self):\n+        r = ProfileRoute(name=\"c\", platform=\"discord\", profile=\"p\",\n+                         guild_id=\"g\", chat_id=\"c\")\n+        assert r.specificity == 6  # 2 + 4\n+\n+    def test_specificity_guild(self):\n+        r = ProfileRoute(name=\"g\", platform=\"discord\", profile=\"p\",\n+                         guild_id=\"g\")\n+        assert r.specificity == 2\n+\n+    def test_specificity_minimal(self):\n+        r = ProfileRoute(name=\"m\", platform=\"telegram\", profile=\"p\")\n+        assert r.specificity == 0\n+\n+    def test_frozen(self):\n+        r = ProfileRoute(name=\"x\", platform=\"discord\", profile=\"p\")\n+        with pytest.raises(AttributeError):\n+            r.name = \"y\"\n+\n+\n+class TestProfileRouteMatching:\n+    def test_exact_thread_match(self):\n+        r = ProfileRoute(name=\"t\", platform=\"discord\", profile=\"trader\",\n+                         guild_id=\"111\", chat_id=\"222\", thread_id=\"333\")\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"222\", thread_id=\"333\")\n+        assert not r.matches(\"discord\", guild_id=\"111\", chat_id=\"222\", thread_id=\"444\")\n+\n+    def test_channel_match(self):\n+        r = ProfileRoute(name=\"c\", platform=\"discord\", profile=\"helper\",\n+                         chat_id=\"222\")\n+        assert r.matches(\"discord\", chat_id=\"222\")\n+        assert not r.matches(\"discord\", chat_id=\"333\")\n+        assert not r.matches(\"telegram\", chat_id=\"222\")\n+\n+    def test_guild_match(self):\n+        r = ProfileRoute(name=\"g\", platform=\"discord\", profile=\"server\",\n+                         guild_id=\"111\")\n+        assert r.matches(\"discord\", guild_id=\"111\")\n+        assert not r.matches(\"discord\", guild_id=\"222\")\n+\n+    def test_disabled_route_no_match(self):\n+        r = ProfileRoute(name=\"d\", platform=\"discord\", profile=\"off\",\n+                         guild_id=\"111\", enabled=False)\n+        assert not r.matches(\"discord\", guild_id=\"111\")\n+\n+    def test_guild_route_matches_any_channel_in_guild(self):\n+        r = ProfileRoute(name=\"g\", platform=\"discord\", profile=\"server\",\n+                         guild_id=\"111\")\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"222\")\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"222\", thread_id=\"333\")\n+\n+    def test_extra_fields_ignored(self):\n+        r = ProfileRoute(name=\"g\", platform=\"discord\", profile=\"server\",\n+                         guild_id=\"111\")\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"any\")\n+\n+    def test_guild_and_chat_are_conjunctive(self):\n+        # A route declaring BOTH guild_id and chat_id requires both to match.\n+        # Regression guard: previously chat_id was checked first and returned\n+        # True before guild_id was ever consulted.\n+        r = ProfileRoute(name=\"gc\", platform=\"discord\", profile=\"scoped\",\n+                         guild_id=\"111\", chat_id=\"222\")\n+        # Both match (direct channel) -> match\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"222\")\n+        # Both match via parent (thread inside the channel) -> match\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"333\", parent_chat_id=\"222\")\n+        # chat matches but guild differs -> NO match (the bug this guards)\n+        assert not r.matches(\"discord\", guild_id=\"999\", chat_id=\"222\")\n+        # guild matches but chat differs -> NO match\n+        assert not r.matches(\"discord\", guild_id=\"111\", chat_id=\"333\")\n+\n+\n+class TestParseProfileRoutes:\n+    def test_empty(self):\n+        assert parse_profile_routes(None) == []\n+        assert parse_profile_routes([]) == []\n+\n+    def test_valid_routes_sorted_by_specificity(self):\n+        raw = [\n+            {\"name\": \"guild\", \"platform\": \"discord\", \"profile\": \"p\", \"guild_id\": \"1\"},\n+            {\"name\": \"thread\", \"platform\": \"discord\", \"profile\": \"p\",\n+             \"guild_id\": \"1\", \"chat_id\": \"2\", \"thread_id\": \"3\"},\n+            {\"name\": \"channel\", \"platform\": \"discord\", \"profile\": \"p\", \"chat_id\": \"2\"},\n+        ]\n+        routes = parse_profile_routes(raw)\n+        names = [r.name for r in routes]\n+        assert names == [\"thread\", \"channel\", \"guild\"]\n+\n+    def test_skips_invalid(self):\n+        raw = [\n+            {\"platform\": \"discord\"},\n+            {\"profile\": \"p\"},\n+            \"not a dict\",\n+            {\"name\": \"ok\", \"platform\": \"telegram\", \"profile\": \"p\"},\n+        ]\n+        routes = parse_profile_routes(raw)\n+        assert len(routes) == 1\n+        assert routes[0].name == \"ok\"\n+\n+    def test_enabled_flag(self):\n+        raw = [\n+            {\"name\": \"off\", \"platform\": \"discord\", \"profile\": \"p\",\n+             \"guild_id\": \"1\", \"enabled\": False},\n+            {\"name\": \"on\", \"platform\": \"discord\", \"profile\": \"p\", \"guild_id\": \"1\"},\n+        ]\n+        routes = parse_profile_routes(raw)\n+        assert not routes[0].enabled\n+        assert routes[1].enabled\n+\n+\n+class TestMatchProfileRoute:\n+    def test_no_routes(self):\n+        assert match_profile_route([], \"discord\") is None\n+\n+    def test_returns_first_match(self):\n+        routes = [\n+            ProfileRoute(name=\"thread\", platform=\"discord\", profile=\"trader\",\n+                         guild_id=\"1\", chat_id=\"2\", thread_id=\"3\"),\n+            ProfileRoute(name=\"channel\", platform=\"discord\", profile=\"helper\",\n+                         chat_id=\"2\"),\n+        ]\n+        m = match_profile_route(routes, \"discord\", guild_id=\"1\", chat_id=\"2\", thread_id=\"3\")\n+        assert m is not None\n+        assert m.profile == \"trader\"\n+\n+    def test_falls_through_to_channel(self):\n+        routes = [\n+            ProfileRoute(name=\"thread\", platform=\"discord\", profile=\"trader\",\n+                         guild_id=\"1\", chat_id=\"2\", thread_id=\"3\"),\n+            ProfileRoute(name=\"channel\", platform=\"discord\", profile=\"helper\",\n+                         chat_id=\"2\"),\n+        ]\n+        m = match_profile_route(routes, \"discord\", guild_id=\"1\", chat_id=\"2\")\n+        assert m is not None\n+        assert m.profile == \"helper\"\n+\n+    def test_no_match_returns_none(self):\n+        routes = [\n+            ProfileRoute(name=\"r\", platform=\"telegram\", profile=\"p\"),\n+        ]\n+        assert match_profile_route(routes, \"discord\") is None\n+\n+\n+class TestSessionKeyIntegration:\n+    def test_default_profile_key(self):\n+        from gateway.session import build_session_key, SessionSource, Platform\n+        src = SessionSource(platform=Platform.DISCORD, chat_id=\"123\",\n+                            chat_type=\"channel\", user_id=\"456\")\n+        key = build_session_key(src)\n+        assert key.startswith(\"agent:main:\")\n+\n+    def test_custom_profile_key(self):\n+        from gateway.session import build_session_key, SessionSource, Platform\n+        src = SessionSource(platform=Platform.DISCORD, chat_id=\"123\",\n+                            chat_type=\"channel\", user_id=\"456\")\n+        key = build_session_key(src, profile=\"trader\")\n+        assert key.startswith(\"agent:trader:\")\n+        assert key == \"agent:trader:discord:channel:123:456\"\n+\n+    def test_isolated_sessions(self):\n+        from gateway.session import build_session_key, SessionSource, Platform\n+        src = SessionSource(platform=Platform.DISCORD, chat_id=\"123\",\n+                            chat_type=\"channel\", user_id=\"456\")\n+        key_default = build_session_key(src)\n+        key_trader = build_session_key(src, profile=\"trader\")\n+        assert key_default != key_trader\n+\n+    def test_dm_profile_scoped(self):\n+        from gateway.session import build_session_key, SessionSource, Platform\n+        src = SessionSource(platform=Platform.DISCORD, chat_id=\"999\",\n+                            chat_type=\"dm\", user_id=\"111\")\n+        key = build_session_key(src, profile=\"bot2\")\n+        assert key == \"agent:bot2:discord:dm:999\"\n+\n+\n+\n+class TestParentChatIdMatching:\n+    \"\"\"Thread messages carry thread_id as chat_id; parent_chat_id is the channel.\"\"\"\n+\n+    def test_channel_route_matches_via_parent_chat_id(self):\n+        r = ProfileRoute(name=\"ch\", platform=\"discord\", profile=\"trader\",\n+                         chat_id=\"222\")\n+        assert r.matches(\"discord\", chat_id=\"333\", parent_chat_id=\"222\")\n+\n+    def test_channel_route_no_match_wrong_parent(self):\n+        r = ProfileRoute(name=\"ch\", platform=\"discord\", profile=\"trader\",\n+                         chat_id=\"222\")\n+        assert not r.matches(\"discord\", chat_id=\"333\", parent_chat_id=\"444\")\n+\n+    def test_match_profile_route_with_parent_chat_id(self):\n+        routes = [\n+            ProfileRoute(name=\"ch\", platform=\"discord\", profile=\"trader\",\n+                         chat_id=\"222\"),\n+        ]\n+        m = match_profile_route(routes, \"discord\", chat_id=\"333\", parent_chat_id=\"222\")\n+        assert m is not None\n+        assert m.profile == \"trader\"\n+\n+    def test_thread_id_does_not_match_parent_chat_id(self):\n+        \"\"\"thread_id only matches the actual thread_id, never parent_chat_id.\n+        Discord snowflakes are globally unique, so thread_id != channel_id.\"\"\"\n+        r = ProfileRoute(name=\"th\", platform=\"discord\", profile=\"helper\",\n+                         thread_id=\"555\")\n+        assert r.matches(\"discord\", thread_id=\"555\")\n+        assert not r.matches(\"discord\", parent_chat_id=\"555\")\n+\n+    def test_no_parent_chat_id_still_works(self):\n+        r = ProfileRoute(name=\"ch\", platform=\"discord\", profile=\"trader\",\n+                         chat_id=\"222\")\n+        assert r.matches(\"discord\", chat_id=\"222\")\n+\n+    def test_guild_route_matches_with_parent_chat_id(self):\n+        \"\"\"Guild routes should match regardless of chat_id or parent_chat_id.\"\"\"\n+        r = ProfileRoute(name=\"g\", platform=\"discord\", profile=\"server\",\n+                         guild_id=\"111\")\n+        assert r.matches(\"discord\", guild_id=\"111\", chat_id=\"333\", parent_chat_id=\"444\")\n+\n+\n+class TestForumPostMatching:\n+    \"\"\"Test that forum posts match via parent_chat_id (direct parent).\"\"\"\n+\n+    def test_forum_channel_route_matches_forum_post(self):\n+        \"\"\"A route on a forum channel should match comments on posts in that forum.\n+        \n+        In Discord, forum posts (threads) have parent_chat_id = forum channel ID.\n+        No cache is needed — the parent relationship is direct.\n+        \"\"\"\n+        r = ProfileRoute(name=\"forum\", platform=\"discord\", profile=\"forum_profile\",\n+                         chat_id=\"forum_channel_123\")\n+        # A comment on a forum post: chat_id=post_thread_id, parent_chat_id=forum_channel_id\n+        assert r.matches(\"discord\", chat_id=\"post_thread_456\", parent_chat_id=\"forum_channel_123\")\n+\n+    def test_forum_post_comment_matches_channel_not_thread_id(self):\n+        \"\"\"Verify that thread_id matching is distinct from parent_chat_id matching.\"\"\"\n+        routes = [\n+            ProfileRoute(name=\"forum\", platform=\"discord\", profile=\"forum_profile\",\n+                         chat_id=\"forum_channel_123\"),\n+            ProfileRoute(name=\"post\", platform=\"discord\", profile=\"post_profile\",\n+                         thread_id=\"post_thread_456\"),\n+        ]\n+        # A comment on the forum post should match the forum channel route, not the thread route\n+        m = match_profile_route(routes, \"discord\", chat_id=\"post_thread_456\", \n+                                 parent_chat_id=\"forum_channel_123\")\n+        assert m is not None\n+        assert m.profile == \"forum_profile\""]},{"file":"website/docs/user-guide/multi-profile-gateways.md","hunks":["@@ -189,6 +189,50 @@ Kanban workers only ever see their own profile's secrets). Kanban,\n profile-scoped skills/memory/SOUL, and model routing all behave per-profile\n exactly as they do with separate gateways.\n \n+### Routing shared-bot chats to profiles (`profile_routes`)\n+\n+Multiplexing selects a profile per **credential** (each profile's own bot\n+token) or per **URL prefix** (`/p/<profile>/` for HTTP platforms). When several\n+communities share **one** bot token — for example one Discord bot serving many\n+guilds — you can additionally route specific guilds/channels/threads to\n+different profiles with `gateway.profile_routes`:\n+\n+```yaml\n+gateway:\n+  multiplex_profiles: true\n+  profile_routes:\n+    # An entire Discord server → one profile\n+    - name: acme-server\n+      platform: discord\n+      guild_id: \"1234567890\"\n+      profile: acme\n+\n+    # One channel in that server → a different profile\n+    - name: acme-support\n+      platform: discord\n+      guild_id: \"1234567890\"\n+      chat_id: \"9876543210\"\n+      profile: acme-support\n+\n+    # A Telegram group (no guild concept — chat_id only)\n+    - name: tg-group\n+      platform: telegram\n+      chat_id: \"-1001234567890\"\n+      profile: tg-profile\n+```\n+\n+Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`),\n+all declared fields must hold (AND), and a route keyed on a channel also\n+matches threads/forum posts whose parent is that channel. Messages that match\n+no route stay on the default/active profile. The routed profile gets the full\n+per-profile isolation described above (config, skills, memory, credentials,\n+session namespace). Routing works on every platform adapter, not just Discord.\n+\n+`profile_routes` requires `gateway.multiplex_profiles: true`; with\n+multiplexing off the routes are ignored. If a route names a profile that does\n+not exist on disk, the gateway logs a warning naming the profile and source and\n+falls back to the default home.\n+\n ## Start, stop, or restart all gateways at once\n \n The CLI ships with single-profile lifecycle commands. To act across every\n"]}]},{"kind":"pr","number":66519,"title":"feat(gateway): /profile <name> switches active profile in messaging platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61205","number":61205},{"kind":"pr","value":"#24914","number":24914}],"category":"stale_abandoned","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}