Compare commits

...

8 Commits

Author SHA1 Message Date
alt-glitch
7efd91d4b4 feat(session): inject Discord IDs block when discord tool is loaded
When DISCORD_BOT_TOKEN is set — meaning the discord tool actually
loads — emit a dedicated IDs block in the session context prompt so
the agent can call ``fetch_messages``, ``pin_message``, etc. with
real identifiers instead of probing.

Currently only ``thread_id`` was exposed as a raw ID (via the
``description`` string).  The agent in a Discord thread had to guess
that the thread ID doubles as a channel ID for the REST API (it
does), and it had no way to reference the parent channel, the guild,
or the triggering message at all.

The block adapts to context:

  - Thread:     guild / parent channel / thread / message
  - Channel:    guild / channel / message
  - (DM has no guild/channel IDs worth listing; only message)

Discord isn't in _PII_SAFE_PLATFORMS, so IDs ship unredacted.
2026-04-25 05:43:23 +05:30
alt-glitch
0aa1269e56 fix(session): gate stale "no Discord APIs" note on DISCORD_BOT_TOKEN
The Discord platform note in the session context prompt claimed the
agent has no server-management APIs — pre-dating the discord tool.
With a bot token configured the agent actually has fetch_messages,
search_members, create_thread, and optionally the discord_admin tool;
telling the model otherwise causes it to refuse or apologise for
calls it is fully able to make.

Gate the disclaimer on DISCORD_BOT_TOKEN being unset, matching the
tool's own ``check_fn``.  Without a token the note still appears and
remains accurate; with a token the model is no longer gaslit into
refusing valid tool calls.
2026-04-25 05:43:23 +05:30
alt-glitch
3c29834354 feat(discord): populate guild_id, parent_chat_id, message_id on SessionSource
Discord knows all four identifiers for every inbound message — guild,
channel (or thread), parent channel when in a thread, and the
triggering message.  Pass them into ``SessionSource`` via the new
``build_source()`` kwargs so downstream code (context-prompt builder,
delivery, logging) can use them without re-resolving from discord.py
objects.

For auto-threaded messages, remember the original channel as the
parent before swapping ``chat_id`` to the freshly created thread.

Behavioural: still a no-op — nothing consumes these fields yet.
2026-04-25 05:43:23 +05:30
alt-glitch
0eb85906b0 feat(session): add guild_id/parent_chat_id/message_id to SessionSource
Groundwork for injecting raw platform identifiers into the agent's
system prompt.  Currently only `thread_id` is exposed as a raw ID —
callers in a Discord thread had to guess `channel_id == thread_id`
(which happens to work because threads are channels in Discord's REST
API) and had no way to reference the parent channel, guild, or the
triggering message.

Adds three optional fields:

- `guild_id` — Discord guild / Slack workspace / Matrix server scope
- `parent_chat_id` — parent channel when chat_id refers to a thread
- `message_id` — ID of the triggering message (pin/reply/react)

Extends `BasePlatformAdapter.build_source()` to accept + forward them
and teaches `to_dict`/`from_dict` to serialize them.  Behaviourally a
no-op: nothing reads the fields yet and they default to None.
2026-04-25 05:43:23 +05:30
alt-glitch
ff9b0528a2 fix(tools): normalize numeric entries and clear stale no_mcp in _save_platform_tools
YAML parses bare numeric toolset names (e.g. 12306:) as int, causing
TypeError in sorted() since the read path normalizes to str but the
save path did not.

The no_mcp sentinel was preserved in existing entries even when the
user re-enabled MCP servers, causing MCP to stay silently disabled.
2026-04-25 05:43:23 +05:30
alt-glitch
8feaa7cd1b feat(feishu): wire feishu doc/drive tools into hermes-feishu composite
The feishu_doc and feishu_drive tools were registered in the tool
registry but never added to the hermes-feishu composite toolset.
The pipeline fix from the prior commit now recovers them automatically
once they are in the composite.
2026-04-25 05:43:23 +05:30
alt-glitch
57a2b97ae8 feat(discord): split discord_server into discord + discord_admin tools
Split the monolithic discord_server tool (14 actions) into two:

- discord: core actions (fetch_messages, search_members, create_thread)
  that are useful for the agent's normal operation. Auto-enabled on
  the discord platform via the pipeline fix.

- discord_admin: server management actions (list channels/roles, pins,
  role assignment) that require explicit opt-in via hermes tools.
  Added to CONFIGURABLE_TOOLSETS and _DEFAULT_OFF_TOOLSETS.
2026-04-25 05:43:23 +05:30
alt-glitch
bd9afb027a fix(tools): recover non-configurable toolsets from composite resolution
The reverse-mapping loop in _get_platform_tools only checked
CONFIGURABLE_TOOLSETS, silently dropping platform-specific toolsets
like discord and feishu_doc whose tools were in the composite but
had no configurable key. Add a second pass over TOOLSETS that picks
up unclaimed toolsets whose tools are present in the resolved
composite.
2026-04-25 05:43:23 +05:30
11 changed files with 554 additions and 202 deletions

View File

@@ -2440,6 +2440,9 @@ class BasePlatformAdapter(ABC):
user_id_alt: Optional[str] = None, user_id_alt: Optional[str] = None,
chat_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None,
is_bot: bool = False, is_bot: bool = False,
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
) -> SessionSource: ) -> SessionSource:
"""Helper to build a SessionSource for this platform.""" """Helper to build a SessionSource for this platform."""
# Normalize empty topic to None # Normalize empty topic to None
@@ -2457,6 +2460,9 @@ class BasePlatformAdapter(ABC):
user_id_alt=user_id_alt, user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt, chat_id_alt=chat_id_alt,
is_bot=is_bot, 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 @abstractmethod

View File

@@ -3256,6 +3256,7 @@ class DiscordAdapter(BasePlatformAdapter):
if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: 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) thread = await self._auto_create_thread(message)
if thread: if thread:
parent_channel_id = str(message.channel.id)
is_thread = True is_thread = True
thread_id = str(thread.id) thread_id = str(thread.id)
auto_threaded_channel = thread auto_threaded_channel = thread
@@ -3315,6 +3316,9 @@ class DiscordAdapter(BasePlatformAdapter):
thread_id=thread_id, thread_id=thread_id,
chat_topic=chat_topic, chat_topic=chat_topic,
is_bot=getattr(message.author, "bot", False), 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 # Build media URLs -- download image attachments to local cache so the

View File

@@ -83,6 +83,9 @@ class SessionSource:
user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id) 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 chat_id_alt: Optional[str] = None # Signal group internal ID
is_bot: bool = False # True when the message author is a bot/webhook (Discord) 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 @property
def description(self) -> str: def description(self) -> str:
@@ -120,8 +123,14 @@ class SessionSource:
d["user_id_alt"] = self.user_id_alt d["user_id_alt"] = self.user_id_alt
if self.chat_id_alt: if self.chat_id_alt:
d["chat_id_alt"] = 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 return d
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
return cls( return cls(
@@ -135,6 +144,9 @@ class SessionSource:
chat_topic=data.get("chat_topic"), chat_topic=data.get("chat_topic"),
user_id_alt=data.get("user_id_alt"), user_id_alt=data.get("user_id_alt"),
chat_id_alt=data.get("chat_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"),
) )
@@ -273,14 +285,34 @@ def build_session_context_prompt(
"that you can only read messages sent directly to you and respond." "that you can only read messages sent directly to you and respond."
) )
elif context.source.platform == Platform.DISCORD: elif context.source.platform == Platform.DISCORD:
lines.append("") # The discord tool self-gates on DISCORD_BOT_TOKEN at registry
lines.append( # check time. Match that condition so the prompt stays honest:
"**Platform notes:** You are running inside Discord. " # with a token the agent has fetch_messages/search_members/
"You do NOT have access to Discord-specific APIs — you cannot search " # create_thread (and optionally discord_admin) and should know
"channel history, pin messages, manage roles, or list server members. " # the IDs it can call them with; without one it really is
"Do not promise to perform these actions. If the user asks, explain " # limited to reading/replying via the gateway.
"that you can only read messages sent directly to you and respond." if (os.environ.get("DISCORD_BOT_TOKEN") or "").strip():
) 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."
)
# Connected platforms # Connected platforms
platforms_list = ["local (files on this machine)"] platforms_list = ["local (files on this machine)"]

View File

@@ -833,7 +833,7 @@ DEFAULT_CONFIG = {
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing "reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# discord_server tool: restrict which actions the agent may call. # discord / discord_admin tools: restrict which actions the agent may call.
# Default (empty) = all actions allowed (subject to bot privileged intents). # Default (empty) = all actions allowed (subject to bot privileged intents).
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages") # Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
# or YAML list. Unknown names are dropped with a warning at load time. # or YAML list. Unknown names are dropped with a warning at load time.

View File

@@ -67,12 +67,13 @@ CONFIGURABLE_TOOLSETS = [
("messaging", "📨 Cross-Platform Messaging", "send_message"), ("messaging", "📨 Cross-Platform Messaging", "send_message"),
("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
("homeassistant", "🏠 Home Assistant", "smart home device control"), ("homeassistant", "🏠 Home Assistant", "smart home device control"),
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
] ]
# Toolsets that are OFF by default for new installs. # Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # 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. # but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl"} _DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "discord_admin"}
def _get_effective_configurable_toolsets(): def _get_effective_configurable_toolsets():
@@ -549,7 +550,7 @@ def _get_platform_tools(
include_default_mcp_servers: bool = True, include_default_mcp_servers: bool = True,
) -> Set[str]: ) -> Set[str]:
"""Resolve which individual toolset names are enabled for a platform.""" """Resolve which individual toolset names are enabled for a platform."""
from toolsets import resolve_toolset from toolsets import resolve_toolset, TOOLSETS
platform_toolsets = config.get("platform_toolsets") or {} platform_toolsets = config.get("platform_toolsets") or {}
toolset_names = platform_toolsets.get(platform) toolset_names = platform_toolsets.get(platform)
@@ -563,6 +564,8 @@ def _get_platform_tools(
toolset_names = [str(ts) for ts in toolset_names] toolset_names = [str(ts) for ts in toolset_names]
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
plugin_ts_keys = _get_plugin_toolset_keys()
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
# If the saved list contains any configurable keys directly, the user # If the saved list contains any configurable keys directly, the user
# has explicitly configured this platform — use direct membership. # has explicitly configured this platform — use direct membership.
@@ -585,16 +588,46 @@ def _get_platform_tools(
ts_tools = set(resolve_toolset(ts_key)) ts_tools = set(resolve_toolset(ts_key))
if ts_tools and ts_tools.issubset(all_tool_names): if ts_tools and ts_tools.issubset(all_tool_names):
enabled_toolsets.add(ts_key) enabled_toolsets.add(ts_key)
default_off = set(_DEFAULT_OFF_TOOLSETS) default_off = set(_DEFAULT_OFF_TOOLSETS)
if platform in default_off: if platform in default_off:
default_off.remove(platform) default_off.remove(platform)
enabled_toolsets -= default_off enabled_toolsets -= default_off
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
# feishu_drive). These are part of the platform's default composite but
# absent from CONFIGURABLE_TOOLSETS, so they can't appear in the TUI
# checklist or in a user-saved config. Must run in BOTH branches —
# otherwise saving via `hermes tools` (which flips has_explicit_config
# to True) silently drops them.
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
configurable_tool_universe = set()
for ck in configurable_keys:
configurable_tool_universe.update(resolve_toolset(ck))
claimed = set()
for ts_key in enabled_toolsets:
claimed.update(resolve_toolset(ts_key))
skip = configurable_keys | plugin_ts_keys | platform_default_keys
skip |= {k for k in TOOLSETS if k.startswith("hermes-")}
skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform}
for ts_key, ts_def in TOOLSETS.items():
if ts_key in skip:
continue
if ts_def.get("includes"):
continue
ts_tools = set(resolve_toolset(ts_key))
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
continue
if ts_tools.issubset(configurable_tool_universe):
continue
if not ts_tools.issubset(claimed):
enabled_toolsets.add(ts_key)
claimed.update(ts_tools)
# Plugin toolsets: enabled by default unless explicitly disabled. # Plugin toolsets: enabled by default unless explicitly disabled.
# A plugin toolset is "known" for a platform once `hermes tools` # A plugin toolset is "known" for a platform once `hermes tools`
# has been saved for that platform (tracked via known_plugin_toolsets). # has been saved for that platform (tracked via known_plugin_toolsets).
# Unknown plugins default to enabled; known-but-absent = disabled. # Unknown plugins default to enabled; known-but-absent = disabled.
plugin_ts_keys = _get_plugin_toolset_keys()
if plugin_ts_keys: if plugin_ts_keys:
known_map = config.get("known_plugin_toolsets", {}) known_map = config.get("known_plugin_toolsets", {})
known_for_platform = set(known_map.get(platform, [])) known_for_platform = set(known_map.get(platform, []))
@@ -609,7 +642,6 @@ def _get_platform_tools(
# Preserve any explicit non-configurable toolset entries (for example, # Preserve any explicit non-configurable toolset entries (for example,
# custom toolsets or MCP server names saved in platform_toolsets). # custom toolsets or MCP server names saved in platform_toolsets).
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
explicit_passthrough = { explicit_passthrough = {
ts ts
for ts in toolset_names for ts in toolset_names
@@ -669,6 +701,7 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
existing_toolsets = config.get("platform_toolsets", {}).get(platform, []) existing_toolsets = config.get("platform_toolsets", {}).get(platform, [])
if not isinstance(existing_toolsets, list): if not isinstance(existing_toolsets, list):
existing_toolsets = [] existing_toolsets = []
existing_toolsets = [str(ts) for ts in existing_toolsets]
# Preserve any entries that are NOT configurable toolsets and NOT platform # Preserve any entries that are NOT configurable toolsets and NOT platform
# defaults (i.e. only MCP server names should be preserved) # defaults (i.e. only MCP server names should be preserved)
@@ -676,6 +709,8 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
entry for entry in existing_toolsets entry for entry in existing_toolsets
if entry not in configurable_keys and entry not in platform_default_keys if entry not in configurable_keys and entry not in platform_default_keys
} }
if "no_mcp" not in enabled_toolset_keys:
preserved_entries.discard("no_mcp")
# Merge preserved entries with new enabled toolsets # Merge preserved entries with new enabled toolsets
config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries)

View File

@@ -288,30 +288,34 @@ def get_tool_definitions(
filtered_tools[i] = {"type": "function", "function": dynamic_schema} filtered_tools[i] = {"type": "function", "function": dynamic_schema}
break break
# Rebuild discord_server schema based on the bot's privileged intents # Rebuild discord / discord_admin schemas based on the bot's privileged
# (detected from GET /applications/@me) and the user's action allowlist # intents (detected from GET /applications/@me) and the user's action
# in config. Hides actions the bot's intents don't support so the # allowlist in config. Hides actions the bot's intents don't support so
# model never attempts them, and annotates fetch_messages when the # the model never attempts them, and annotates fetch_messages when the
# MESSAGE_CONTENT intent is missing. # MESSAGE_CONTENT intent is missing.
if "discord_server" in available_tool_names: _discord_schema_fns = {
try: "discord": "get_dynamic_schema_core",
from tools.discord_tool import get_dynamic_schema "discord_admin": "get_dynamic_schema_admin",
dynamic = get_dynamic_schema() }
except Exception: # pragma: no cover — defensive, fall back to static for discord_tool_name in _discord_schema_fns:
dynamic = None if discord_tool_name in available_tool_names:
if dynamic is None: try:
# Tool filtered out entirely (empty allowlist or detection disabled from tools import discord_tool as _dt
# the only remaining actions). Drop it from the schema list. schema_fn = getattr(_dt, _discord_schema_fns[discord_tool_name])
filtered_tools = [ dynamic = schema_fn()
t for t in filtered_tools except Exception:
if t.get("function", {}).get("name") != "discord_server" dynamic = None
] if dynamic is None:
available_tool_names.discard("discord_server") filtered_tools = [
else: t for t in filtered_tools
for i, td in enumerate(filtered_tools): if t.get("function", {}).get("name") != discord_tool_name
if td.get("function", {}).get("name") == "discord_server": ]
filtered_tools[i] = {"type": "function", "function": dynamic} available_tool_names.discard(discord_tool_name)
break else:
for i, td in enumerate(filtered_tools):
if td.get("function", {}).get("name") == discord_tool_name:
filtered_tools[i] = {"type": "function", "function": dynamic}
break
# Strip web tool cross-references from browser_navigate description when # Strip web tool cross-references from browser_navigate description when
# web_search / web_extract are not available. The static schema says # web_search / web_extract are not available. The static schema says

View File

@@ -601,3 +601,122 @@ class TestImagegenModelPicker:
_configure_imagegen_model("fal", config) _configure_imagegen_model("fal", config)
assert isinstance(config["image_gen"], dict) assert isinstance(config["image_gen"], dict)
assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b" assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b"
def test_get_platform_tools_recovers_non_configurable_toolsets_from_composite():
"""Non-configurable toolsets whose tools are in the composite but not in
CONFIGURABLE_TOOLSETS should still appear in the result.
"""
from toolsets import TOOLSETS
from hermes_cli.tools_config import PLATFORMS
from unittest.mock import patch as mock_patch
fake_toolsets = dict(TOOLSETS)
fake_toolsets["_test_platform_tool"] = {
"description": "test",
"tools": ["_test_special_tool"],
"includes": [],
}
fake_toolsets["hermes-_test_platform"] = {
"description": "test composite",
"tools": ["web_search", "web_extract", "terminal", "process", "_test_special_tool"],
"includes": [],
}
test_platforms = {
"_test_platform": {"label": "Test", "default_toolset": "hermes-_test_platform"},
}
with mock_patch("hermes_cli.tools_config.PLATFORMS", {**PLATFORMS, **test_platforms}):
with mock_patch("toolsets.TOOLSETS", fake_toolsets):
enabled = _get_platform_tools({}, "_test_platform")
assert "_test_platform_tool" in enabled
assert "web" in enabled
assert "terminal" in enabled
def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets():
"""Toolsets whose tools are fully covered by configurable keys should NOT
be added by the second pass (prevents 'search', 'hermes-acp' noise).
"""
enabled = _get_platform_tools({}, "cli")
assert "search" not in enabled
def test_get_platform_tools_discord_includes_discord_not_admin():
enabled = _get_platform_tools({}, "discord")
assert "discord" 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_admin_in_default_off():
assert "discord_admin" in _DEFAULT_OFF_TOOLSETS
def test_get_platform_tools_feishu_includes_doc_and_drive():
enabled = _get_platform_tools({}, "feishu")
assert "feishu_doc" in enabled
assert "feishu_drive" in enabled
def test_get_platform_tools_feishu_tools_not_on_other_platforms():
for plat in ["cli", "telegram", "discord"]:
enabled = _get_platform_tools({}, plat)
assert "feishu_doc" not in enabled, f"feishu_doc leaked onto {plat}"
assert "feishu_drive" not in enabled, f"feishu_drive leaked onto {plat}"
def test_save_platform_tools_normalizes_numeric_entries():
"""YAML may parse bare numeric toolset names as int. They should be
normalized to str so they survive the save round-trip.
"""
config = {
"platform_toolsets": {
"cli": ["web", "terminal", 12306, "custom-mcp"]
}
}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", {"web", "browser"})
saved = config["platform_toolsets"]["cli"]
assert "12306" in saved
assert 12306 not in saved
def test_save_platform_tools_clears_stale_no_mcp():
"""When the new selection doesn't include no_mcp, the sentinel should
be stripped from preserved entries so MCP servers are re-enabled.
"""
config = {
"platform_toolsets": {
"cli": ["web", "terminal", "no_mcp"]
}
}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", {"web", "browser"})
saved = config["platform_toolsets"]["cli"]
assert "no_mcp" not in saved
def test_save_platform_tools_preserves_explicit_no_mcp():
"""When the new selection explicitly includes no_mcp, it should be kept."""
config = {
"platform_toolsets": {
"cli": ["web", "no_mcp"]
}
}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", {"web", "no_mcp"})
saved = config["platform_toolsets"]["cli"]
assert "no_mcp" in saved

View File

@@ -200,8 +200,8 @@ class TestToolsetConsistency:
def test_hermes_platforms_share_core_tools(self): def test_hermes_platforms_share_core_tools(self):
"""All hermes-* platform toolsets share the same core tools. """All hermes-* platform toolsets share the same core tools.
Platform-specific additions (e.g. ``discord_server`` on Platform-specific additions (e.g. ``discord`` / ``discord_admin``
hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top — on hermes-discord, gated on DISCORD_BOT_TOKEN) are allowed on top —
the invariant is that the core set is identical across platforms. the invariant is that the core set is identical across platforms.
""" """
platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] platforms = ["hermes-cli", "hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"]

View File

@@ -11,6 +11,8 @@ import pytest
from tools.discord_tool import ( from tools.discord_tool import (
DiscordAPIError, DiscordAPIError,
_ACTIONS, _ACTIONS,
_ADMIN_ACTIONS,
_CORE_ACTIONS,
_available_actions, _available_actions,
_build_schema, _build_schema,
_channel_type_name, _channel_type_name,
@@ -21,8 +23,11 @@ from tools.discord_tool import (
_load_allowed_actions_config, _load_allowed_actions_config,
_reset_capability_cache, _reset_capability_cache,
check_discord_tool_requirements, check_discord_tool_requirements,
discord_server, discord_admin_handler,
discord_core,
get_dynamic_schema, get_dynamic_schema,
get_dynamic_schema_admin,
get_dynamic_schema_core,
) )
@@ -147,32 +152,32 @@ class TestDiscordRequest:
class TestDiscordServerValidation: class TestDiscordServerValidation:
def test_no_token(self, monkeypatch): def test_no_token(self, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False) monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert "error" in result assert "error" in result
assert "DISCORD_BOT_TOKEN" in result["error"] assert "DISCORD_BOT_TOKEN" in result["error"]
def test_unknown_action(self, monkeypatch): def test_unknown_action(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="bad_action")) result = json.loads(discord_core(action="bad_action"))
assert "error" in result assert "error" in result
assert "Unknown action" in result["error"] assert "Unknown action" in result["error"]
assert "available_actions" in result assert "available_actions" in result
def test_missing_required_guild_id(self, monkeypatch): def test_missing_required_guild_id(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="list_channels")) result = json.loads(discord_admin_handler(action="list_channels"))
assert "error" in result assert "error" in result
assert "guild_id" in result["error"] assert "guild_id" in result["error"]
def test_missing_required_channel_id(self, monkeypatch): def test_missing_required_channel_id(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="fetch_messages")) result = json.loads(discord_core(action="fetch_messages"))
assert "error" in result assert "error" in result
assert "channel_id" in result["error"] assert "channel_id" in result["error"]
def test_missing_multiple_params(self, monkeypatch): def test_missing_multiple_params(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="add_role")) result = json.loads(discord_admin_handler(action="add_role"))
assert "error" in result assert "error" in result
assert "guild_id" in result["error"] assert "guild_id" in result["error"]
assert "user_id" in result["error"] assert "user_id" in result["error"]
@@ -191,7 +196,7 @@ class TestListGuilds:
{"id": "111", "name": "Test Server", "icon": "abc", "owner": True, "permissions": "123"}, {"id": "111", "name": "Test Server", "icon": "abc", "owner": True, "permissions": "123"},
{"id": "222", "name": "Other Server", "icon": None, "owner": False, "permissions": "456"}, {"id": "222", "name": "Other Server", "icon": None, "owner": False, "permissions": "456"},
] ]
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert result["count"] == 2 assert result["count"] == 2
assert result["guilds"][0]["name"] == "Test Server" assert result["guilds"][0]["name"] == "Test Server"
assert result["guilds"][1]["id"] == "222" assert result["guilds"][1]["id"] == "222"
@@ -219,7 +224,7 @@ class TestServerInfo:
"premium_subscription_count": 5, "premium_subscription_count": 5,
"verification_level": 1, "verification_level": 1,
} }
result = json.loads(discord_server(action="server_info", guild_id="111")) result = json.loads(discord_admin_handler(action="server_info", guild_id="111"))
assert result["name"] == "My Server" assert result["name"] == "My Server"
assert result["member_count"] == 42 assert result["member_count"] == 42
assert result["online_count"] == 10 assert result["online_count"] == 10
@@ -242,7 +247,7 @@ class TestListChannels:
{"id": "12", "name": "voice", "type": 2, "position": 1, "parent_id": "10", "topic": None, "nsfw": False}, {"id": "12", "name": "voice", "type": 2, "position": 1, "parent_id": "10", "topic": None, "nsfw": False},
{"id": "13", "name": "no-category", "type": 0, "position": 0, "parent_id": None, "topic": None, "nsfw": False}, {"id": "13", "name": "no-category", "type": 0, "position": 0, "parent_id": None, "topic": None, "nsfw": False},
] ]
result = json.loads(discord_server(action="list_channels", guild_id="111")) result = json.loads(discord_admin_handler(action="list_channels", guild_id="111"))
assert result["total_channels"] == 3 # excludes the category itself assert result["total_channels"] == 3 # excludes the category itself
groups = result["channel_groups"] groups = result["channel_groups"]
# Uncategorized first # Uncategorized first
@@ -257,7 +262,7 @@ class TestListChannels:
def test_empty_guild(self, mock_req, monkeypatch): def test_empty_guild(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [] mock_req.return_value = []
result = json.loads(discord_server(action="list_channels", guild_id="111")) result = json.loads(discord_admin_handler(action="list_channels", guild_id="111"))
assert result["total_channels"] == 0 assert result["total_channels"] == 0
@@ -274,7 +279,7 @@ class TestChannelInfo:
"topic": "Welcome!", "nsfw": False, "position": 0, "topic": "Welcome!", "nsfw": False, "position": 0,
"parent_id": "10", "rate_limit_per_user": 0, "last_message_id": "999", "parent_id": "10", "rate_limit_per_user": 0, "last_message_id": "999",
} }
result = json.loads(discord_server(action="channel_info", channel_id="11")) result = json.loads(discord_admin_handler(action="channel_info", channel_id="11"))
assert result["name"] == "general" assert result["name"] == "general"
assert result["type"] == "text" assert result["type"] == "text"
assert result["guild_id"] == "111" assert result["guild_id"] == "111"
@@ -293,7 +298,7 @@ class TestListRoles:
{"id": "2", "name": "Admin", "position": 2, "color": 16711680, "mentionable": True, "managed": False, "hoist": True}, {"id": "2", "name": "Admin", "position": 2, "color": 16711680, "mentionable": True, "managed": False, "hoist": True},
{"id": "3", "name": "Mod", "position": 1, "color": 255, "mentionable": True, "managed": False, "hoist": True}, {"id": "3", "name": "Mod", "position": 1, "color": 255, "mentionable": True, "managed": False, "hoist": True},
] ]
result = json.loads(discord_server(action="list_roles", guild_id="111")) result = json.loads(discord_admin_handler(action="list_roles", guild_id="111"))
assert result["count"] == 3 assert result["count"] == 3
# Should be sorted by position descending # Should be sorted by position descending
assert result["roles"][0]["name"] == "Admin" assert result["roles"][0]["name"] == "Admin"
@@ -317,7 +322,7 @@ class TestMemberInfo:
"joined_at": "2024-01-01T00:00:00Z", "joined_at": "2024-01-01T00:00:00Z",
"premium_since": None, "premium_since": None,
} }
result = json.loads(discord_server(action="member_info", guild_id="111", user_id="42")) result = json.loads(discord_admin_handler(action="member_info", guild_id="111", user_id="42"))
assert result["username"] == "testuser" assert result["username"] == "testuser"
assert result["nickname"] == "Testy" assert result["nickname"] == "Testy"
assert result["roles"] == ["2", "3"] assert result["roles"] == ["2", "3"]
@@ -334,7 +339,7 @@ class TestSearchMembers:
mock_req.return_value = [ mock_req.return_value = [
{"user": {"id": "42", "username": "testuser", "global_name": "Test", "bot": False}, "nick": None, "roles": []}, {"user": {"id": "42", "username": "testuser", "global_name": "Test", "bot": False}, "nick": None, "roles": []},
] ]
result = json.loads(discord_server(action="search_members", guild_id="111", query="test")) result = json.loads(discord_core(action="search_members", guild_id="111", query="test"))
assert result["count"] == 1 assert result["count"] == 1
assert result["members"][0]["username"] == "testuser" assert result["members"][0]["username"] == "testuser"
mock_req.assert_called_once_with( mock_req.assert_called_once_with(
@@ -346,7 +351,7 @@ class TestSearchMembers:
def test_search_members_limit_capped(self, mock_req, monkeypatch): def test_search_members_limit_capped(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [] mock_req.return_value = []
discord_server(action="search_members", guild_id="111", query="x", limit=200) discord_core(action="search_members", guild_id="111", query="x", limit=200)
call_params = mock_req.call_args[1]["params"] call_params = mock_req.call_args[1]["params"]
assert call_params["limit"] == "100" # Capped at 100 assert call_params["limit"] == "100" # Capped at 100
@@ -370,7 +375,7 @@ class TestFetchMessages:
"pinned": False, "pinned": False,
}, },
] ]
result = json.loads(discord_server(action="fetch_messages", channel_id="11")) result = json.loads(discord_core(action="fetch_messages", channel_id="11"))
assert result["count"] == 1 assert result["count"] == 1
assert result["messages"][0]["content"] == "Hello world" assert result["messages"][0]["content"] == "Hello world"
assert result["messages"][0]["author"]["username"] == "user1" assert result["messages"][0]["author"]["username"] == "user1"
@@ -379,7 +384,7 @@ class TestFetchMessages:
def test_fetch_messages_with_pagination(self, mock_req, monkeypatch): def test_fetch_messages_with_pagination(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [] mock_req.return_value = []
discord_server(action="fetch_messages", channel_id="11", before="999", limit=10) discord_core(action="fetch_messages", channel_id="11", before="999", limit=10)
call_params = mock_req.call_args[1]["params"] call_params = mock_req.call_args[1]["params"]
assert call_params["before"] == "999" assert call_params["before"] == "999"
assert call_params["limit"] == "10" assert call_params["limit"] == "10"
@@ -396,7 +401,7 @@ class TestListPins:
mock_req.return_value = [ mock_req.return_value = [
{"id": "500", "content": "Important announcement", "author": {"username": "admin"}, "timestamp": "2024-01-01T00:00:00Z"}, {"id": "500", "content": "Important announcement", "author": {"username": "admin"}, "timestamp": "2024-01-01T00:00:00Z"},
] ]
result = json.loads(discord_server(action="list_pins", channel_id="11")) result = json.loads(discord_admin_handler(action="list_pins", channel_id="11"))
assert result["count"] == 1 assert result["count"] == 1
assert result["pinned_messages"][0]["content"] == "Important announcement" assert result["pinned_messages"][0]["content"] == "Important announcement"
@@ -410,7 +415,7 @@ class TestPinUnpin:
def test_pin_message(self, mock_req, monkeypatch): def test_pin_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None # 204 mock_req.return_value = None # 204
result = json.loads(discord_server(action="pin_message", channel_id="11", message_id="500")) result = json.loads(discord_admin_handler(action="pin_message", channel_id="11", message_id="500"))
assert result["success"] is True assert result["success"] is True
mock_req.assert_called_once_with("PUT", "/channels/11/pins/500", "test-token") mock_req.assert_called_once_with("PUT", "/channels/11/pins/500", "test-token")
@@ -418,7 +423,7 @@ class TestPinUnpin:
def test_unpin_message(self, mock_req, monkeypatch): def test_unpin_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None mock_req.return_value = None
result = json.loads(discord_server(action="unpin_message", channel_id="11", message_id="500")) result = json.loads(discord_admin_handler(action="unpin_message", channel_id="11", message_id="500"))
assert result["success"] is True assert result["success"] is True
@@ -431,7 +436,7 @@ class TestCreateThread:
def test_create_standalone_thread(self, mock_req, monkeypatch): def test_create_standalone_thread(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "800", "name": "New Thread"} mock_req.return_value = {"id": "800", "name": "New Thread"}
result = json.loads(discord_server(action="create_thread", channel_id="11", name="New Thread")) result = json.loads(discord_core(action="create_thread", channel_id="11", name="New Thread"))
assert result["success"] is True assert result["success"] is True
assert result["thread_id"] == "800" assert result["thread_id"] == "800"
# Verify the API call # Verify the API call
@@ -444,7 +449,7 @@ class TestCreateThread:
def test_create_thread_from_message(self, mock_req, monkeypatch): def test_create_thread_from_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "801", "name": "Discussion"} mock_req.return_value = {"id": "801", "name": "Discussion"}
result = json.loads(discord_server( result = json.loads(discord_core(
action="create_thread", channel_id="11", name="Discussion", message_id="1001", action="create_thread", channel_id="11", name="Discussion", message_id="1001",
)) ))
assert result["success"] is True assert result["success"] is True
@@ -463,7 +468,7 @@ class TestRoleManagement:
def test_add_role(self, mock_req, monkeypatch): def test_add_role(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None mock_req.return_value = None
result = json.loads(discord_server( result = json.loads(discord_admin_handler(
action="add_role", guild_id="111", user_id="42", role_id="2", action="add_role", guild_id="111", user_id="42", role_id="2",
)) ))
assert result["success"] is True assert result["success"] is True
@@ -475,7 +480,7 @@ class TestRoleManagement:
def test_remove_role(self, mock_req, monkeypatch): def test_remove_role(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None mock_req.return_value = None
result = json.loads(discord_server( result = json.loads(discord_admin_handler(
action="remove_role", guild_id="111", user_id="42", role_id="2", action="remove_role", guild_id="111", user_id="42", role_id="2",
)) ))
assert result["success"] is True assert result["success"] is True
@@ -490,15 +495,23 @@ class TestErrorHandling:
def test_api_error_handled(self, mock_req, monkeypatch): def test_api_error_handled(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = DiscordAPIError(403, '{"message": "Missing Access"}') mock_req.side_effect = DiscordAPIError(403, '{"message": "Missing Access"}')
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert "error" in result assert "error" in result
assert "403" in result["error"] assert "403" in result["error"]
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_unexpected_error_handled(self, mock_req, monkeypatch): def test_unexpected_error_handled_admin(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = RuntimeError("something broke") mock_req.side_effect = RuntimeError("something broke")
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert "error" in result
assert "something broke" in result["error"]
@patch("tools.discord_tool._discord_request")
def test_unexpected_error_handled_core(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = RuntimeError("something broke")
result = json.loads(discord_core(action="fetch_messages", channel_id="11"))
assert "error" in result assert "error" in result
assert "something broke" in result["error"] assert "something broke" in result["error"]
@@ -508,79 +521,109 @@ class TestErrorHandling:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRegistration: class TestRegistration:
def test_tool_registered(self): def test_core_tool_registered(self):
from tools.registry import registry from tools.registry import registry
entry = registry._tools.get("discord_server") entry = registry._tools.get("discord")
assert entry is not None assert entry is not None
assert entry.schema["name"] == "discord_server" assert entry.schema["name"] == "discord"
assert entry.toolset == "discord" assert entry.toolset == "discord"
assert entry.check_fn is not None assert entry.check_fn is not None
assert entry.requires_env == ["DISCORD_BOT_TOKEN"] assert entry.requires_env == ["DISCORD_BOT_TOKEN"]
def test_schema_actions(self): def test_admin_tool_registered(self):
"""Static schema should list all actions (the model_tools post-processing
narrows this per-session; static registration is the superset)."""
from tools.registry import registry from tools.registry import registry
entry = registry._tools["discord_server"] entry = registry._tools.get("discord_admin")
actions = entry.schema["parameters"]["properties"]["action"]["enum"] assert entry is not None
expected = [ assert entry.schema["name"] == "discord_admin"
"list_guilds", "server_info", "list_channels", "channel_info", assert entry.toolset == "discord_admin"
"list_roles", "member_info", "search_members", "fetch_messages", assert entry.check_fn is not None
"list_pins", "pin_message", "unpin_message", "create_thread", assert entry.requires_env == ["DISCORD_BOT_TOKEN"]
"add_role", "remove_role",
] def test_core_schema_actions(self):
assert set(actions) == set(expected) """Core static schema should list only core actions."""
assert set(_ACTIONS.keys()) == set(expected) from tools.registry import registry
entry = registry._tools["discord"]
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
assert actions == {"fetch_messages", "search_members", "create_thread"}
def test_admin_schema_actions(self):
"""Admin static schema should list only admin actions."""
from tools.registry import registry
entry = registry._tools["discord_admin"]
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
expected_admin = set(_ACTIONS.keys()) - {"fetch_messages", "search_members", "create_thread"}
assert actions == expected_admin
def test_all_actions_covered(self):
"""Core + admin actions should cover all known actions."""
assert set(_CORE_ACTIONS.keys()) | set(_ADMIN_ACTIONS.keys()) == set(_ACTIONS.keys())
assert set(_CORE_ACTIONS.keys()) & set(_ADMIN_ACTIONS.keys()) == set()
def test_schema_parameter_bounds(self): def test_schema_parameter_bounds(self):
from tools.registry import registry from tools.registry import registry
entry = registry._tools["discord_server"] entry = registry._tools["discord"]
props = entry.schema["parameters"]["properties"] props = entry.schema["parameters"]["properties"]
assert props["limit"]["minimum"] == 1 assert props["limit"]["minimum"] == 1
assert props["limit"]["maximum"] == 100 assert props["limit"]["maximum"] == 100
assert props["auto_archive_duration"]["enum"] == [60, 1440, 4320, 10080] assert props["auto_archive_duration"]["enum"] == [60, 1440, 4320, 10080]
def test_schema_description_is_action_manifest(self): def test_core_schema_description(self):
"""The top-level description should include the action manifest """Core schema description should mention core actions."""
(one-line signatures per action) so the model can find required
params without re-reading every parameter description."""
from tools.registry import registry from tools.registry import registry
entry = registry._tools["discord_server"] entry = registry._tools["discord"]
desc = entry.schema["description"] desc = entry.schema["description"]
# Spot-check a few entries
assert "list_guilds()" in desc
assert "fetch_messages(channel_id)" in desc assert "fetch_messages(channel_id)" in desc
assert "search_members(guild_id, query)" in desc
assert "create_thread(channel_id, name)" in desc
# Admin actions should NOT be in core description
assert "list_guilds()" not in desc
assert "add_role(" not in desc
def test_admin_schema_description(self):
"""Admin schema description should mention admin actions."""
from tools.registry import registry
entry = registry._tools["discord_admin"]
desc = entry.schema["description"]
assert "list_guilds()" in desc
assert "add_role(guild_id, user_id, role_id)" in desc assert "add_role(guild_id, user_id, role_id)" in desc
# Core actions should NOT be in admin description
assert "fetch_messages(" not in desc
assert "create_thread(" not in desc
def test_handler_callable(self): def test_handler_callable(self):
from tools.registry import registry from tools.registry import registry
entry = registry._tools["discord_server"] entry = registry._tools["discord"]
assert callable(entry.handler) assert callable(entry.handler)
entry_admin = registry._tools["discord_admin"]
assert callable(entry_admin.handler)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Toolset: discord_server only in hermes-discord # Toolset: discord / discord_admin only in hermes-discord
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestToolsetInclusion: class TestToolsetInclusion:
def test_discord_server_in_hermes_discord_toolset(self): def test_discord_tools_in_hermes_discord_toolset(self):
from toolsets import TOOLSETS from toolsets import TOOLSETS
assert "discord_server" in TOOLSETS["hermes-discord"]["tools"] assert "discord" in TOOLSETS["hermes-discord"]["tools"]
assert "discord_admin" in TOOLSETS["hermes-discord"]["tools"]
def test_discord_server_not_in_core_tools(self): def test_discord_tools_not_in_core_tools(self):
from toolsets import _HERMES_CORE_TOOLS from toolsets import _HERMES_CORE_TOOLS
assert "discord_server" not in _HERMES_CORE_TOOLS assert "discord" not in _HERMES_CORE_TOOLS
assert "discord_admin" not in _HERMES_CORE_TOOLS
def test_discord_server_not_in_other_toolsets(self): def test_discord_tools_not_in_other_toolsets(self):
from toolsets import TOOLSETS from toolsets import TOOLSETS
for name, ts in TOOLSETS.items(): for name, ts in TOOLSETS.items():
if name == "hermes-discord": if name in ("hermes-discord", "hermes-gateway", "discord", "discord_admin"):
continue continue
# The gateway toolset might include it if it unions all platform tools tools = ts.get("tools", [])
if name == "hermes-gateway": assert "discord" not in tools or name == "discord", (
continue f"discord tool should not be in toolset '{name}'"
assert "discord_server" not in ts.get("tools", []), ( )
f"discord_server should not be in toolset '{name}'" assert "discord_admin" not in tools or name == "discord_admin", (
f"discord_admin tool should not be in toolset '{name}'"
) )
@@ -798,40 +841,69 @@ class TestDynamicSchema:
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_no_token_returns_none(self, mock_req, monkeypatch): def test_no_token_returns_none(self, mock_req, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False) monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
assert get_dynamic_schema() is None assert get_dynamic_schema_core() is None
assert get_dynamic_schema_admin() is None
mock_req.assert_not_called() mock_req.assert_not_called()
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_full_intents_full_schema(self, mock_req, monkeypatch): def test_full_intents_core_schema(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.config.load_config", "hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}}, lambda: {"discord": {"server_actions": ""}},
) )
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)} mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema() schema = get_dynamic_schema_core()
actions = schema["parameters"]["properties"]["action"]["enum"] actions = set(schema["parameters"]["properties"]["action"]["enum"])
assert set(actions) == set(_ACTIONS.keys()) assert actions == set(_CORE_ACTIONS.keys())
# No content warning assert schema["name"] == "discord"
@patch("tools.discord_tool._discord_request")
def test_full_intents_admin_schema(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema_admin()
actions = set(schema["parameters"]["properties"]["action"]["enum"])
assert actions == set(_ADMIN_ACTIONS.keys())
assert schema["name"] == "discord_admin"
# No content warning when MESSAGE_CONTENT is enabled
assert "MESSAGE_CONTENT" not in schema["description"] assert "MESSAGE_CONTENT" not in schema["description"]
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_no_members_intent_removes_member_actions_from_schema( def test_no_members_intent_removes_member_actions_from_admin_schema(
self, mock_req, monkeypatch, self, mock_req, monkeypatch,
): ):
"""member_info is an admin action; it should be hidden when
GUILD_MEMBERS intent is missing."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.config.load_config", "hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}}, lambda: {"discord": {"server_actions": ""}},
) )
mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT
schema = get_dynamic_schema() schema = get_dynamic_schema_admin()
actions = schema["parameters"]["properties"]["action"]["enum"]
assert "member_info" not in actions
assert "member_info" not in schema["description"]
@patch("tools.discord_tool._discord_request")
def test_no_members_intent_hides_search_members_from_core(
self, mock_req, monkeypatch,
):
"""search_members is a core action gated by GUILD_MEMBERS intent."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT
schema = get_dynamic_schema_core()
actions = schema["parameters"]["properties"]["action"]["enum"] actions = schema["parameters"]["properties"]["action"]["enum"]
assert "search_members" not in actions assert "search_members" not in actions
assert "member_info" not in actions
# Manifest description should also not advertise them
assert "search_members" not in schema["description"]
assert "member_info" not in schema["description"]
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_no_message_content_adds_warning_note(self, mock_req, monkeypatch): def test_no_message_content_adds_warning_note(self, mock_req, monkeypatch):
@@ -841,41 +913,53 @@ class TestDynamicSchema:
lambda: {"discord": {"server_actions": ""}}, lambda: {"discord": {"server_actions": ""}},
) )
mock_req.return_value = {"flags": 1 << 14} # only GUILD_MEMBERS mock_req.return_value = {"flags": 1 << 14} # only GUILD_MEMBERS
schema = get_dynamic_schema() schema = get_dynamic_schema_core()
assert "MESSAGE_CONTENT" in schema["description"] assert "MESSAGE_CONTENT" in schema["description"]
# But fetch_messages is still available # But fetch_messages is still available
actions = schema["parameters"]["properties"]["action"]["enum"] actions = schema["parameters"]["properties"]["action"]["enum"]
assert "fetch_messages" in actions assert "fetch_messages" in actions
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_config_allowlist_narrows_schema(self, mock_req, monkeypatch): def test_config_allowlist_narrows_admin_schema(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.config.load_config", "hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds,list_channels"}}, lambda: {"discord": {"server_actions": "list_guilds,list_channels"}},
) )
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)} mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema() schema = get_dynamic_schema_admin()
actions = schema["parameters"]["properties"]["action"]["enum"] actions = schema["parameters"]["properties"]["action"]["enum"]
assert actions == ["list_guilds", "list_channels"] assert actions == ["list_guilds", "list_channels"]
# Manifest description should only show allowed ones (check for
# the signature marker, which is specific to manifest lines)
assert "list_guilds()" in schema["description"] assert "list_guilds()" in schema["description"]
assert "add_role(" not in schema["description"] assert "add_role(" not in schema["description"]
assert "create_thread(" not in schema["description"]
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_empty_allowlist_with_valid_values_hides_tool(self, mock_req, monkeypatch): def test_empty_allowlist_with_valid_values_hides_tools(self, mock_req, monkeypatch):
"""If the allowlist resolves to zero valid actions (e.g. all names """If the allowlist resolves to zero valid actions (e.g. all names
were typos), get_dynamic_schema returns None so the tool is dropped were typos), get_dynamic_schema returns None so the tool is dropped."""
entirely rather than showing an empty enum."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.config.load_config", "hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "typo_one,typo_two"}}, lambda: {"discord": {"server_actions": "typo_one,typo_two"}},
) )
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)} mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
assert get_dynamic_schema() is None assert get_dynamic_schema_core() is None
assert get_dynamic_schema_admin() is None
@patch("tools.discord_tool._discord_request")
def test_backward_compat_wrapper(self, mock_req, monkeypatch):
"""get_dynamic_schema() should delegate to get_dynamic_schema_core()."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema()
assert schema is not None
assert schema["name"] == "discord"
actions = set(schema["parameters"]["properties"]["action"]["enum"])
assert actions == set(_CORE_ACTIONS.keys())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -890,7 +974,7 @@ class TestRuntimeAllowlistEnforcement:
"hermes_cli.config.load_config", "hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds"}}, lambda: {"discord": {"server_actions": "list_guilds"}},
) )
result = json.loads(discord_server(action="add_role", guild_id="1", user_id="2", role_id="3")) result = json.loads(discord_admin_handler(action="add_role", guild_id="1", user_id="2", role_id="3"))
assert "error" in result assert "error" in result
assert "disabled by config" in result["error"] assert "disabled by config" in result["error"]
mock_req.assert_not_called() mock_req.assert_not_called()
@@ -903,7 +987,7 @@ class TestRuntimeAllowlistEnforcement:
lambda: {"discord": {"server_actions": "list_guilds"}}, lambda: {"discord": {"server_actions": "list_guilds"}},
) )
mock_req.return_value = [] mock_req.return_value = []
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert "guilds" in result assert "guilds" in result
@@ -930,7 +1014,7 @@ class Test403Enrichment:
lambda: {"discord": {"server_actions": ""}}, lambda: {"discord": {"server_actions": ""}},
) )
mock_req.side_effect = DiscordAPIError(403, '{"message":"Missing Permissions"}') mock_req.side_effect = DiscordAPIError(403, '{"message":"Missing Permissions"}')
result = json.loads(discord_server( result = json.loads(discord_admin_handler(
action="add_role", guild_id="1", user_id="2", role_id="3", action="add_role", guild_id="1", user_id="2", role_id="3",
)) ))
assert "error" in result assert "error" in result
@@ -944,7 +1028,7 @@ class Test403Enrichment:
lambda: {"discord": {"server_actions": ""}}, lambda: {"discord": {"server_actions": ""}},
) )
mock_req.side_effect = DiscordAPIError(500, "server error") mock_req.side_effect = DiscordAPIError(500, "server error")
result = json.loads(discord_server(action="list_guilds")) result = json.loads(discord_admin_handler(action="list_guilds"))
assert "500" in result["error"] assert "500" in result["error"]
assert "MANAGE_ROLES" not in result["error"] assert "MANAGE_ROLES" not in result["error"]
@@ -961,10 +1045,10 @@ class TestModelToolsIntegration:
_reset_capability_cache() _reset_capability_cache()
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_discord_server_schema_rebuilt_by_get_tool_definitions( def test_discord_admin_schema_rebuilt_by_get_tool_definitions(
self, mock_req, monkeypatch, self, mock_req, monkeypatch,
): ):
"""When model_tools.get_tool_definitions runs with discord_server """When model_tools.get_tool_definitions runs with discord_admin
available, it should replace the static schema with the dynamic one.""" available, it should replace the static schema with the dynamic one."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr( monkeypatch.setattr(
@@ -976,16 +1060,16 @@ class TestModelToolsIntegration:
from model_tools import get_tool_definitions from model_tools import get_tool_definitions
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True) tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
discord_tool = next( discord_admin_tool = next(
(t for t in tools if t.get("function", {}).get("name") == "discord_server"), (t for t in tools if t.get("function", {}).get("name") == "discord_admin"),
None, None,
) )
assert discord_tool is not None, "discord_server should be in the schema" assert discord_admin_tool is not None, "discord_admin should be in the schema"
actions = discord_tool["function"]["parameters"]["properties"]["action"]["enum"] actions = discord_admin_tool["function"]["parameters"]["properties"]["action"]["enum"]
assert actions == ["list_guilds", "server_info"] assert actions == ["list_guilds", "server_info"]
@patch("tools.discord_tool._discord_request") @patch("tools.discord_tool._discord_request")
def test_discord_server_dropped_when_allowlist_empties_it( def test_discord_tools_dropped_when_allowlist_empties_them(
self, mock_req, monkeypatch, self, mock_req, monkeypatch,
): ):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok") monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
@@ -998,4 +1082,6 @@ class TestModelToolsIntegration:
from model_tools import get_tool_definitions from model_tools import get_tool_definitions
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True) tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
names = [t.get("function", {}).get("name") for t in tools] names = [t.get("function", {}).get("name") for t in tools]
assert "discord" not in names
assert "discord_admin" not in names
assert "discord_server" not in names assert "discord_server" not in names

View File

@@ -473,6 +473,12 @@ _ACTIONS = {
"remove_role": _remove_role, "remove_role": _remove_role,
} }
_CORE_ACTION_NAMES = frozenset({"fetch_messages", "search_members", "create_thread"})
_ADMIN_ACTION_NAMES = frozenset(_ACTIONS.keys()) - _CORE_ACTION_NAMES
_CORE_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _CORE_ACTION_NAMES}
_ADMIN_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _ADMIN_ACTION_NAMES}
# Single-source-of-truth manifest: action → (signature, one-line description). # Single-source-of-truth manifest: action → (signature, one-line description).
# Consumed by :func:`_build_schema` so the schema's top-level description # Consumed by :func:`_build_schema` so the schema's top-level description
# always matches the registered action set. # always matches the registered action set.
@@ -531,7 +537,7 @@ def _load_allowed_actions_config() -> Optional[List[str]]:
from hermes_cli.config import load_config from hermes_cli.config import load_config
cfg = load_config() cfg = load_config()
except Exception as exc: except Exception as exc:
logger.debug("discord_server: could not load config (%s); allowing all actions.", exc) logger.debug("discord: could not load config (%s); allowing all actions.", exc)
return None return None
raw = (cfg.get("discord") or {}).get("server_actions") raw = (cfg.get("discord") or {}).get("server_actions")
@@ -586,12 +592,16 @@ def _available_actions(
def _build_schema( def _build_schema(
actions: List[str], actions: List[str],
caps: Optional[Dict[str, Any]] = None, caps: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: tool_name: str = "discord",
"""Build the tool schema for the given filtered action list.""" ) -> Optional[Dict[str, Any]]:
"""Build the tool schema for the given filtered action list.
Returns ``None`` when *actions* is empty — callers should drop the
tool from registration in that case.
"""
caps = caps or {} caps = caps or {}
if not actions: if not actions:
# Tool shouldn't be registered when empty, but guard anyway. return None
actions = list(_ACTIONS.keys())
# Action manifest lines (action-first, parameter-scoped). # Action manifest lines (action-first, parameter-scoped).
manifest_lines = [ manifest_lines = [
@@ -602,24 +612,36 @@ def _build_schema(
manifest_block = "\n".join(manifest_lines) manifest_block = "\n".join(manifest_lines)
content_note = "" content_note = ""
if caps.get("detected") and caps.get("has_message_content") is False: affected_actions = {"fetch_messages", "list_pins"} & set(actions)
if affected_actions and caps.get("detected") and caps.get("has_message_content") is False:
names = " and ".join(sorted(affected_actions))
content_note = ( content_note = (
"\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. " f"\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. "
"fetch_messages and list_pins will return message metadata (author, " f"{names} will return message metadata (author, "
"timestamps, attachments, reactions, pin state) but `content` will be " "timestamps, attachments, reactions, pin state) but `content` will be "
"empty for messages not sent as a direct mention to the bot or in DMs. " "empty for messages not sent as a direct mention to the bot or in DMs. "
"Enable the intent in the Discord Developer Portal to see all content." "Enable the intent in the Discord Developer Portal to see all content."
) )
description = ( if tool_name == "discord_admin":
"Query and manage a Discord server via the REST API.\n\n" description = (
"Available actions:\n" "Manage a Discord server via the REST API.\n\n"
f"{manifest_block}\n\n" "Available actions:\n"
"Call list_guilds first to discover guild_ids, then list_channels for " f"{manifest_block}\n\n"
"channel_ids. Runtime errors will tell you if the bot lacks a specific " "Call list_guilds first to discover guild_ids, then list_channels for "
"per-guild permission (e.g. MANAGE_ROLES for add_role)." "channel_ids. Runtime errors will tell you if the bot lacks a specific "
f"{content_note}" "per-guild permission (e.g. MANAGE_ROLES for add_role)."
) f"{content_note}"
)
else:
description = (
"Read and participate in a Discord server.\n\n"
"Available actions:\n"
f"{manifest_block}\n\n"
"Use the channel_id from the current conversation context. "
"Use search_members to look up user IDs by name prefix."
f"{content_note}"
)
properties: Dict[str, Any] = { properties: Dict[str, Any] = {
"action": { "action": {
@@ -676,7 +698,7 @@ def _build_schema(
} }
return { return {
"name": "discord_server", "name": tool_name,
"description": description, "description": description,
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -686,28 +708,33 @@ def _build_schema(
} }
def get_dynamic_schema() -> Optional[Dict[str, Any]]: def _get_dynamic_schema(
"""Return a schema filtered by current intents + config allowlist. action_subset: Dict[str, Any],
tool_name: str,
Called by ``model_tools.get_tool_definitions`` as a post-processing ) -> Optional[Dict[str, Any]]:
step so the schema the model sees always reflects reality. Returns """Build a dynamic schema for *action_subset* filtered by intents + config."""
``None`` when no actions are available (tool should be removed from
the schema list entirely).
"""
token = _get_bot_token() token = _get_bot_token()
if not token: if not token:
return None return None
caps = _detect_capabilities(token) caps = _detect_capabilities(token)
allowlist = _load_allowed_actions_config() allowlist = _load_allowed_actions_config()
actions = _available_actions(caps, allowlist) actions = [a for a in _available_actions(caps, allowlist) if a in action_subset]
if not actions: if not actions:
logger.warning(
"discord_server: config allowlist/intents left zero available actions; "
"hiding tool from this session."
)
return None return None
return _build_schema(actions, caps) return _build_schema(actions, caps, tool_name=tool_name)
def get_dynamic_schema_core() -> Optional[Dict[str, Any]]:
return _get_dynamic_schema(_CORE_ACTIONS, "discord")
def get_dynamic_schema_admin() -> Optional[Dict[str, Any]]:
return _get_dynamic_schema(_ADMIN_ACTIONS, "discord_admin")
def get_dynamic_schema() -> Optional[Dict[str, Any]]:
"""Backward-compat wrapper — returns core schema."""
return get_dynamic_schema_core()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -774,11 +801,13 @@ def check_discord_tool_requirements() -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main handler # Handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def discord_server( def _run_discord_action(
action: str, action: str,
valid_actions: Dict[str, Any],
tool_label: str,
guild_id: str = "", guild_id: str = "",
channel_id: str = "", channel_id: str = "",
user_id: str = "", user_id: str = "",
@@ -790,18 +819,17 @@ def discord_server(
before: str = "", before: str = "",
after: str = "", after: str = "",
auto_archive_duration: int = 1440, auto_archive_duration: int = 1440,
task_id: str = None,
) -> str: ) -> str:
"""Execute a Discord server action.""" """Shared handler logic for both discord tools."""
token = _get_bot_token() token = _get_bot_token()
if not token: if not token:
return json.dumps({"error": "DISCORD_BOT_TOKEN not configured."}) return json.dumps({"error": "DISCORD_BOT_TOKEN not configured."})
action_fn = _ACTIONS.get(action) action_fn = valid_actions.get(action)
if not action_fn: if not action_fn:
return json.dumps({ return json.dumps({
"error": f"Unknown action: {action}", "error": f"Unknown action: {action}",
"available_actions": list(_ACTIONS.keys()), "available_actions": list(valid_actions.keys()),
}) })
# Config-level allowlist gate (defense in depth — schema already filtered, # Config-level allowlist gate (defense in depth — schema already filtered,
@@ -848,44 +876,64 @@ def discord_server(
auto_archive_duration=auto_archive_duration, auto_archive_duration=auto_archive_duration,
) )
except DiscordAPIError as e: except DiscordAPIError as e:
logger.warning("Discord API error in action '%s': %s", action, e) logger.warning("Discord API error in %s action '%s': %s", tool_label, action, e)
if e.status == 403: if e.status == 403:
return json.dumps({"error": _enrich_403(action, e.body)}) return json.dumps({"error": _enrich_403(action, e.body)})
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
except Exception as e: except Exception as e:
logger.exception("Unexpected error in discord_server action '%s'", action) logger.exception("Unexpected error in %s action '%s'", tool_label, action)
return json.dumps({"error": f"Unexpected error: {e}"}) return json.dumps({"error": f"Unexpected error: {e}"})
def discord_core(action: str, **kwargs) -> str:
"""Execute a core Discord action (fetch_messages, search_members, create_thread)."""
return _run_discord_action(action, _CORE_ACTIONS, "discord", **kwargs)
def discord_admin_handler(action: str, **kwargs) -> str:
"""Execute a Discord admin action (server management)."""
return _run_discord_action(action, _ADMIN_ACTIONS, "discord_admin", **kwargs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tool registration # Tool registration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Register with the full unfiltered schema. ``model_tools.get_tool_definitions`` _HANDLER_DEFAULTS = {
# rebuilds this per-session via ``get_dynamic_schema`` so the model only ever "action": "", "guild_id": "", "channel_id": "", "user_id": "",
# sees intent-available, config-allowed actions. The static registration is a "role_id": "", "message_id": "", "query": "", "name": "",
# safe baseline for tools that inspect the registry directly. "limit": 50, "before": "", "after": "", "auto_archive_duration": 1440,
_STATIC_SCHEMA = _build_schema(list(_ACTIONS.keys()), caps={"detected": False}) }
def _make_handler(handler_fn):
"""Create a registry-compatible handler lambda for a discord handler."""
return lambda args, **kw: handler_fn(
**{k: args.get(k, v) for k, v in _HANDLER_DEFAULTS.items()},
)
_STATIC_CORE_SCHEMA = _build_schema(
list(_CORE_ACTIONS.keys()), caps={"detected": False}, tool_name="discord",
)
_STATIC_ADMIN_SCHEMA = _build_schema(
list(_ADMIN_ACTIONS.keys()), caps={"detected": False}, tool_name="discord_admin",
)
registry.register( registry.register(
name="discord_server", name="discord",
toolset="discord", toolset="discord",
schema=_STATIC_SCHEMA, schema=_STATIC_CORE_SCHEMA,
handler=lambda args, **kw: discord_server( handler=_make_handler(discord_core),
action=args.get("action", ""), check_fn=check_discord_tool_requirements,
guild_id=args.get("guild_id", ""), requires_env=["DISCORD_BOT_TOKEN"],
channel_id=args.get("channel_id", ""), )
user_id=args.get("user_id", ""),
role_id=args.get("role_id", ""), registry.register(
message_id=args.get("message_id", ""), name="discord_admin",
query=args.get("query", ""), toolset="discord_admin",
name=args.get("name", ""), schema=_STATIC_ADMIN_SCHEMA,
limit=args.get("limit", 50), handler=_make_handler(discord_admin_handler),
before=args.get("before", ""),
after=args.get("after", ""),
auto_archive_duration=args.get("auto_archive_duration", 1440),
task_id=kw.get("task_id"),
),
check_fn=check_discord_tool_requirements, check_fn=check_discord_tool_requirements,
requires_env=["DISCORD_BOT_TOKEN"], requires_env=["DISCORD_BOT_TOKEN"],
) )

View File

@@ -202,6 +202,18 @@ TOOLSETS = {
"includes": [] "includes": []
}, },
"discord": {
"description": "Discord read and participate tools (fetch messages, search members, create threads)",
"tools": ["discord"],
"includes": [],
},
"discord_admin": {
"description": "Discord server management (list channels/roles, pin messages, assign roles)",
"tools": ["discord_admin"],
"includes": [],
},
"feishu_doc": { "feishu_doc": {
"description": "Read Feishu/Lark document content", "description": "Read Feishu/Lark document content",
"tools": ["feishu_doc_read"], "tools": ["feishu_doc_read"],
@@ -317,8 +329,8 @@ TOOLSETS = {
"hermes-discord": { "hermes-discord": {
"description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)",
"tools": _HERMES_CORE_TOOLS + [ "tools": _HERMES_CORE_TOOLS + [
# Discord server introspection & management (gated on DISCORD_BOT_TOKEN via check_fn) "discord",
"discord_server", "discord_admin",
], ],
"includes": [] "includes": []
}, },
@@ -379,7 +391,13 @@ TOOLSETS = {
"hermes-feishu": { "hermes-feishu": {
"description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)", "description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)",
"tools": _HERMES_CORE_TOOLS, "tools": _HERMES_CORE_TOOLS + [
"feishu_doc_read",
"feishu_drive_list_comments",
"feishu_drive_list_comment_replies",
"feishu_drive_reply_comment",
"feishu_drive_add_comment",
],
"includes": [] "includes": []
}, },