# NousResearch/hermes-agent · Gateway profile-based message routing feature

## Open now

- 6 issues, 2 PRs open on GitHub.
- #4321: \[Feature\] Map Telegram forum topics to different agent profiles under a single bot token
- #9514: Feature: Single-Daemon Multi-Agent with Per-Topic Workspace & Memory Isolation
- #24913: \[Feature\]: one gateway serves multiple agents — switch via \`/profile <name>\` or \`@<name>\`
- #52860: \[Feature\]: Gateway slash-command profile routing via a default attendant profile
- #65018: feat: Gateway-Level Profile Router (Hub-and-Spoke) for deterministic message routing
- #65936: feat(gateway): /profile <name> should switch profiles in Telegram (and other messaging platforms)
- PR #5489: feat(telegram): per-topic skill binding and auto-discovery for group forums
- PR #53048: feat(gateway): per-topic profile isolation for Telegram forum topics

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.

## TL;DR

- 29 member(s): 6 open issue(s), 12 closed issue(s), 2 open PR(s), 1 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #64835. Verify it addresses your case.
- **Recommended:** read PR #64835 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.08`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #4321: \[Feature\] Map Telegram forum topics to different agent profiles under a single bot token
- #9514: Feature: Single-Daemon Multi-Agent with Per-Topic Workspace & Memory Isolation
- #24913: \[Feature\]: one gateway serves multiple agents — switch via \`/profile <name>\` or \`@<name>\`
- #52860: \[Feature\]: Gateway slash-command profile routing via a default attendant profile
- #65018: feat: Gateway-Level Profile Router (Hub-and-Spoke) for deterministic message routing
- #65936: feat(gateway): /profile <name> should switch profiles in Telegram (and other messaging platforms)

## Closed / duplicate issues

- #4622: Feature: Per-Topic Role/Skill Binding for Telegram Group Forum Topics
- #7517: Feature Request: Native Multi-Agent Support
- #10030: Profiles support multiple user gateway : telegram weixin etc？？？
- #10143: \[Feature\] Topic-to-Profile routing: dispatch messages to different profiles based on Telegram topic/thread
- #18423: \[Feature\]: Profile routing for Discord
- #19809: \[Feature\]: Per-channel profile routing for Discord (single bot, single gateway)
- #23735: Design discussion: multi-profile deployments in a single gateway process
- #29535: Gateway: route chat/thread messages to Hermes profiles in a single gateway process
- #34819: Feature: Telegram forum topic identity routing — each topic acts as a different agent
- #40173: feat(telegram): channel_profiles — route Telegram chats to Hermes profiles in one gateway
- #57871: Support shared-token chat/topic routes for multiplexed gateway profiles
- #57872: Support routing one bot token across multiple profile homes

## Prior & competing PRs

- **#4625: feat(telegram): per-topic skill binding and auto-discovery for group forums**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#64835: feat(gateway): profile-based routing for inbound messages (salvage #20096)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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` (+1 more)
  - fix hunks:
  - `docs/profile-routing.md`

    ```diff
    @@ -0,0 +1,117 @@
    +# 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/<name>`). |
    +| `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) | 0 |
    +
    +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.
    +
    +## Relationship to multiplexing
    +
    +`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what
    +activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and
    +profile-namespaced session keys); routing is the decision layer that picks *which*
    +profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes`
    +is ignored entirely — behavior is byte-identical to a single-profile gateway.
    ```
  - `gateway/config.py`

    ```diff
    @@ -12,7 +12,7 @@
     import os
     import json
     from pathlib import Path
    -from dataclasses import dataclass, field
    +from dataclasses import asdict, dataclass, field, is_dataclass
     from typing import Dict, List, Optional, Any, Callable
     from enum import Enum
     
    ```

    ```diff
    @@ -721,6 +721,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 = []
    ```

    ```diff
    @@ -827,6 +832,10 @@ 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": [
    +                asdict(r) if is_dataclass(r) and not isinstance(r, type) else r
    +                for r in self.profile_routes
    +            ],
             }
         
         @classmethod
    ```

    ```diff
    @@ -919,6 +928,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,
    ```

    ```diff
    @@ -941,6 +954,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:
    ```

    ```diff
    @@ -1053,6 +1067,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):
                     if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:
    ```
  - `gateway/platforms/base.py`

    ```diff
    @@ -2353,6 +2353,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
    @@ -5507,10 +5517,47 @@ def build_source(
             auto_thread_created: bool = False,
             auto_thread_initial_name: Optional[str] = None,
         ) -> 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),
    ```

    ```diff
    @@ -5527,6 +5574,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,
                 auto_thread_created=auto_thread_created,
                 auto_thread_initial_name=auto_thread_initial_name,
    ```
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -1515,6 +1515,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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:
    ```
  - `gateway/profile_routing.py`

    ```diff
    @@ -0,0 +1,166 @@
    +"""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 14
    +  2. platform + chat_id (channel route)             — specificity 6
    +  3. platform + guild_id (guild/server route)       — specificity 2
    +  4. No match                                       → default profile
    +
    +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):
    +
    +    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 dataclasses import dataclass
    +from typing import Any, Dict, List, Optional
    +
    +import logging
    +
    +logger = logging.getLogger(__name__)
    +
    +
    +@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.
    +
    +        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
    +        if self.platform != platform:
    +            return False
    +        if self.thread_id and self.thread_id != thread_id:
    +            return False
    +        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
    +
    +
    +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. Lazy import avoids a
    +        # circular dependency at module load time.
    +        try:
    +            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
    +        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
    ```
  - `gateway/run.py`

    ```diff
    @@ -8759,6 +8759,20 @@ def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]:
                 if isinstance(val, str) and val.strip():
                     token = [redacted:secret-assignment]
                     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 = [redacted:secret-assignment]
    +                        break
             if not token:
                 config = getattr(adapter, "config", None)
                 val = getattr(config, "token", None)
    ```

    ```diff
    @@ -8795,12 +8809,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
    @@ -17246,19 +17262,108 @@ 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 multiplexing is off, 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.
    +
    +        Gated on ``gateway.multiplex_profiles``: routing stamps
    +        ``source.profile``, which selects the session-key namespace and batch
    +        keys — but the profile-scoped agent run only activates under
    +        multiplexing. Without this gate, a configured route with multiplexing
    +        off would namespace batch/session keys by profile while the agent
    +        still runs in ``agent:main``, splitting the two out of agreement.
    +        """
    +        config = getattr(self, "config", None)
    +        if not getattr(config, "multiplex_profiles", False):
    +            return 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.debug(
    +            "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/<profile>/ 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/<profile>/ 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
    +        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() or get_active_profile_name() or "default"
    -            return get_profile_dir(name)
    +            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"
    +            
    +            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(
    ```
  - `gateway/session.py`

    ```diff
    @@ -1993,6 +1993,7 @@ def _get_or_create_session_impl(
                         "chat_id": source.chat_id,
                         "chat_type": source.chat_type,
                         "thread_id": source.thread_id,
    +                    "profile_name": source.profile,
                     }
     
             if _needs_save:
    ```

    ```diff
    @@ -2273,6 +2274,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->
                     "chat_id": old_entry.origin.chat_id if old_entry.origin else None,
                     "chat_type": old_entry.origin.chat_type if old_entry.origin else None,
                     "thread_id": old_entry.origin.thread_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:
    ```
  - `hermes_state.py`

    ```diff
    @@ -791,6 +791,7 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
         compression_failure_cooldown_until REAL,
         compression_failure_error TEXT,
         compression_fallback_streak INTEGER NOT NULL DEFAULT 0,
    +    profile_name TEXT,
         rewind_count INTEGER NOT NULL DEFAULT 0,
         archived INTEGER NOT NULL DEFAULT 0,
         FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
    ```

    ```diff
    @@ -1760,6 +1761,7 @@ def _insert_session_row(
             thread_id: str = None,
             parent_session_id: str = None,
             cwd: str = None,
    +        profile_name: str = None,
         ) -> None:
             """Insert a session row, enriching NULL metadata on conflict.
     
    ```

    ```diff
    @@ -1783,9 +1785,9 @@ def _do(conn):
                 conn.execute(
                     """INSERT INTO sessions (
                        id, source, user_id, session_key, chat_id, chat_type, thread_id,
    -                   model, model_config, system_prompt, parent_session_id, cwd, started_at
    +                   model, model_config, system_prompt, parent_session_id, cwd, profile_name, started_at
                     )
    -                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    +                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                        ON CONFLICT(id) DO UPDATE SET
                            model = COALESCE(sessions.model, excluded.model),
                            model_config = COALESCE(sessions.model_config, excluded.model_config),
    ```

    ```diff
    @@ -1795,7 +1797,8 @@ def _do(conn):
                            chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
                            thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
                            parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
    -                       cwd = COALESCE(sessions.cwd, excluded.cwd)""",
    +                       cwd = COALESCE(sessions.cwd, excluded.cwd),
    +                       profile_name = COALESCE(sessions.profile_name, excluded.profile_name)""",
                     (
                         session_id,
                         source,
    ```

    ```diff
    @@ -1809,6 +1812,7 @@ def _do(conn):
                         system_prompt,
                         parent_session_id,
                         cwd,
    +                    profile_name,
                         time.time(),
                     ),
                 )
    ```
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -6612,12 +6612,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:
    ```
  - `plugins/platforms/feishu/adapter.py`

    ```diff
    @@ -3633,6 +3633,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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,
             )
     
         @staticmethod
    ```
  - `plugins/platforms/matrix/adapter.py`

    ```diff
    @@ -3455,6 +3455,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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:
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -7925,6 +7925,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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:
    ```
  - `plugins/platforms/wecom/adapter.py`

    ```diff
    @@ -578,6 +578,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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:
    ```
  - `plugins/platforms/whatsapp/adapter.py`

    ```diff
    @@ -1278,6 +1278,7 @@ def _text_batch_key(self, event: MessageEvent) -> str:
                 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:
    ```
  - `run_agent.py`

    ```diff
    @@ -599,6 +599,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,
    ```

    ```diff
    @@ -608,6 +615,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:
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "Burgunthy",  # PR #20096 salvage (gateway: profile-based routing for inbound messages)
         "[redacted:email]": "webtecnica",  # PR #63360 salvage (nous: restore inference-api base_url)
         "[redacted:email]": "minchang",  # PR #42231 salvage (providers: add Upstage Solar)
         "[redacted:email]": "neo-claw-bot",  # PR #58465 salvage (moa: drop empty user turns from advisory view)
    ```
  - `tests/gateway/test_config.py`

    ```diff
    @@ -1467,3 +1467,93 @@ def test_resolver_tristate(self, monkeypatch):
             for noise in ("", "   ", "maybe", "2"):
                 monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", noise)
                 assert _env_multiplex_profiles_override() is None, repr(noise)
    +
    +
    +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"
    +        )
    ```
  - `tests/gateway/test_multiplex_adapter_registry.py`

    ```diff
    @@ -45,6 +45,58 @@ class _Config:
             assert "config-token" not in fp
     
     
    +    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 = "[redacted:secret-assignment]"
    +        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 = "[redacted:secret-assignment]"
    +        class _Both:
    +            token = "[redacted:secret-assignment]"
    +            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):
    ```
  - `tests/gateway/test_profile_resolution.py`

    ```diff
    @@ -0,0 +1,471 @@
    +"""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, 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
    +
    +
    [redacted:email]
    +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
    +
    +
    [redacted:email]
    +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,
    +    )
    +
    +
    [redacted:email]
    +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."""
    +    
    +    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()
    +
    +
    +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
    +
    +
    +# 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:<profile>:...`` 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
    +
    +
    +class TestMultiplexGate:
    +    """``profile_routes`` only activates under ``gateway.multiplex_profiles``.
    +
    +    Routing stamps ``source.profile``, which namespaces session/batch keys —
    +    but the profile-scoped agent run (``_profile_runtime_scope``) only engages
    +    when multiplexing is on. Without the gate, a configured route with
    +    multiplexing off would split batch/session keys into ``agent:<profile>``
    +    while the agent still served the turn from ``agent:main``'s home.
    +    """
    +
    +    def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):
    +        mock_runner.config.multiplex_profiles = False
    +        mock_runner.config.profile_routes = [
    +            ProfileRoute(name="dc", platform="discord", profile="coder",
    +                         guild_id="789", chat_id="123456"),
    +        ]
    +        discord_source.profile = None
    +
    +        assert mock_runner._profile_name_for_source(discord_source) is None
    +
    +    def test_routes_active_when_multiplex_on(self, mock_runner, discord_source):
    +        mock_runner.config.multiplex_profiles = True
    +        mock_runner.config.profile_routes = [
    +            ProfileRoute(name="dc", platform="discord", profile="coder",
    +                         guild_id="789", chat_id="123456"),
    +        ]
    +        discord_source.profile = None
    +
    +        assert mock_runner._profile_name_for_source(discord_source) == "coder"
    +
    +    def test_build_source_leaves_profile_none_when_multiplex_off(self, mock_runner):
    +        """End-to-end through the real adapter ``build_source``: with routes
    +        configured but multiplexing off, no profile is stamped and the session
    +        key stays in the legacy ``agent:main`` namespace — byte-identical to a
    +        gateway with no routes at all."""
    +        mock_runner.config.multiplex_profiles = False
    +        mock_runner.config.profile_routes = [
    +            ProfileRoute(name="dc", platform="discord", profile="coder",
    +                         guild_id="111", chat_id="222"),
    +        ]
    +        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 is None
    +        key = build_session_key(source, profile=source.profile)
    +        assert key.startswith("agent:main:"), key
    ```
  - `tests/gateway/test_profile_routing.py`

    ```diff
    @@ -0,0 +1,261 @@
    +"""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")
    +
    +    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):
    +        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")
    +
    +
    +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"
    ```
  - `website/docs/user-guide/multi-profile-gateways.md`

    ```diff
    @@ -189,6 +189,50 @@ Kanban workers only ever see their own profile's secrets). Kanban,
     profile-scoped skills/memory/SOUL, and model routing all behave per-profile
     exactly as they do with separate gateways.
     
    +### Routing shared-bot chats to profiles (`profile_routes`)
    +
    +Multiplexing selects a profile per **credential** (each profile's own bot
    +token) or per **URL prefix** (`/p/<profile>/` for HTTP platforms). When several
    +communities share **one** bot token — for example one Discord bot serving many
    +guilds — you can additionally route specific guilds/channels/threads to
    +different profiles with `gateway.profile_routes`:
    +
    +```yaml
    +gateway:
    +  multiplex_profiles: true
    +  profile_routes:
    +    # An entire Discord server → one profile
    +    - name: acme-server
    +      platform: discord
    +      guild_id: "1234567890"
    +      profile: acme
    +
    +    # One channel in that server → a different profile
    +    - name: acme-support
    +      platform: discord
    +      guild_id: "1234567890"
    +      chat_id: "9876543210"
    +      profile: acme-support
    +
    +    # A Telegram group (no guild concept — chat_id only)
    +    - name: tg-group
    +      platform: telegram
    +      chat_id: "-1001234567890"
    +      profile: tg-profile
    +```
    +
    +Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`),
    +all declared fields must hold (AND), and a route keyed on a channel also
    +matches threads/forum posts whose parent is that channel. Messages that match
    +no route stay on the default/active profile. The routed profile gets the full
    +per-profile isolation described above (config, skills, memory, credentials,
    +session namespace). Routing works on every platform adapter, not just Discord.
    +
    +`profile_routes` requires `gateway.multiplex_profiles: true`; with
    +multiplexing off the routes are ignored. If a route names a profile that does
    +not exist on disk, the gateway logs a warning naming the profile and source and
    +falls back to the default home.
    +
     ## Start, stop, or restart all gateways at once
     
     The CLI ships with single-profile lifecycle commands. To act across every
    ```
- **#5489: feat(telegram): per-topic skill binding and auto-discovery for group forums**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `1cae9ac62`, `5505dbbf4`, `ef73babea`
  - files: `gateway/platforms/telegram.py`, `gateway/run.py`
- **#18510: feat(telegram): route forum topics to Hermes profiles**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`
- **#20018: feat: Profile-based channel routing for single-gateway multi-agent**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#24914: feat: One gateway for multiple agent profiles.**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `f538470cf4a`, `d5d02eabb03`, `#64835`, `#69441`
- **#36872: Route messages to per-profile workers behind one bot token**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#64835`
- **#47027: feat(orchestration): AgentContext, RoutingTable, ContextPool — CICS-model for single-daemon multi-agent**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#53048: feat(gateway): per-topic profile isolation for Telegram forum topics**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#64835`, `#20096`
  - files: `agent/auxiliary_client.py`, `agent/skill_commands.py`, `cron/jobs.py`, `gateway/run.py`, `gateway/session.py`, `gateway/session_context.py`, `gateway/slash_commands.py`, `hermes_cli/auth.py`, `hermes_cli/env_loader.py`, `hermes_cli/main.py`, `hermes_cli/profiles.py`, `hermes_cli/runtime_provider.py`, `hermes_cli/subcommands/profile.py`, `hermes_state.py`, `tests/agent/test_auxiliary_client.py`, `tests/cron/test_cron_profile_isolation.py`, `tests/gateway/test_model_command_flat_string_config.py`, `tests/gateway/test_model_picker_persist.py`, `tests/gateway/test_profile_isolation_rework_suite.py`, `tests/gateway/test_reasoning_command.py` (+18 more)
- **#57873: feat(gateway): route shared bot chats to profiles**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#64835`, `#20096`
- **#66519: feat(gateway): /profile <name> switches active profile in messaging platforms**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#61205`, `#24914`

## Fix status

- Merged to main in PR #64835 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-15`.
- Subsystem: `agent`
