Follow-ups on the salvaged #20096 profile-routing feature: - _profile_name_for_source now returns None unless gateway.multiplex_profiles is on. Routing stamps source.profile, which namespaces session/batch keys, but the profile-scoped agent run only activates under multiplexing — without the gate, configured routes with multiplexing off split batch/session keys into agent:<profile> while the agent still ran from agent:main. - Widen the profile-aware _text_batch_key fix from Discord to every adapter that builds batch keys via build_session_key (telegram, whatsapp, matrix, feishu, wecom, weixin) — routing is platform-generic, so the batch-key namespace fix must be too. - Downgrade the no-route-matched log from INFO to DEBUG (fired on every unrouted inbound message). - GatewayConfig.to_dict(): serialize profile_routes as plain dicts (ProfileRoute dataclasses are not JSON-safe). - Docs: correct the 'independent of multiplexing' claim in docs/profile-routing.md (routing requires multiplexing), fix the platform-only specificity row (0, not 1), and document profile_routes in website/docs/user-guide/multi-profile-gateways.md. - Tests: pin the multiplex gate (routes ignored when off, active when on, build_source end-to-end stays in agent:main when off).
5 KiB
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.pyRelated: Session Lifecycle,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).
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.
platformmust 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_idandchat_idrequires 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
- An inbound message arrives at a platform adapter.
BasePlatformAdapter.build_sourcebuilds theSessionSourcefor the message. Every adapter carries a back-reference to the runningGatewayRunner(gateway_runner, injected ingateway/run.py), so it asks the runner to resolve the target profile via_profile_name_for_source._profile_name_for_sourceruns the configured routes throughmatch_profile_routeand stampssource.profilewith the winning route's profile (or leaves it unset).- Downstream,
_resolve_profile_home_for_sourcechooses 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.