mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
8 Commits
feat/langf
...
sid/fix-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7efd91d4b4 | ||
|
|
0aa1269e56 | ||
|
|
3c29834354 | ||
|
|
0eb85906b0 | ||
|
|
ff9b0528a2 | ||
|
|
8feaa7cd1b | ||
|
|
57a2b97ae8 | ||
|
|
bd9afb027a |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +123,12 @@ 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
|
||||||
@@ -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)"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
)
|
)
|
||||||
|
|||||||
24
toolsets.py
24
toolsets.py
@@ -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": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user