Gateway profile-based message routing feature

NousResearch/hermes-agent · cluster 4321 · agent

Data as of

8 open items. Fix merged in #64835. Check it covers your case.

The short answer

6open issues
2open PRs
29items grouped
Known?
Yes. 29 related items grouped here, 12 issues already closed.
Fix event
A fix was merged to main in PR #64835. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
6 issues and 2 PRs.

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.

Open issues6

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

Open PRs2

  • PR#5489feat(telegram): per-topic skill binding and auto-discovery for group forums
  • PR#53048feat(gateway): per-topic profile isolation for Telegram forum topics

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64835 feat(gateway): profile-based routing for inbound messages (salvage #20096)

    merged to main

    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)

    Show the diff
    • docs/profile-routing.md
      @@ -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
      @@ -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
       
      @@ -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 = []
      @@ -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
      @@ -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,
      @@ -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:
      @@ -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
      @@ -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
      @@ -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),
      @@ -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
      @@ -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
      @@ -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
      @@ -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)
      @@ -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).
      @@ -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
      @@ -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:
      @@ -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
      @@ -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)
      @@ -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.
       
      @@ -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),
      @@ -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,
      @@ -1809,6 +1812,7 @@ def _do(conn):
                           system_prompt,
                           parent_session_id,
                           cwd,
      +                    profile_name,
                           time.time(),
                       ),
                   )
    • plugins/platforms/discord/adapter.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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

8 PRs closed without merging

From the close comments: done elsewhere 2 · stale or abandoned 2 · merged via another change 1 · superseded by another PR 1 · other 2

  • #4625 feat(telegram): per-topic skill binding and auto-discovery for group forums · other
  • #18510 feat(telegram): route forum topics to Hermes profiles · merged via another change
  • #20018 feat: Profile-based channel routing for single-gateway multi-agent · other
  • #24914 feat: One gateway for multiple agent profiles. · superseded by another PR → f538470, d5d02ea, #64835, #69441
  • #36872 Route messages to per-profile workers behind one bot token · done elsewhere → #64835
Show 3 more
  • #47027 feat(orchestration): AgentContext, RoutingTable, ContextPool — CICS-model for single-daemon multi-agent · stale or abandoned
  • #57873 feat(gateway): route shared bot chats to profiles · done elsewhere → #64835, #20096
  • #66519 feat(gateway): /profile <name> switches active profile in messaging platforms · stale or abandoned → #61205, #24914
Closed issues · 12
  • Closed#4622Feature: Per-Topic Role/Skill Binding for Telegram Group Forum Topics
  • Closed#7517Feature Request: Native Multi-Agent Support
  • Closed#10030Profiles support multiple user gateway : telegram weixin etc???
  • Closed#10143[Feature] Topic-to-Profile routing: dispatch messages to different profiles based on Telegram topic/thread
  • Closed#18423[Feature]: Profile routing for Discord
  • Closed#19809[Feature]: Per-channel profile routing for Discord (single bot, single gateway)
  • Closed#23735Design discussion: multi-profile deployments in a single gateway process
  • Closed#29535Gateway: route chat/thread messages to Hermes profiles in a single gateway process
  • Closed#34819Feature: Telegram forum topic identity routing — each topic acts as a different agent
  • Closed#40173feat(telegram): channel_profiles — route Telegram chats to Hermes profiles in one gateway
  • Closed#57871Support shared-token chat/topic routes for multiplexed gateway profiles
  • Closed#57872Support routing one bot token across multiple profile homes
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#64835
Closed issues
12
Attempts
8 PRs closed without merging
Fix link checked
15 Jul 2026

For your agent

The same facts, typed.