Skip to content

feat(gateway): profile-based routing for inbound messages#20096

Closed
Burgunthy wants to merge 10 commits into
NousResearch:mainfrom
Burgunthy:feat/profile-routing
Closed

feat(gateway): profile-based routing for inbound messages#20096
Burgunthy wants to merge 10 commits into
NousResearch:mainfrom
Burgunthy:feat/profile-routing

Conversation

@Burgunthy

@Burgunthy Burgunthy commented May 5, 2026

Copy link
Copy Markdown
Contributor

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_scope machinery introduced by gateway.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_profiles is off, profile_routes is ignored and behavior is byte-identical to main.

Motivation

multiplex_profiles already 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

inbound message
      │
      ▼
BasePlatformAdapter.build_source()
      │
      │  calls runner._profile_name_for_source(source)
      │  → match_profile_route(routes, platform, guild, chat, thread)
      │  → most specific match wins (thread > channel > guild)
      │
      ▼
SessionSource(profile="routed-profile")
      │
      ▼
_run_agent() checks config.multiplex_profiles
      │
      │  if true: _resolve_profile_home_for_source(source)
      │           → reads source.profile (or re-runs routing as fallback)
      │           → returns profile's HERMES_HOME
      │
      ▼
_profile_runtime_scope(profile_home)
      │
      │  set_hermes_home_override()  → config/skills/memory/SOUL resolve to profile
      │  set_secret_scope()          → credentials resolve to profile
      │
      ▼
agent runs fully isolated per profile

No new isolation logic. Just a routing decision stamped on source.profile, which the existing scope machinery already reads.

Configuration

gateway:
  multiplex_profiles: true              # required for isolation to activate
  profile_routes:
    - name: server-default
      platform: discord
      guild_id: "GUILD_ID"
      profile: server-profile

    - name: special-channel
      platform: discord
      guild_id: "GUILD_ID"
      chat_id: "CHANNEL_ID"
      profile: channel-profile

    - name: thread-route
      platform: discord
      chat_id: "CHANNEL_ID"
      thread_id: "THREAD_ID"
      profile: thread-profile

Matching priority (most specific first):

Specificity Match
14 platform + guild_id + chat_id + thread_id
12 platform + chat_id + thread_id
6 platform + guild_id + chat_id
4 platform + chat_id
2 platform + guild_id
0 no match → default profile

Routes keyed on a channel match direct messages and any messages in a thread/post whose parent_chat_id is that channel (one level up). Discord forum posts already have parent_chat_id set to the forum channel by the adapter, so routes on a forum channel match comments on its posts.

Safety

  • Missing-profile warning: if a route targets a profile that doesn't exist on disk (typo: crypto-tradr), _resolve_profile_home_for_source logs a WARNING with 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.
  • Path traversal: validate_profile_name() enforces ^[a-z0-9][a-z0-9_-]*$ at config parse time.
  • No new SQL: reuses existing session-key machinery; no new columns or queries.

Files

New:

  • gateway/profile_routing.py (~170) — ProfileRoute dataclass with specificity scoring, parse_profile_routes, match_profile_route. Path traversal protection via validate_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_routes field on GatewayConfig, yaml parsing (top-level or nested under gateway:).
  • gateway/platforms/base.py (+35) — build_source() resolves profile via routing and stamps it on source.profile.
  • gateway/run.py (+90) — _profile_name_for_source() runs the routing match; _resolve_profile_home_for_source adds defensive fallback chain and missing-profile warning.

Backwards compatibility

  • No profile_routes configured → _profile_name_for_source returns None → behavior identical to main.
  • multiplex_profiles off → profile_routes ignored (existing short-circuit in _run_agent).
  • No DB migrations, no schema changes, no new env vars.

Test plan

  • 43 unit tests pass (31 routing + 12 resolution)
  • Verified end-to-end: build_sourcesource.profile_resolve_profile_home_for_source reads it correctly
  • Verified fallback chain: source.profile > routing match > active profile > default
  • Verified missing-profile warning fires with correct context
  • Live validation on Discord with multiple profiles (reviewer-led)

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have labels May 5, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes closed #20018. Addresses feature requests #18423, #19809, and partially #13633 (Feishu profile routing).

@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes closed #20018.

@Burgunthy
Burgunthy force-pushed the feat/profile-routing branch 3 times, most recently from 80420ad to 89c25d6 Compare May 10, 2026 12:22
@bbogdanov

Copy link
Copy Markdown

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

@Burgunthy

Copy link
Copy Markdown
Contributor Author

Great point! I'''ve implemented this in the latest update. The routing now supports server-level (guild_id) routing with a hierarchical priority system:

  • guild_id (server) → chat_id (channel) → thread_id (thread)
  • Priority: thread(8) > channel(4) > guild(2) > user(1)

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-profile

Each 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 guild_id routes.

@fabsau

fabsau commented May 28, 2026

Copy link
Copy Markdown

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?

@Burgunthy

Burgunthy commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@fabsau Sorry for the late reply!

The routing should work for Teams — the adapter passes conv.id as chat_id, so you can route per channel like this:

profile_routes:
  - name: team-channel
    platform: teams
    chat_id: "19:[email protected]"
    profile: specialist

That said, I haven't set up Teams with Hermes myself, so this is based on the adapter code only, not from actual use.

@calebchongc

Copy link
Copy Markdown

+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

@romansoft

Copy link
Copy Markdown

+1 very useful for multi-agent communication via single Discord gateway.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
Burgunthy and others added 3 commits July 13, 2026 22:43
# 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]>
teknium1 pushed a commit that referenced this pull request Jul 15, 2026
…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]>
teknium1 pushed a commit that referenced this pull request Jul 15, 2026
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]>
teknium1 added a commit that referenced this pull request Jul 15, 2026
…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).
teknium1 pushed a commit that referenced this pull request Jul 15, 2026
…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]>
teknium1 pushed a commit that referenced this pull request Jul 15, 2026
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]>
teknium1 added a commit that referenced this pull request Jul 15, 2026
…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).
@teknium1

Copy link
Copy Markdown
Contributor

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:

  • Gated routing on gateway.multiplex_profiles (routing stamps source.profile, which namespaces session/batch keys, but the profile-scoped agent run only activates under multiplexing — without the gate the two could disagree)
  • Widened your Discord batch-key fix to the other adapters that batch text (telegram, whatsapp, matrix, feishu, wecom, weixin)
  • Downgraded the no-route-matched log to DEBUG, made GatewayConfig.to_dict() JSON-safe for routes, and documented profile_routes in the multi-profile-gateways user guide

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!

iykwak10-sys pushed a commit to iykwak10-sys/hermes-agent that referenced this pull request Jul 16, 2026
…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]>
iykwak10-sys pushed a commit to iykwak10-sys/hermes-agent that referenced this pull request Jul 16, 2026
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]>
iykwak10-sys pushed a commit to iykwak10-sys/hermes-agent that referenced this pull request Jul 16, 2026
…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).
iykwak10-sys pushed a commit to iykwak10-sys/hermes-agent that referenced this pull request Jul 16, 2026
@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Jul 19, 2026
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…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]>
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
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]>
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…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).
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants