diff --git a/docs/profile-routing.md b/docs/profile-routing.md new file mode 100644 index 000000000000..52c6934b47ab --- /dev/null +++ b/docs/profile-routing.md @@ -0,0 +1,115 @@ +# Profile-Based Routing for Inbound Messages + +> **Audience:** Gateway operators and contributors +> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py` +> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md` + +## Overview + +By default a single gateway run uses one profile (memory, persona, tools). **Profile-based +routing** lets one gateway instance serve **multiple isolated profiles**, selecting which +profile handles an inbound message based on *where the message came from* — the platform, +server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`). + +This is the inbound counterpart to multiplexing: instead of running N gateways, run one +gateway and route per-community / per-channel / per-thread to a dedicated profile. Each +profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools). + +Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every +adapter — not just Discord. + +## Configuring routes + +Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested +`gateway.profile_routes` forms are accepted (the nested form is what +`hermes config set gateway.profile_routes ...` writes). + +```yaml +profile_routes: + # Route an entire Discord server (guild) to one profile. + - name: server-default + platform: discord + guild_id: "1234567890" + profile: server-profile + + # Override a specific channel within that server with a different profile. + - name: support-channel + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + profile: support-profile + + # Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only). + - name: tg-group + platform: telegram + chat_id: "-1001234567890" + profile: tg-profile + + # Route a single Discord thread. + - name: standup-thread + platform: discord + guild_id: "1234567890" + chat_id: "9876543210" + thread_id: "1111111111" + profile: standup +``` + +### Fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Human-readable route identifier (used in logs). | +| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … | +| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/`). | +| `guild_id` | no | Server/guild (Discord). | +| `chat_id` | no | Channel/group/DM id. | +| `thread_id` | no | Thread id within a channel. | +| `enabled` | no | Default `true`; set `false` to disable a route without removing it. | + +## Matching rules + +A route matches an inbound source when **every discriminator the route declares is satisfied** +(conjunctive / AND). A field the route leaves unset is ignored. + +- **`platform`** must equal the source platform exactly. +- **`thread_id`** (if set) must equal the source thread id. +- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a + channel matches the channel's route (hierarchical match for Discord forums/threads). +- **`guild_id`** (if set) must equal the source guild. + +> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match +> alone does not satisfy a guild constraint — this is intentional and tested. + +When multiple routes match, the **most specific** one wins. Specificity is additive: + +| Discriminator | Weight | +|---|---| +| `thread_id` | 8 | +| `chat_id` | 4 | +| `guild_id` | 2 | +| (platform only) | 1 | + +So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server. +If no route matches, the message uses the default/active profile. + +## How it works at runtime + +1. An inbound message arrives at a platform adapter. +2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every + adapter carries a back-reference to the running `GatewayRunner` + (`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the + target profile via `_profile_name_for_source`. +3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and + stamps `source.profile` with the winning route's profile (or leaves it unset). +4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory + (`source.profile` → active profile → `default`) and the session is scoped per-profile, so + each routed community gets isolated memory and conversation state. + +Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`), +every platform goes through this path — not just Discord. + +## Migration / coexistence with multiplexing + +`profile_routes` is independent of `gateway.multiplex_profiles`. Multiplexing splits the +gateway across model credentials; profile routing splits conversation state across profiles. +They compose: you may multiplex credentials while also routing channels to distinct profiles. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 4e8820ec6f7d..d3c935733e6b 100644 --- a/gateway/platforms/base.py +++ b/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 diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py index b2884858fe52..c0ec0acc2bf9 100644 --- a/gateway/profile_routing.py +++ b/gateway/profile_routing.py @@ -80,10 +80,14 @@ class ProfileRoute: parent_chat_id: Optional[str] = None, ) -> bool: """Return True if this route matches the given source fields. - - Supports hierarchical matching for Discord forums: + + All configured discriminators are matched conjunctively (AND): every + discriminator that the route declares must hold. ``chat_id`` supports + hierarchical matching for Discord forums/threads: - Direct channel match: chat_id == route.chat_id - Thread in channel: parent_chat_id == route.chat_id + A route declaring both ``guild_id`` and ``chat_id`` requires both to + match (a chat match alone does not satisfy a guild constraint). """ if not self.enabled: return False @@ -91,20 +95,8 @@ class ProfileRoute: return False if self.thread_id and self.thread_id != thread_id: return False - - # Hierarchical chat_id matching - if self.chat_id: - # Direct match - if self.chat_id == chat_id: - return True - # Parent match (thread or direct child) - if self.chat_id == parent_chat_id: - return True - - # If chat_id was specified but didn't match any level, fail - if self.chat_id: + if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id: return False - if self.guild_id and self.guild_id != guild_id: return False return True diff --git a/gateway/run.py b/gateway/run.py index 23da64665f6b..658e57efaf7a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8835,12 +8835,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if platform_registry.is_registered(platform.value): adapter = platform_registry.create_adapter(platform.value, config) if adapter is not None: - # Adapters that need a back-reference to the gateway runner - # (e.g. for cross-platform admin alerts) declare a - # ``gateway_runner`` attribute. Inject it after creation so - # plugin adapters don't need a custom factory signature. - if hasattr(adapter, "gateway_runner"): - adapter.gateway_runner = self + # Inject a back-reference to the gateway runner so every + # adapter can (a) deliver cross-platform admin alerts and + # (b) resolve inbound profile routing through + # ``runner._profile_name_for_source``. Unconditional: + # ``BasePlatformAdapter`` declares ``gateway_runner``, so + # this reaches ALL platforms (not just the ones that + # pre-declared it), making profile routing platform-generic. + adapter.gateway_runner = self return adapter # Registered but failed to instantiate — don't silently fall # through to built-ins (there are none for plugin platforms). diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index da0ddf32bfb3..0658593ece8f 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -8,6 +8,7 @@ import pytest from gateway.session import SessionSource from gateway.run import GatewayRunner +from gateway.profile_routing import ProfileRoute @pytest.fixture @@ -33,6 +34,22 @@ def discord_source(): ) +@pytest.fixture +def telegram_source(): + """Create a basic Telegram SessionSource for testing. + + Telegram (like Slack/Feishu/etc.) has no ``guild_id`` — only ``chat_id``. + Used to prove profile routing is platform-generic, not Discord-only. + """ + return SessionSource( + platform=MagicMock(value="telegram"), + chat_id="-1001234567890", + guild_id=None, + thread_id=None, + parent_chat_id=None, + ) + + class TestResolutionOrder: """Tests that profile resolution follows the correct priority order.""" @@ -255,3 +272,62 @@ class TestRoutingConsultation: # Should NOT have called routing mock_runner._profile_name_for_source.assert_not_called() + + +class TestNonDiscordProfileRouting: + """Profile routing must be platform-generic, not Discord-only. + + Regression coverage for the ``gateway_runner`` injection gap: previously + only Discord's adapter pre-declared ``gateway_runner``, so only Discord + ever had ``build_source`` call ``_profile_name_for_source``. Telegram / + Feishu / Slack / etc. silently fell through to the default profile. These + tests pin the resolution half for a non-Discord platform (Telegram). + """ + + def test_telegram_route_resolves(self, mock_runner, telegram_source): + """A configured Telegram route resolves to its profile via the real + ``_profile_name_for_source`` (bound onto the mock runner).""" + mock_runner.config.profile_routes = [ + ProfileRoute(name="tg", platform="telegram", profile="tg-profile", + chat_id="-1001234567890"), + ] + telegram_source.profile = None + + assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile" + + def test_telegram_no_route_returns_none(self, mock_runner, telegram_source): + """With no matching Telegram route, resolution returns None (caller + falls back to the default/active profile).""" + mock_runner.config.profile_routes = [ + ProfileRoute(name="dc", platform="discord", profile="dc-profile", + chat_id="123456"), + ] + telegram_source.profile = None + + assert mock_runner._profile_name_for_source(telegram_source) is None + + +class TestGatewayRunnerInjection: + """``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's + unconditional injection reaches every platform adapter — the foundation + that makes the routing in TestNonDiscordProfileRouting reachable at runtime. + """ + + def test_base_adapter_declares_gateway_runner(self): + from gateway.platforms.base import BasePlatformAdapter + + # Class-level attribute exists and defaults to None. + assert hasattr(BasePlatformAdapter, "gateway_runner") + assert BasePlatformAdapter.gateway_runner is None + + def test_subclass_inherits_gateway_runner(self): + from gateway.platforms.base import BasePlatformAdapter + + class _ToyAdapter(BasePlatformAdapter): + pass + + # No manual declaration — yet the attribute is inherited from the base, + # so the gateway's ``adapter.gateway_runner = self`` injection reaches + # every adapter, not just the ones that pre-declared it (Discord). + assert hasattr(_ToyAdapter, "gateway_runner") + assert _ToyAdapter.gateway_runner is None diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py index 4df37a196955..abb0c7bcc8ad 100644 --- a/tests/gateway/test_profile_routing.py +++ b/tests/gateway/test_profile_routing.py @@ -70,6 +70,21 @@ class TestProfileRouteMatching: 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):