feat(gateway): profile-based routing for inbound messages#20096
feat(gateway): profile-based routing for inbound messages#20096Burgunthy wants to merge 10 commits into
Conversation
|
Supersedes closed #20018. |
80420ad to
89c25d6
Compare
|
One thing I would add to this, is server routing as well. I have a case where I have one gateway connected to different servers via the bot and have routing to different servers and approved channels would be beneficial |
|
Great point! I'''ve implemented this in the latest update. The routing now supports server-level (
Config example: profile_routes:
# Route an entire server to one profile
- name: server-default
platform: discord
guild_id: '\''1234567890'\''
profile: server-profile
# Override specific channels within the same server
- name: special-channel
platform: discord
guild_id: '\''1234567890'\''
chat_id: '\''9876543210'\''
profile: channel-profileEach profile has fully isolated memory (MEMORY.md, USER.md, SOUL.md). Your use case — one gateway serving multiple servers with different routing — should work out of the box with |
4cea794 to
06f4c3f
Compare
|
Thank you for your PR and the work you have commited! Once this is merged, could it be also be added for Teams and Matrix? It seems so, right? |
06f4c3f to
5356d73
Compare
|
@fabsau Sorry for the late reply! The routing should work for Teams — the adapter passes profile_routes:
- name: team-channel
platform: teams
chat_id: "19:[email protected]"
profile: specialistThat said, I haven't set up Teams with Hermes myself, so this is based on the adapter code only, not from actual use. |
|
+1 to having this merged into main, having multiple profiles and channel routing them for a single instance would be a useful feature for multiple projects rather than having multiple installations |
|
+1 very useful for multi-agent communication via single Discord gateway. |
b262421 to
41c7c8d
Compare
# Conflicts: # gateway/config.py # gateway/session.py # hermes_state.py # tests/gateway/test_config.py
…way_runner Addresses hermes-sweeper review on NousResearch#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 <[email protected]>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-NousResearch#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <[email protected]>
…way_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 <[email protected]>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on #20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <[email protected]>
…h-key routing to all adapters 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).
…way_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 <[email protected]>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on #20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <[email protected]>
…h-key routing to all adapters 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).
|
Merged via PR #64835 — thank you @Burgunthy for this contribution! All 8 of your commits were cherry-picked onto current main with your authorship preserved in git history (rebase-merge). On top of your work we added a few follow-ups during the salvage:
This was the earliest and most general implementation of a heavily-requested capability — several later PRs asked for subsets of the same thing. Much appreciated! |
…way_runner Addresses hermes-sweeper review on NousResearch#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 <[email protected]>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-NousResearch#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <[email protected]>
…h-key routing to all adapters Follow-ups on the salvaged NousResearch#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).
…way_runner Addresses hermes-sweeper review on NousResearch#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 <[email protected]>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-NousResearch#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <[email protected]>
…h-key routing to all adapters Follow-ups on the salvaged NousResearch#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).
Summary
Adds profile-based routing to the gateway: a single Hermes instance can route specific Discord guilds/channels/threads (and other platforms) to different profiles. Each profile gets full HERMES_HOME isolation — memory, skills, config, secrets, sessions — via the existing
_profile_runtime_scopemachinery introduced bygateway.multiplex_profiles.This PR adds only the routing decision layer on top of that existing infrastructure. It does not introduce new isolation code, new DB columns, or parallel profile-scoped paths. When
multiplex_profilesis off,profile_routesis ignored and behavior is byte-identical tomain.Motivation
multiplex_profilesalready lets a gateway serve multiple profiles, but profile selection is currently driven by the/p/<profile>/URL prefix (HTTP platforms) or per-credential adapter ownership. Chat platforms like Discord have no equivalent — you cannot say "this guild goes to profile X" without spinning up a second adapter. This PR fills that gap.How it works
No new isolation logic. Just a routing decision stamped on
source.profile, which the existing scope machinery already reads.Configuration
Matching priority (most specific first):
platform+guild_id+chat_id+thread_idplatform+chat_id+thread_idplatform+guild_id+chat_idplatform+chat_idplatform+guild_idRoutes keyed on a channel match direct messages and any messages in a thread/post whose
parent_chat_idis that channel (one level up). Discord forum posts already haveparent_chat_idset to the forum channel by the adapter, so routes on a forum channel match comments on its posts.Safety
crypto-tradr),_resolve_profile_home_for_sourcelogs aWARNINGwith the profile name, source identifier, and the fallback reason — then falls back to global HERMES_HOME. Previously this failed silently, routing messages to the default profile with no signal to the operator.validate_profile_name()enforces^[a-z0-9][a-z0-9_-]*$at config parse time.Files
New:
gateway/profile_routing.py(~170) —ProfileRoutedataclass with specificity scoring,parse_profile_routes,match_profile_route. Path traversal protection viavalidate_profile_name.tests/gateway/test_profile_routing.py(~245) — 31 unit tests: specificity, parent_chat_id matching, forum post matching, session key integration, config parsing.tests/gateway/test_profile_resolution.py(~260) — 12 unit tests: resolution order, missing-profile warnings, exception handling, routing consultation.Modified:
hermes_constants.py(+44) —STANDARD_PROFILES,normalize_profile,validate_profile_name,is_standard_profile.gateway/config.py(+22) —profile_routesfield onGatewayConfig, yaml parsing (top-level or nested undergateway:).gateway/platforms/base.py(+35) —build_source()resolves profile via routing and stamps it onsource.profile.gateway/run.py(+90) —_profile_name_for_source()runs the routing match;_resolve_profile_home_for_sourceadds defensive fallback chain and missing-profile warning.Backwards compatibility
profile_routesconfigured →_profile_name_for_sourcereturns None → behavior identical tomain.multiplex_profilesoff →profile_routesignored (existing short-circuit in_run_agent).Test plan
build_source→source.profile→_resolve_profile_home_for_sourcereads it correctlysource.profile> routing match > active profile > default🤖 Generated with Claude Code