hermes - 💡(How to fix) Fix [Feature]: Per-channel profile routing for Discord (single bot, single gateway) [2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#19809Fetched 2026-05-05 06:05:06
View on GitHub
Comments
2
Participants
2
Timeline
6
Reactions
0
Author
Participants
Timeline (top)
labeled ×4commented ×2

Error Message

  • Warn (not error) for missing profiles, and fall back to default_profile for those channels.

What happens on error

| Profile's model/provider misconfigured | Agent construction fails gracefully; error message sent to channel |

Fix Action

Fix / Workaround

Gateway dispatch changes

The profile's .env is NOT loaded into os.environ globally (race condition with concurrent turns). Instead, API keys and provider settings from the profile's config are passed explicitly to the agent construction. If the profile needs a provider not configured in the gateway's .env, the profile's .env values should be read and merged into the runtime kwargs without mutating the global environment.

  1. Multiple bot apps, one per profile (today's workaround). Works, but every persona means another Developer Portal app, token, invite, and gateway process. Doesn't scale, and the server gets cluttered with multiple bot identities.
  2. One profile + discord.channel_prompts per channel. Supported today, but prompts are ephemeral — channels still share one profile's memory, sessions, skills, and terminal.cwd. No real isolation.
  3. Hot-swap HERMES_HOME per request inside one gateway. Same UX as the proposal, but tears down and re-inits agent state every message. Routing keeps each profile's runtime warm and dispatches to it, which is closer to how get_hermes_home() already resolves paths.

Code Example

Single Discord bot
  ├── #coder      → profile: coder
  ├── #research   → profile: research
  └── #ops        → profile: ops

---

discord:
  profile_routing:
    enabled: true
    default_profile: default        # DMs and unmapped channels
    channels:
      "1234567890": coder
      "2345678901": research
      "3456789012": ops

---

def __init__(
    self,
    ...,
    soul_path: Optional[str] = None,      # override SOUL.md location
    memory_home: Optional[str] = None,     # override memory storage root
    skills_dir: Optional[str] = None,      # override skills loading directory
    ...,
):

---

async def _run_agent(
    self,
    ...,
    profile_name: Optional[str] = None,
    profile_home: Optional[str] = None,
):

---

soul_path=str(Path(profile_home) / "SOUL.md") if profile_home else None,
memory_home=str(profile_home) if profile_home else None,
skills_dir=str(Path(profile_home) / "skills") if profile_home else None,

---

def load_soul_md(soul_path: Optional[Path] = None) -> str:
    path = soul_path or (get_hermes_home() / "SOUL.md")

---

def get_all_skills_dirs(skills_dir: Optional[Path] = None) -> list:
    base = skills_dir or get_hermes_home()

---

# Map a channel to a profile (updates config.yaml)
hermes gateway route add --channel 1234567890 --profile coder

# List current routing
hermes gateway route list

# Remove a mapping
hermes gateway route remove --channel 1234567890

---
RAW_BUFFERClick to expand / collapse

Problem or Use Case

Currently, running multiple distinct Hermes personalities on Discord requires N bot applications, N bot tokens, N gateway processes, and N systemd services.

The gateway is the I/O layer (Discord connection, auth, session lookup, media). The profile is the state layer (sessions, memory, skills, SOUL). Running N gateways to get N profiles duplicates the I/O layer to achieve isolation that lives one layer underneath it.

Proposed Solution

One Discord bot. Different channels backed by different Hermes profiles.

Single Discord bot
  ├── #coder      → profile: coder
  ├── #research   → profile: research
  └── #ops        → profile: ops

Each channel gets that profile's own sessions, memory, skills, SOUL.md, model config, and terminal.cwd — exactly what hermes profile create already gives you, just selected by channel instead of by gateway process.

Proposed config

discord:
  profile_routing:
    enabled: true
    default_profile: default        # DMs and unmapped channels
    channels:
      "1234567890": coder
      "2345678901": research
      "3456789012": ops
  • enabled: true — opt-in. When false or absent, behavior is identical to today.
  • default_profile: default — which profile handles DMs and unmapped channels. If omitted, the gateway's own profile handles them (current behavior).
  • channels — map of Discord channel ID (string) → profile name. Profile must exist under ~/.hermes/profiles/<name>/.
  • Threads inherit their parent channel's profile. No per-thread config needed.

How it should work

When a Discord message arrives in a mapped channel:

  1. The gateway resolves the channel ID to a profile name from discord.profile_routing.channels.
  2. If mapped, it loads that profile's config.yaml (for model, provider, toolset resolution) and resolves paths under ~/.hermes/profiles/<name>/.
  3. The session key is namespaced: profile:<name>:<base_key>. This isolates the in-memory agent cache, model overrides, and session store lookups per profile automatically.
  4. An AIAgent is constructed (or reused from cache) with the profile's SOUL, memory directory, and skills directory — not the gateway's default.
  5. A per-profile SessionStore points to <profile_home>/sessions/ and a per-profile SessionDB to <profile_home>/state.db. These are cached on the GatewayRunner so they're not recreated every turn.
  6. If unmapped, the message falls through to the default agent — identical to today.

What gets isolated per profile

SubsystemIsolated?How
SOUL.mdYesRead from <profile_home>/SOUL.md
MemoryYesMemoryManager initialized with <profile_home>
SkillsYesLoaded from <profile_home>/skills/
SessionsYesSessionStore at <profile_home>/sessions/
State DBYesSessionDB at <profile_home>/state.db
Model / provider / toolsetsYesRead from <profile_home>/config.yaml
terminal.cwdYesSet in profile config
Agent cacheYesSession key prefix profile:<name>:

What stays shared (by design)

SubsystemShared?Why
MCP serversSharedProcess-level singletons. Per-profile MCP is out of scope; users who need it can still run separate gateways.
PluginsSharedSame reasoning.
Credential poolsSharedBy design — key rotation spans all profiles.
ToolsSharedTools are stateless. They use cwd and env, which are already per-profile via config.
LoggingSharedProfile name appears in session key in logs — sufficient for observability.

Implementation approach: targeted in-process overrides

Rather than replacing every get_hermes_home() call in the codebase (fragile — miss one and you silently leak state), add three explicit optional parameters to AIAgent.__init__:

def __init__(
    self,
    ...,
    soul_path: Optional[str] = None,      # override SOUL.md location
    memory_home: Optional[str] = None,     # override memory storage root
    skills_dir: Optional[str] = None,      # override skills loading directory
    ...,
):

When provided, the agent uses these paths instead of deriving from get_hermes_home(). When None (the default), behavior is unchanged. This means:

  • Graceful degradation — if a subsystem isn't wired up for the override yet, it falls back to the global home rather than silently mixing profile data.
  • No get_hermes_home() replacement needed — logging, MCP init, plugin loading, and other process-level concerns continue using the gateway's home. They don't need per-profile isolation.
  • Small blast radius — changes are additive. Three new optional params, a routing lookup in the message handler, and per-profile session store caching.

Gateway dispatch changes

In GatewayRunner._handle_message_with_agent, after building the session source but before creating the session entry:

  1. Check discord.profile_routing.channels for the channel ID.
  2. If mapped, resolve the profile home, load its config, and swap in the profile's session store and state DB.
  3. Prefix the session key with profile:<name>:.
  4. Pass soul_path, memory_home, and skills_dir overrides through _run_agent to the AIAgent constructor.

In _run_agent, add the profile params to the signature and forward them to AIAgent:

async def _run_agent(
    self,
    ...,
    profile_name: Optional[str] = None,
    profile_home: Optional[str] = None,
):

The existing AIAgent() construction site (~line 12569 in gateway/run.py) passes the overrides:

soul_path=str(Path(profile_home) / "SOUL.md") if profile_home else None,
memory_home=str(profile_home) if profile_home else None,
skills_dir=str(Path(profile_home) / "skills") if profile_home else None,

Subsystem plumbing

Three small changes to accept the overrides:

agent/prompt_builder.pyload_soul_md():

def load_soul_md(soul_path: Optional[Path] = None) -> str:
    path = soul_path or (get_hermes_home() / "SOUL.md")

agent/skill_utils.pyget_all_skills_dirs():

def get_all_skills_dirs(skills_dir: Optional[Path] = None) -> list:
    base = skills_dir or get_hermes_home()

agent/memory_manager.py — already accepts hermes_home kwarg in initialize_all(). No change needed if the caller passes memory_home as hermes_home.

Config validation

On gateway startup, if discord.profile_routing.enabled is true:

  • Validate each profile name in channels has a directory at ~/.hermes/profiles/<name>/.
  • Warn (not error) for missing profiles, and fall back to default_profile for those channels.
  • Validate default_profile exists if specified.

Profile config resolution

When a profile is mapped, its config.yaml is read and used for:

  • Model selection (model.default, model.provider)
  • Toolset enablement (platform_toolsets)
  • Agent settings (agent.max_turns, etc.)
  • terminal.cwd

The profile's .env is NOT loaded into os.environ globally (race condition with concurrent turns). Instead, API keys and provider settings from the profile's config are passed explicitly to the agent construction. If the profile needs a provider not configured in the gateway's .env, the profile's .env values should be read and merged into the runtime kwargs without mutating the global environment.

Thread behavior

  • Threads inherit their parent channel's profile. The session key for a thread is profile:<name>:<base_thread_key> — same profile prefix as the parent channel.
  • No new config knob for per-thread profile selection.

CLI commands

No new CLI subcommands needed for the core feature. Profile management uses the existing hermes profile commands.

Optional convenience (post-MVP):

# Map a channel to a profile (updates config.yaml)
hermes gateway route add --channel 1234567890 --profile coder

# List current routing
hermes gateway route list

# Remove a mapping
hermes gateway route remove --channel 1234567890

Slash commands

No new slash commands needed. The routing is config-driven and transparent to users in Discord.

Optional post-MVP: /profile in a channel shows which profile is active for that channel.

What happens on error

ScenarioBehavior
Mapped profile directory missingLog warning, fall back to default_profile (or gateway default)
Profile config.yaml unreadableSame fallback
Profile's model/provider misconfiguredAgent construction fails gracefully; error message sent to channel
Two channels mapped to same profileWorks fine — they share that profile's memory/sessions via different session keys
Profile routing enabled but no channels mappedNo-op; all messages use default

Not in scope

  • Per-profile MCP server isolation (would require process separation)
  • Cross-guild profile routing (one bot in multiple Discord servers)
  • Per-user profile selection within a channel
  • Auto-provisioning profiles or Discord channels

Alternatives Considered

  1. Multiple bot apps, one per profile (today's workaround). Works, but every persona means another Developer Portal app, token, invite, and gateway process. Doesn't scale, and the server gets cluttered with multiple bot identities.
  2. One profile + discord.channel_prompts per channel. Supported today, but prompts are ephemeral — channels still share one profile's memory, sessions, skills, and terminal.cwd. No real isolation.
  3. Hot-swap HERMES_HOME per request inside one gateway. Same UX as the proposal, but tears down and re-inits agent state every message. Routing keeps each profile's runtime warm and dispatches to it, which is closer to how get_hermes_home() already resolves paths.

Feature Type

Gateway / messaging improvement

Scope

None

Contribution

  • I'd like to implement this myself and submit a PR

Debug Report (optional)

extent analysis

TL;DR

To implement the proposed solution for running multiple distinct Hermes personalities on Discord with a single bot application, update the AIAgent constructor to accept optional parameters for overriding the SOUL path, memory home, and skills directory.

Guidance

  • Update the AIAgent.__init__ method to include optional parameters soul_path, memory_home, and skills_dir to allow for per-profile overrides.
  • Modify the GatewayRunner._handle_message_with_agent method to resolve the profile home and load its config based on the discord.profile_routing.channels mapping.
  • Implement config validation to ensure that each profile name in channels has a directory at ~/.hermes/profiles/<name>/.
  • Update the agent/prompt_builder.py, agent/skill_utils.py, and agent/memory_manager.py modules to accept the overrides.

Example

def __init__(
    self,
    ...,
    soul_path: Optional[str] = None,      # override SOUL.md location
    memory_home: Optional[str] = None,     # override memory storage root
    skills_dir: Optional[str] = None,      # override skills loading directory
    ...,
):

Notes

The proposed solution requires careful consideration of the implications of sharing certain subsystems, such as MCP servers and plugins, across profiles. Additionally, the implementation should ensure that the overrides are properly validated and handled to prevent errors or security vulnerabilities.

Recommendation

Apply the workaround by updating the AIAgent constructor and implementing the necessary changes to support per-profile overrides, as this approach provides a more scalable and maintainable solution compared to the current workaround of using multiple bot applications.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING