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:
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user