-
Notifications
You must be signed in to change notification settings - Fork 46.2k
feat(gateway): profile-based routing for inbound messages #20096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
34d91f8
feat(gateway): add profile-based routing for inbound messages
Burgunthy e809cd9
fix(profile-routing): remove dead forum cache, warn on missing profile
Burgunthy df1f48b
refactor(profiles): drop dead helpers from hermes_constants
Burgunthy 83146ee
fix(gateway): read adapter token from config for fingerprint check
Burgunthy cafc635
fix(config): honor gateway.multiplex_profiles nested form
Burgunthy 0c1bc2d
fix(session): persist profile_name and route batch key by profile
Burgunthy 6281369
Merge origin/main into profile-routing-v3
Burgunthy 87ce4ad
Merge remote-tracking branch 'origin/main' into profile-routing-v3
Burgunthy a774f51
fix(gateway): profile routing — conjunctive matching + universal gate…
Burgunthy 149056e
test(gateway): adapter→session-key integration for Discord + Telegram
Burgunthy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This selector is unavailable for adapters that do not declare
gateway_runner: the common factory injects that reference only whenhasattr(adapter, "gateway_runner")is true. Slack, Matrix, and Telegram do not declare it, so they will leavesource.profileunset. Wire the selector through the base adapter/factory and add a non-Discord integration test.