mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(gateway): profile routing — conjunctive matching + universal gateway_runner
Addresses hermes-sweeper review on #20096. Problem 1 (profile_routing.py): route matching returned True on a chat_id hit before the guild_id constraint was consulted, so a route declaring both guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND) semantics — every declared discriminator must hold; hierarchical parent_chat_id matching is preserved. Added a regression test for the guild+chat case. Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter pre-declared the attribute, and only Discord did — so build_source never called _profile_name_for_source for Telegram/Feishu/Slack/etc., despite the platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made the plugin-registry injection unconditional, so profile routing now reaches every platform. Added non-Discord (Telegram) resolution coverage and an injection-inheritance test. Also adds docs/profile-routing.md documenting gateway.profile_routes (matching rules, specificity, profile isolation) — requested in review. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e8b7ce8c19
commit
f58e4622cf
6 changed files with 231 additions and 21 deletions
115
docs/profile-routing.md
Normal file
115
docs/profile-routing.md
Normal file
|
|
@ -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/<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) | 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue