Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2543,6 +2543,9 @@ def build_source(
user_id_alt: Optional[str] = None,
chat_id_alt: Optional[str] = None,
is_bot: bool = False,
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
Expand All @@ -2560,6 +2563,9 @@ def build_source(
user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt,
is_bot=is_bot,
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
)

@abstractmethod
Expand Down
4 changes: 4 additions & 0 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:
thread = await self._auto_create_thread(message)
if thread:
parent_channel_id = str(message.channel.id)
is_thread = True
thread_id = str(thread.id)
auto_threaded_channel = thread
Expand Down Expand Up @@ -3320,6 +3321,9 @@ async def _handle_message(self, message: DiscordMessage) -> None:
thread_id=thread_id,
chat_topic=chat_topic,
is_bot=getattr(message.author, "bot", False),
guild_id=str(message.guild.id) if message.guild else None,
parent_chat_id=parent_channel_id,
message_id=str(message.id),
)

# Build media URLs -- download image attachments to local cache so the
Expand Down
74 changes: 65 additions & 9 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ class SessionSource:
user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id)
chat_id_alt: Optional[str] = None # Signal group internal ID
is_bot: bool = False # True when the message author is a bot/webhook (Discord)
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)

@property
def description(self) -> str:
Expand Down Expand Up @@ -124,8 +127,14 @@ def to_dict(self) -> Dict[str, Any]:
d["user_id_alt"] = self.user_id_alt
if self.chat_id_alt:
d["chat_id_alt"] = self.chat_id_alt
if self.guild_id:
d["guild_id"] = self.guild_id
if self.parent_chat_id:
d["parent_chat_id"] = self.parent_chat_id
if self.message_id:
d["message_id"] = self.message_id
return d

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
return cls(
Expand All @@ -139,6 +148,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
chat_topic=data.get("chat_topic"),
user_id_alt=data.get("user_id_alt"),
chat_id_alt=data.get("chat_id_alt"),
guild_id=data.get("guild_id"),
parent_chat_id=data.get("parent_chat_id"),
message_id=data.get("message_id"),
)


Expand Down Expand Up @@ -190,6 +202,31 @@ def to_dict(self) -> Dict[str, Any]:
and the LLM needs the real ID to tag users."""


def _discord_tools_loaded() -> bool:
"""True iff the agent will actually have Discord tools this session.

Two conditions must hold:
1. The `discord` or `discord_admin` toolset is enabled for the
Discord platform via `hermes tools` (opt-in, default OFF).
2. `DISCORD_BOT_TOKEN` is set — the tool's `check_fn` gates on it
at registry time, so the toolset being enabled in config is not
enough if the token isn't configured.

Returns False (safe default — keeps the stale-API disclaimer) on any
error so a bad config can't silently promise tools the agent lacks.
"""
if not (os.environ.get("DISCORD_BOT_TOKEN") or "").strip():
return False
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
cfg = load_config()
enabled = _get_platform_tools(cfg, "discord", include_default_mcp_servers=False)
return "discord" in enabled or "discord_admin" in enabled
except Exception:
return False


def build_session_context_prompt(
context: SessionContext,
*,
Expand Down Expand Up @@ -277,14 +314,33 @@ def build_session_context_prompt(
"that you can only read messages sent directly to you and respond."
)
elif context.source.platform == Platform.DISCORD:
lines.append("")
lines.append(
"**Platform notes:** You are running inside Discord. "
"You do NOT have access to Discord-specific APIs — you cannot search "
"channel history, pin messages, manage roles, or list server members. "
"Do not promise to perform these actions. If the user asks, explain "
"that you can only read messages sent directly to you and respond."
)
# Inject the Discord IDs block only when the agent actually has
# Discord tools loaded this session — i.e. the user opted into
# `discord` / `discord_admin` via `hermes tools` AND the bot
# token is configured. Otherwise keep the stale-API disclaimer
# honest so we never promise tools the agent lacks.
if _discord_tools_loaded():
src = context.source
id_lines = ["", "**Discord IDs (for the `discord` / `discord_admin` tools):**"]
if src.guild_id:
id_lines.append(f" - Guild: `{src.guild_id}`")
if src.thread_id and src.parent_chat_id:
id_lines.append(f" - Parent channel: `{src.parent_chat_id}`")
id_lines.append(f" - Thread: `{src.thread_id}` (use as `channel_id` for fetch_messages etc.)")
else:
id_lines.append(f" - Channel: `{src.chat_id}`")
if src.message_id:
id_lines.append(f" - Triggering message: `{src.message_id}`")
lines.extend(id_lines)
else:
lines.append("")
lines.append(
"**Platform notes:** You are running inside Discord. "
"You do NOT have access to Discord-specific APIs — you cannot search "
"channel history, pin messages, manage roles, or list server members. "
"Do not promise to perform these actions. If the user asks, explain "
"that you can only read messages sent directly to you and respond."
)
elif context.source.platform == Platform.BLUEBUBBLES:
lines.append("")
lines.append(
Expand Down
77 changes: 70 additions & 7 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,35 @@
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
("homeassistant", "🏠 Home Assistant", "smart home device control"),
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
]

# Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
# but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord_admin"}
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin"}

# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
# absent from this map is available on every platform (current behaviour).
#
# Use this for tools whose APIs only make sense on one platform (Discord
# server admin, Slack workspace admin, etc.). Keeps every other platform's
# checklist from filling up with irrelevant toggles.
_TOOLSET_PLATFORM_RESTRICTIONS: Dict[str, Set[str]] = {
"discord": {"discord"},
"discord_admin": {"discord"},
}


def _toolset_allowed_for_platform(ts_key: str, platform: str) -> bool:
"""Return True if ``ts_key`` is configurable on ``platform``.

Toolsets without a restriction entry are allowed everywhere (the default).
"""
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key)
return allowed is None or platform in allowed


def _get_effective_configurable_toolsets():
Expand Down Expand Up @@ -617,7 +639,10 @@ def _get_platform_tools(
has_explicit_config = any(ts in configurable_keys for ts in toolset_names)

if has_explicit_config:
enabled_toolsets = {ts for ts in toolset_names if ts in configurable_keys}
enabled_toolsets = {
ts for ts in toolset_names
if ts in configurable_keys and _toolset_allowed_for_platform(ts, platform)
}
else:
# No explicit config — fall back to resolving composite toolset names
# (e.g. "hermes-cli") to individual tool names and reverse-mapping.
Expand All @@ -627,12 +652,19 @@ def _get_platform_tools(

enabled_toolsets = set()
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
if not _toolset_allowed_for_platform(ts_key, platform):
continue
ts_tools = set(resolve_toolset(ts_key))
if ts_tools and ts_tools.issubset(all_tool_names):
enabled_toolsets.add(ts_key)

default_off = set(_DEFAULT_OFF_TOOLSETS)
if platform in default_off:
# Legacy safety: if the platform's own name matches a default-off
# toolset (e.g. `homeassistant` platform + `homeassistant` toolset),
# keep that toolset enabled on first install. Skip this dodge for
# platform-restricted toolsets — those are always opt-in even on
# their own platform (e.g. `discord` + `discord` should stay OFF).
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
default_off.remove(platform)
enabled_toolsets -= default_off

Expand Down Expand Up @@ -735,6 +767,14 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
"""
config.setdefault("platform_toolsets", {})

# Drop platform-scoped toolsets that don't apply here. Prevents the
# "Configure all platforms" checklist (or a hand-edited config.yaml)
# from turning on, say, the `discord` toolset for Telegram.
enabled_toolset_keys = {
ts for ts in enabled_toolset_keys
if _toolset_allowed_for_platform(ts, platform)
}

# Get the set of all configurable toolset keys (built-in + plugin)
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
plugin_keys = _get_plugin_toolset_keys()
Expand Down Expand Up @@ -869,15 +909,20 @@ def _estimate_tool_tokens() -> Dict[str, int]:
return _tool_token_cache


def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]:
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: str = "cli") -> Set[str]:
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
from hermes_cli.curses_ui import curses_checklist
from toolsets import resolve_toolset

# Pre-compute per-tool token counts (cached after first call).
tool_tokens = _estimate_tool_tokens()

effective = _get_effective_configurable_toolsets()
effective_all = _get_effective_configurable_toolsets()
# Drop platform-scoped toolsets that don't apply to this platform.
effective = [
(k, l, d) for (k, l, d) in effective_all
if _toolset_allowed_for_platform(k, platform)
]

labels = []
for ts_key, ts_label, ts_desc in effective:
Expand Down Expand Up @@ -1791,7 +1836,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS

# Show checklist
new_enabled = _prompt_toolset_checklist(pinfo["label"], checklist_preselected)
new_enabled = _prompt_toolset_checklist(pinfo["label"], checklist_preselected, pkey)

added = new_enabled - current_enabled
removed = current_enabled - new_enabled
Expand Down Expand Up @@ -2147,7 +2192,11 @@ def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]

def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"):
"""Print a summary of enabled/disabled toolsets and MCP tool filters."""
effective = _get_effective_configurable_toolsets()
effective_all = _get_effective_configurable_toolsets()
effective = [
(k, l, d) for (k, l, d) in effective_all
if _toolset_allowed_for_platform(k, platform)
]
builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}

print(f"Built-in toolsets ({platform}):")
Expand Down Expand Up @@ -2213,6 +2262,20 @@ def tools_disable_enable_command(args):
_print_error(f"Unknown toolset '{name}'")
toolset_targets = [t for t in toolset_targets if t in valid_toolsets]

# Reject platform-scoped toolsets on platforms that don't allow them.
restricted_targets = [
t for t in toolset_targets
if not _toolset_allowed_for_platform(t, platform)
]
if restricted_targets:
for name in restricted_targets:
allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set())
_print_error(
f"Toolset '{name}' is not available on platform '{platform}' "
f"(only: {', '.join(allowed)})"
)
toolset_targets = [t for t in toolset_targets if t not in restricted_targets]

if toolset_targets:
_apply_toolset_change(config, platform, toolset_targets, action)

Expand Down
52 changes: 47 additions & 5 deletions tests/hermes_cli/test_tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,20 +699,62 @@ def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets():
assert "search" not in enabled


def test_get_platform_tools_discord_includes_discord_not_admin():
def test_get_platform_tools_discord_both_off_by_default():
"""Both `discord` and `discord_admin` are opt-in via `hermes tools`,
even on the Discord platform itself. Users shouldn't auto-inherit 19
extra tools just because DISCORD_BOT_TOKEN is set."""
enabled = _get_platform_tools({}, "discord")
assert "discord" in enabled
assert "discord" not in enabled
assert "discord_admin" not in enabled


def test_discord_admin_in_configurable_toolsets():
assert any(ts_key == "discord_admin" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
def test_discord_toolsets_in_configurable_toolsets():
keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
assert "discord" in keys
assert "discord_admin" in keys


def test_discord_admin_in_default_off():
def test_discord_toolsets_in_default_off():
assert "discord" in _DEFAULT_OFF_TOOLSETS
assert "discord_admin" in _DEFAULT_OFF_TOOLSETS


def test_discord_toolsets_not_available_on_other_platforms():
"""Platform-scoping: discord / discord_admin should not appear on CLI,
Telegram, etc. — not even as an opt-in."""
from hermes_cli.tools_config import _toolset_allowed_for_platform
for plat in ["cli", "telegram", "slack", "whatsapp", "signal"]:
assert not _toolset_allowed_for_platform("discord", plat), (
f"`discord` toolset leaked onto {plat}"
)
assert not _toolset_allowed_for_platform("discord_admin", plat), (
f"`discord_admin` toolset leaked onto {plat}"
)
assert _toolset_allowed_for_platform("discord", "discord")
assert _toolset_allowed_for_platform("discord_admin", "discord")


def test_discord_toolsets_user_enabled_are_honored():
"""When the user opts in via `hermes tools`, the toolset appears."""
config = {"platform_toolsets": {"discord": ["web", "terminal", "discord"]}}
enabled = _get_platform_tools(config, "discord")
assert "discord" in enabled
assert "discord_admin" not in enabled


def test_save_platform_tools_strips_restricted_toolsets():
"""Hand-edited or all-platforms checklist with `discord` selected for
Telegram must be stripped at save time."""
from hermes_cli.tools_config import _save_platform_tools
config = {}
_save_platform_tools(config, "telegram", {"web", "terminal", "discord", "discord_admin"})
saved = config["platform_toolsets"]["telegram"]
assert "discord" not in saved
assert "discord_admin" not in saved
assert "web" in saved
assert "terminal" in saved


def test_get_platform_tools_feishu_includes_doc_and_drive():
enabled = _get_platform_tools({}, "feishu")
assert "feishu_doc" in enabled
Expand Down
Loading