diff --git a/cli-config.yaml.example b/cli-config.yaml.example index aa2cc7072..7bc2c4908 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -333,6 +333,12 @@ session_reset: idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours) 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 # ============================================================================= diff --git a/gateway/config.py b/gateway/config.py index 2b187c521..c6ff14fc4 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -174,7 +174,10 @@ class GatewayConfig: # STT settings 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]: """Return list of platforms that are enabled and configured.""" connected = [] @@ -239,6 +242,7 @@ class GatewayConfig: "sessions_dir": str(self.sessions_dir), "always_log_local": self.always_log_local, "stt_enabled": self.stt_enabled, + "group_sessions_per_user": self.group_sessions_per_user, } @classmethod @@ -279,6 +283,8 @@ class GatewayConfig: if stt_enabled is 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( platforms=platforms, default_reset_policy=default_policy, @@ -289,6 +295,7 @@ class GatewayConfig: sessions_dir=sessions_dir, always_log_local=data.get("always_log_local", 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: 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 # (env vars take precedence — only set if not already defined) discord_cfg = yaml_cfg.get("discord", {}) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index f103fb8b9..480848b67 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -752,7 +752,10 @@ class BasePlatformAdapter(ABC): if not self._message_handler: 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 if session_key in self._active_sessions: diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 2673ab155..08750faed 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -829,7 +829,10 @@ class TelegramAdapter(BasePlatformAdapter): def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str: """Return a batching key for Telegram photos/albums.""" 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) if media_group_id: return f"{session_key}:album:{media_group_id}" diff --git a/gateway/run.py b/gateway/run.py index 7475564d5..fcfd3a718 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -514,6 +514,21 @@ class GatewayRunner: def exit_reason(self) -> Optional[str]: 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: """React to a non-retryable adapter failure after startup.""" logger.error( @@ -942,6 +957,12 @@ class GatewayRunner: config: Any ) -> Optional[BasePlatformAdapter]: """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: from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements if not check_telegram_requirements(): @@ -1113,7 +1134,7 @@ class GatewayRunner: # Special case: Telegram/photo bursts often arrive as multiple near- # simultaneous updates. Do NOT interrupt for photo-only follow-ups here; # 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 event.get_command() == "status": return await self._handle_status_command(event) @@ -1302,7 +1323,7 @@ class GatewayRunner: logger.debug("Skill command check failed (non-fatal): %s", e) # 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: user_text = event.text.strip().lower() if user_text in ("yes", "y", "approve", "ok", "go", "do it"): @@ -1854,7 +1875,7 @@ class GatewayRunner: source = event.source # 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 # gets the "Session reset!" response immediately. @@ -3084,7 +3105,7 @@ class GatewayRunner: return "Session database not available." source = event.source - session_key = build_session_key(source) + session_key = self._session_key_for_source(source) name = event.get_command_args().strip() if not name: @@ -3156,7 +3177,7 @@ class GatewayRunner: async def _handle_usage_command(self, event: MessageEvent) -> str: """Handle /usage command -- show token usage for the session's last agent run.""" source = event.source - session_key = build_session_key(source) + session_key = self._session_key_for_source(source) agent = self._running_agents.get(session_key) if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: diff --git a/gateway/session.py b/gateway/session.py index 3dafa1781..23971a912 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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. 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: - 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. - - 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. """ platform = source.platform.value @@ -350,7 +352,7 @@ def build_session_key(source: SessionSource) -> str: key_parts.append(source.chat_id) if 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)) return ":".join(key_parts) @@ -432,7 +434,10 @@ class SessionStore: def _generate_session_key(self, source: SessionSource) -> str: """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: """Check if a session has expired based on its reset policy. diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c604ee521..ec4215f9c 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -96,6 +96,7 @@ class TestGatewayConfigRoundtrip: }, reset_triggers=["/new"], quick_commands={"limits": {"type": "exec", "command": "echo ok"}}, + group_sessions_per_user=False, ) d = config.to_dict() restored = GatewayConfig.from_dict(d) @@ -104,6 +105,7 @@ class TestGatewayConfigRoundtrip: assert restored.platforms[Platform.TELEGRAM].token == "tok_123" assert restored.reset_triggers == ["/new"] assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} + assert restored.group_sessions_per_user is False class TestLoadGatewayConfig: @@ -125,6 +127,18 @@ class TestLoadGatewayConfig: 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): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index e9e629fe6..e29a9583d 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -392,6 +392,31 @@ class TestWhatsAppDMSessionKeyConsistency: assert second_entry.session_key == "agent:main:discord:group:guild-123:bob" 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): """Non-WhatsApp DMs should also include chat_id to separate users.""" source = SessionSource( @@ -439,6 +464,23 @@ class TestWhatsAppDMSessionKeyConsistency: assert build_session_key(second) == "agent:main:discord:group:guild-123:bob" 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): """Forum-style threads need a distinct session key within one group.""" source = SessionSource( diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index b6922f381..8adec23f1 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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. +## 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 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. diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 8391715eb..656775de3 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -14,15 +14,71 @@ Before setup, here's the part most people want to know: how Hermes behaves once | 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. | | **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 -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. ## Step 1: Create a Discord Application @@ -175,13 +231,25 @@ Add the following to your `~/.hermes/.env` file: ```bash # Required -DISCORD_BOT_TOKEN=your-bot-token-from-developer-portal +DISCORD_BOT_TOKEN=your-bot-token DISCORD_ALLOWED_USERS=284102345871466496 # Multiple allowed users (comma-separated) # 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 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. +### 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 :::warning diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 1c238a67c..07d46af64 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -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: -| Chat Type | Key Format | Example | -|-----------|-----------|---------| -| Telegram DM | `agent:main:telegram:dm` | One session per bot | -| Discord DM | `agent:main:discord:dm` | One session per bot | -| WhatsApp DM | `agent:main:whatsapp:dm:` | Per-user (multi-user) | -| Group chat | `agent:main::group:` | Per-group | -| Channel | `agent:main::channel:` | Per-channel | +| Chat Type | Default Key Format | Behavior | +|-----------|--------------------|----------| +| Telegram DM | `agent:main:telegram:dm:` | One session per DM chat | +| Discord DM | `agent:main:discord:dm:` | One session per DM chat | +| WhatsApp DM | `agent:main:whatsapp:dm:` | One session per DM chat | +| Group chat | `agent:main::group::` | Per-user inside the group when the platform exposes a user ID | +| Group thread/topic | `agent:main::group:::` | Per-user inside that thread/topic | +| Channel | `agent:main::channel::` | Per-user inside the channel when the platform exposes a user ID | -:::info -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. -::: +When Hermes cannot get a participant identifier for a shared chat, it falls back to one shared session for that room. + +### 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