fix(gateway): make group session isolation configurable

default group and channel sessions to per-user isolation, allow opting back into shared room sessions via config.yaml, and document Discord gateway routing and session behavior.
This commit is contained in:
teknium1
2026-03-16 00:22:23 -07:00
parent 06a7d19f98
commit 38b4fd3737
11 changed files with 246 additions and 27 deletions

View File

@@ -333,6 +333,12 @@ session_reset:
idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours) idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM) at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
# When true, group/channel chats use one session per participant when the platform
# provides a user ID. This is the secure default and prevents users in the same
# room from sharing context, interrupts, and token costs. Set false only if you
# explicitly want one shared "room brain" per group/channel.
group_sessions_per_user: true
# ============================================================================= # =============================================================================
# Skills Configuration # Skills Configuration
# ============================================================================= # =============================================================================

View File

@@ -174,7 +174,10 @@ class GatewayConfig:
# STT settings # STT settings
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
# Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
def get_connected_platforms(self) -> List[Platform]: def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured.""" """Return list of platforms that are enabled and configured."""
connected = [] connected = []
@@ -239,6 +242,7 @@ class GatewayConfig:
"sessions_dir": str(self.sessions_dir), "sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local, "always_log_local": self.always_log_local,
"stt_enabled": self.stt_enabled, "stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
} }
@classmethod @classmethod
@@ -279,6 +283,8 @@ class GatewayConfig:
if stt_enabled is None: if stt_enabled is None:
stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None
group_sessions_per_user = data.get("group_sessions_per_user")
return cls( return cls(
platforms=platforms, platforms=platforms,
default_reset_policy=default_policy, default_reset_policy=default_policy,
@@ -289,6 +295,7 @@ class GatewayConfig:
sessions_dir=sessions_dir, sessions_dir=sessions_dir,
always_log_local=data.get("always_log_local", True), always_log_local=data.get("always_log_local", True),
stt_enabled=_coerce_bool(stt_enabled, True), stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
) )
@@ -344,6 +351,14 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(stt_cfg, dict) and "enabled" in stt_cfg: if isinstance(stt_cfg, dict) and "enabled" in stt_cfg:
config.stt_enabled = _coerce_bool(stt_cfg.get("enabled"), True) config.stt_enabled = _coerce_bool(stt_cfg.get("enabled"), True)
# Bridge group session isolation from config.yaml into gateway runtime.
# Secure default is per-user isolation in shared chats.
if "group_sessions_per_user" in yaml_cfg:
config.group_sessions_per_user = _coerce_bool(
yaml_cfg.get("group_sessions_per_user"),
True,
)
# Bridge discord settings from config.yaml to env vars # Bridge discord settings from config.yaml to env vars
# (env vars take precedence — only set if not already defined) # (env vars take precedence — only set if not already defined)
discord_cfg = yaml_cfg.get("discord", {}) discord_cfg = yaml_cfg.get("discord", {})

View File

@@ -752,7 +752,10 @@ class BasePlatformAdapter(ABC):
if not self._message_handler: if not self._message_handler:
return return
session_key = build_session_key(event.source) session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
)
# Check if there's already an active handler for this session # Check if there's already an active handler for this session
if session_key in self._active_sessions: if session_key in self._active_sessions:

View File

@@ -829,7 +829,10 @@ class TelegramAdapter(BasePlatformAdapter):
def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str: def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
"""Return a batching key for Telegram photos/albums.""" """Return a batching key for Telegram photos/albums."""
from gateway.session import build_session_key from gateway.session import build_session_key
session_key = build_session_key(event.source) session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
)
media_group_id = getattr(msg, "media_group_id", None) media_group_id = getattr(msg, "media_group_id", None)
if media_group_id: if media_group_id:
return f"{session_key}:album:{media_group_id}" return f"{session_key}:album:{media_group_id}"

View File

@@ -514,6 +514,21 @@ class GatewayRunner:
def exit_reason(self) -> Optional[str]: def exit_reason(self) -> Optional[str]:
return self._exit_reason return self._exit_reason
def _session_key_for_source(self, source: SessionSource) -> str:
"""Resolve the current session key for a source, honoring gateway config when available."""
if hasattr(self, "session_store") and self.session_store is not None:
try:
session_key = self.session_store._generate_session_key(source)
if isinstance(session_key, str) and session_key:
return session_key
except Exception:
pass
config = getattr(self, "config", None)
return build_session_key(
source,
group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
)
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None: async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
"""React to a non-retryable adapter failure after startup.""" """React to a non-retryable adapter failure after startup."""
logger.error( logger.error(
@@ -942,6 +957,12 @@ class GatewayRunner:
config: Any config: Any
) -> Optional[BasePlatformAdapter]: ) -> Optional[BasePlatformAdapter]:
"""Create the appropriate adapter for a platform.""" """Create the appropriate adapter for a platform."""
if hasattr(config, "extra") and isinstance(config.extra, dict):
config.extra.setdefault(
"group_sessions_per_user",
self.config.group_sessions_per_user,
)
if platform == Platform.TELEGRAM: if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements(): if not check_telegram_requirements():
@@ -1113,7 +1134,7 @@ class GatewayRunner:
# Special case: Telegram/photo bursts often arrive as multiple near- # Special case: Telegram/photo bursts often arrive as multiple near-
# simultaneous updates. Do NOT interrupt for photo-only follow-ups here; # simultaneous updates. Do NOT interrupt for photo-only follow-ups here;
# let the adapter-level batching/queueing logic absorb them. # let the adapter-level batching/queueing logic absorb them.
_quick_key = build_session_key(source) _quick_key = self._session_key_for_source(source)
if _quick_key in self._running_agents: if _quick_key in self._running_agents:
if event.get_command() == "status": if event.get_command() == "status":
return await self._handle_status_command(event) return await self._handle_status_command(event)
@@ -1302,7 +1323,7 @@ class GatewayRunner:
logger.debug("Skill command check failed (non-fatal): %s", e) logger.debug("Skill command check failed (non-fatal): %s", e)
# Check for pending exec approval responses # Check for pending exec approval responses
session_key_preview = build_session_key(source) session_key_preview = self._session_key_for_source(source)
if session_key_preview in self._pending_approvals: if session_key_preview in self._pending_approvals:
user_text = event.text.strip().lower() user_text = event.text.strip().lower()
if user_text in ("yes", "y", "approve", "ok", "go", "do it"): if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
@@ -1854,7 +1875,7 @@ class GatewayRunner:
source = event.source source = event.source
# Get existing session key # Get existing session key
session_key = self.session_store._generate_session_key(source) session_key = self._session_key_for_source(source)
# Flush memories in the background (fire-and-forget) so the user # Flush memories in the background (fire-and-forget) so the user
# gets the "Session reset!" response immediately. # gets the "Session reset!" response immediately.
@@ -3084,7 +3105,7 @@ class GatewayRunner:
return "Session database not available." return "Session database not available."
source = event.source source = event.source
session_key = build_session_key(source) session_key = self._session_key_for_source(source)
name = event.get_command_args().strip() name = event.get_command_args().strip()
if not name: if not name:
@@ -3156,7 +3177,7 @@ class GatewayRunner:
async def _handle_usage_command(self, event: MessageEvent) -> str: async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the session's last agent run.""" """Handle /usage command -- show token usage for the session's last agent run."""
source = event.source source = event.source
session_key = build_session_key(source) session_key = self._session_key_for_source(source)
agent = self._running_agents.get(session_key) agent = self._running_agents.get(session_key)
if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:

View File

@@ -315,7 +315,7 @@ class SessionEntry:
) )
def build_session_key(source: SessionSource) -> str: def build_session_key(source: SessionSource, group_sessions_per_user: bool = True) -> str:
"""Build a deterministic session key from a message source. """Build a deterministic session key from a message source.
This is the single source of truth for session key construction. This is the single source of truth for session key construction.
@@ -328,9 +328,11 @@ def build_session_key(source: SessionSource) -> str:
Group/channel rules: Group/channel rules:
- chat_id identifies the parent group/channel. - chat_id identifies the parent group/channel.
- user_id/user_id_alt isolates participants within that parent chat when available. - user_id/user_id_alt isolates participants within that parent chat when available when
``group_sessions_per_user`` is enabled.
- thread_id differentiates threads within that parent chat. - thread_id differentiates threads within that parent chat.
- Without participant identifiers, messages fall back to one shared session per chat. - Without participant identifiers, or when isolation is disabled, messages fall back to one
shared session per chat.
- Without identifiers, messages fall back to one session per platform/chat_type. - Without identifiers, messages fall back to one session per platform/chat_type.
""" """
platform = source.platform.value platform = source.platform.value
@@ -350,7 +352,7 @@ def build_session_key(source: SessionSource) -> str:
key_parts.append(source.chat_id) key_parts.append(source.chat_id)
if source.thread_id: if source.thread_id:
key_parts.append(source.thread_id) key_parts.append(source.thread_id)
if participant_id: if group_sessions_per_user and participant_id:
key_parts.append(str(participant_id)) key_parts.append(str(participant_id))
return ":".join(key_parts) return ":".join(key_parts)
@@ -432,7 +434,10 @@ class SessionStore:
def _generate_session_key(self, source: SessionSource) -> str: def _generate_session_key(self, source: SessionSource) -> str:
"""Generate a session key from a source.""" """Generate a session key from a source."""
return build_session_key(source) return build_session_key(
source,
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
)
def _is_session_expired(self, entry: SessionEntry) -> bool: def _is_session_expired(self, entry: SessionEntry) -> bool:
"""Check if a session has expired based on its reset policy. """Check if a session has expired based on its reset policy.

View File

@@ -96,6 +96,7 @@ class TestGatewayConfigRoundtrip:
}, },
reset_triggers=["/new"], reset_triggers=["/new"],
quick_commands={"limits": {"type": "exec", "command": "echo ok"}}, quick_commands={"limits": {"type": "exec", "command": "echo ok"}},
group_sessions_per_user=False,
) )
d = config.to_dict() d = config.to_dict()
restored = GatewayConfig.from_dict(d) restored = GatewayConfig.from_dict(d)
@@ -104,6 +105,7 @@ class TestGatewayConfigRoundtrip:
assert restored.platforms[Platform.TELEGRAM].token == "tok_123" assert restored.platforms[Platform.TELEGRAM].token == "tok_123"
assert restored.reset_triggers == ["/new"] assert restored.reset_triggers == ["/new"]
assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
assert restored.group_sessions_per_user is False
class TestLoadGatewayConfig: class TestLoadGatewayConfig:
@@ -125,6 +127,18 @@ class TestLoadGatewayConfig:
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text("group_sessions_per_user: false\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.group_sessions_per_user is False
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch): def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes" hermes_home = tmp_path / ".hermes"
hermes_home.mkdir() hermes_home.mkdir()

View File

@@ -392,6 +392,31 @@ class TestWhatsAppDMSessionKeyConsistency:
assert second_entry.session_key == "agent:main:discord:group:guild-123:bob" assert second_entry.session_key == "agent:main:discord:group:guild-123:bob"
assert first_entry.session_id != second_entry.session_id assert first_entry.session_id != second_entry.session_id
def test_store_shares_group_sessions_when_disabled_in_config(self, store):
store.config.group_sessions_per_user = False
first = SessionSource(
platform=Platform.DISCORD,
chat_id="guild-123",
chat_type="group",
user_id="alice",
user_name="Alice",
)
second = SessionSource(
platform=Platform.DISCORD,
chat_id="guild-123",
chat_type="group",
user_id="bob",
user_name="Bob",
)
first_entry = store.get_or_create_session(first)
second_entry = store.get_or_create_session(second)
assert first_entry.session_key == "agent:main:discord:group:guild-123"
assert second_entry.session_key == "agent:main:discord:group:guild-123"
assert first_entry.session_id == second_entry.session_id
def test_telegram_dm_includes_chat_id(self): def test_telegram_dm_includes_chat_id(self):
"""Non-WhatsApp DMs should also include chat_id to separate users.""" """Non-WhatsApp DMs should also include chat_id to separate users."""
source = SessionSource( source = SessionSource(
@@ -439,6 +464,23 @@ class TestWhatsAppDMSessionKeyConsistency:
assert build_session_key(second) == "agent:main:discord:group:guild-123:bob" assert build_session_key(second) == "agent:main:discord:group:guild-123:bob"
assert build_session_key(first) != build_session_key(second) assert build_session_key(first) != build_session_key(second)
def test_group_sessions_can_be_shared_when_isolation_disabled(self):
first = SessionSource(
platform=Platform.DISCORD,
chat_id="guild-123",
chat_type="group",
user_id="alice",
)
second = SessionSource(
platform=Platform.DISCORD,
chat_id="guild-123",
chat_type="group",
user_id="bob",
)
assert build_session_key(first, group_sessions_per_user=False) == "agent:main:discord:group:guild-123"
assert build_session_key(second, group_sessions_per_user=False) == "agent:main:discord:group:guild-123"
def test_group_thread_includes_thread_id(self): def test_group_thread_includes_thread_id(self):
"""Forum-style threads need a distinct session key within one group.""" """Forum-style threads need a distinct session key within one group."""
source = SessionSource( source = SessionSource(

View File

@@ -848,6 +848,21 @@ voice:
Use `/voice on` in the CLI to enable microphone mode, `record_key` to start/stop recording, and `/voice tts` to toggle spoken replies. See [Voice Mode](/docs/user-guide/features/voice-mode) for end-to-end setup and platform-specific behavior. Use `/voice on` in the CLI to enable microphone mode, `record_key` to start/stop recording, and `/voice tts` to toggle spoken replies. See [Voice Mode](/docs/user-guide/features/voice-mode) for end-to-end setup and platform-specific behavior.
## Group Chat Session Isolation
Control whether shared chats keep one conversation per room or one conversation per participant:
```yaml
group_sessions_per_user: true # true = per-user isolation in groups/channels, false = one shared session per chat
```
- `true` is the default and recommended setting. In Discord channels, Telegram groups, Slack channels, and similar shared contexts, each sender gets their own session when the platform provides a user ID.
- `false` reverts to the old shared-room behavior. That can be useful if you explicitly want Hermes to treat a channel like one collaborative conversation, but it also means users share context, token costs, and interrupt state.
- Direct messages are unaffected. Hermes still keys DMs by chat/DM ID as usual.
- Threads stay isolated from their parent channel either way; with `true`, each participant also gets their own session inside the thread.
For the behavior details and examples, see [Sessions](/docs/user-guide/sessions) and the [Discord guide](/docs/user-guide/messaging/discord).
## Quick Commands ## Quick Commands
Define custom commands that run shell commands without invoking the LLM — zero token usage, instant execution. Especially useful from messaging platforms (Telegram, Discord, etc.) for quick server checks or utility scripts. Define custom commands that run shell commands without invoking the LLM — zero token usage, instant execution. Especially useful from messaging platforms (Telegram, Discord, etc.) for quick server checks or utility scripts.

View File

@@ -14,15 +14,71 @@ Before setup, here's the part most people want to know: how Hermes behaves once
| Context | Behavior | | Context | Behavior |
|---------|----------| |---------|----------|
| **DMs** | Hermes responds to every message. No `@mention` needed. | | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
| **Server channels** | By default, Hermes only responds when you `@mention` it. If you post in a channel without mentioning it, Hermes ignores the message. | | **Server channels** | By default, Hermes only responds when you `@mention` it. If you post in a channel without mentioning it, Hermes ignores the message. |
| **Free-response channels** | You can make specific channels mention-free with `DISCORD_FREE_RESPONSE_CHANNELS`, or disable mentions globally with `DISCORD_REQUIRE_MENTION=false`. | | **Free-response channels** | You can make specific channels mention-free with `DISCORD_FREE_RESPONSE_CHANNELS`, or disable mentions globally with `DISCORD_REQUIRE_MENTION=false`. |
| **Threads** | Hermes replies in the same thread. Mention rules still apply unless that thread or its parent channel is configured as free-response. | | **Threads** | Hermes replies in the same thread. Mention rules still apply unless that thread or its parent channel is configured as free-response. Threads stay isolated from the parent channel for session history. |
| **Shared channels with multiple users** | By default, Hermes isolates session history per user inside the channel for safety and clarity. Two people talking in the same channel do not share one transcript unless you explicitly disable that. |
:::tip :::tip
If you want a normal shared bot channel where people can talk to Hermes without tagging it every time, add that channel to `DISCORD_FREE_RESPONSE_CHANNELS`. If you want a normal bot-help channel where people can talk to Hermes without tagging it every time, add that channel to `DISCORD_FREE_RESPONSE_CHANNELS`.
::: :::
### Discord Gateway Model
Hermes on Discord is not a webhook that replies statelessly. It runs through the full messaging gateway, which means each incoming message goes through:
1. authorization (`DISCORD_ALLOWED_USERS`)
2. mention / free-response checks
3. session lookup
4. session transcript loading
5. normal Hermes agent execution, including tools, memory, and slash commands
6. response delivery back to Discord
That matters because behavior in a busy server depends on both Discord routing and Hermes session policy.
### Session Model in Discord
By default:
- each DM gets its own session
- each server thread gets its own session namespace
- each user in a shared channel gets their own session inside that channel
So if Alice and Bob both talk to Hermes in `#research`, Hermes treats those as separate conversations by default even though they are using the same visible Discord channel.
This is controlled by `config.yaml`:
```yaml
group_sessions_per_user: true
```
Set it to `false` only if you explicitly want one shared conversation for the entire room:
```yaml
group_sessions_per_user: false
```
Shared sessions can be useful for a collaborative room, but they also mean:
- users share context growth and token costs
- one person's long tool-heavy task can bloat everyone else's context
- one person's in-flight run can interrupt another person's follow-up in the same room
### Interrupts and Concurrency
Hermes tracks running agents by session key.
With the default `group_sessions_per_user: true`:
- Alice interrupting her own in-flight request only affects Alice's session in that channel
- Bob can keep talking in the same channel without inheriting Alice's history or interrupting Alice's run
With `group_sessions_per_user: false`:
- the whole room shares one running-agent slot for that channel/thread
- follow-up messages from different people can interrupt or queue behind each other
This guide walks you through the full setup process — from creating your bot on Discord's Developer Portal to sending your first message. This guide walks you through the full setup process — from creating your bot on Discord's Developer Portal to sending your first message.
## Step 1: Create a Discord Application ## Step 1: Create a Discord Application
@@ -175,13 +231,25 @@ Add the following to your `~/.hermes/.env` file:
```bash ```bash
# Required # Required
DISCORD_BOT_TOKEN=your-bot-token-from-developer-portal DISCORD_BOT_TOKEN=your-bot-token
DISCORD_ALLOWED_USERS=284102345871466496 DISCORD_ALLOWED_USERS=284102345871466496
# Multiple allowed users (comma-separated) # Multiple allowed users (comma-separated)
# DISCORD_ALLOWED_USERS=284102345871466496,198765432109876543 # DISCORD_ALLOWED_USERS=284102345871466496,198765432109876543
``` ```
Optional behavior settings in `~/.hermes/config.yaml`:
```yaml
discord:
require_mention: true
group_sessions_per_user: true
```
- `discord.require_mention: true` keeps Hermes quiet in normal server traffic unless mentioned
- `group_sessions_per_user: true` keeps each participant's context isolated inside shared channels and threads
### Start the Gateway ### Start the Gateway
Once configured, start the Discord gateway: Once configured, start the Discord gateway:
@@ -265,6 +333,18 @@ For the full setup and operational guide, see:
**Fix**: Add your User ID to `DISCORD_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. **Fix**: Add your User ID to `DISCORD_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway.
### People in the same channel are sharing context unexpectedly
**Cause**: `group_sessions_per_user` is disabled, or the platform cannot provide a user ID for the messages in that context.
**Fix**: Set this in `~/.hermes/config.yaml` and restart the gateway:
```yaml
group_sessions_per_user: true
```
If you intentionally want a shared room conversation, leave it off — just expect shared transcript history and shared interrupt behavior.
## Security ## Security
:::warning :::warning

View File

@@ -299,17 +299,32 @@ The agent is prompted to use session search automatically:
On messaging platforms, sessions are keyed by a deterministic session key built from the message source: On messaging platforms, sessions are keyed by a deterministic session key built from the message source:
| Chat Type | Key Format | Example | | Chat Type | Default Key Format | Behavior |
|-----------|-----------|---------| |-----------|--------------------|----------|
| Telegram DM | `agent:main:telegram:dm` | One session per bot | | Telegram DM | `agent:main:telegram:dm:<chat_id>` | One session per DM chat |
| Discord DM | `agent:main:discord:dm` | One session per bot | | Discord DM | `agent:main:discord:dm:<chat_id>` | One session per DM chat |
| WhatsApp DM | `agent:main:whatsapp:dm:<chat_id>` | Per-user (multi-user) | | WhatsApp DM | `agent:main:whatsapp:dm:<chat_id>` | One session per DM chat |
| Group chat | `agent:main:<platform>:group:<chat_id>` | Per-group | | Group chat | `agent:main:<platform>:group:<chat_id>:<user_id>` | Per-user inside the group when the platform exposes a user ID |
| Channel | `agent:main:<platform>:channel:<chat_id>` | Per-channel | | Group thread/topic | `agent:main:<platform>:group:<chat_id>:<thread_id>:<user_id>` | Per-user inside that thread/topic |
| Channel | `agent:main:<platform>:channel:<chat_id>:<user_id>` | Per-user inside the channel when the platform exposes a user ID |
:::info When Hermes cannot get a participant identifier for a shared chat, it falls back to one shared session for that room.
WhatsApp DMs include the chat ID in the session key because multiple users can DM the bot. Other platforms use a single DM session since the bot is configured per-user via allowlists.
::: ### Shared vs Isolated Group Sessions
By default, Hermes uses `group_sessions_per_user: true` in `config.yaml`. That means:
- Alice and Bob can both talk to Hermes in the same Discord channel without sharing transcript history
- one user's long tool-heavy task does not pollute another user's context window
- interrupt handling also stays per-user because the running-agent key matches the isolated session key
If you want one shared "room brain" instead, set:
```yaml
group_sessions_per_user: false
```
That reverts groups/channels to a single shared session per room, which preserves shared conversational context but also shares token costs, interrupt state, and context growth.
### Session Reset Policies ### Session Reset Policies