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.
This commit is contained in:
alt-glitch
2026-04-24 15:47:29 +05:30
committed by Teknium
parent 9830905dab
commit 81987f0350
8 changed files with 354 additions and 189 deletions

View File

@@ -848,7 +848,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

@@ -68,12 +68,13 @@ CONFIGURABLE_TOOLSETS = [
("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"),
("spotify", "🎵 Spotify", "playback, search, playlists, library"), ("spotify", "🎵 Spotify", "playback, search, playlists, library"),
("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", "spotify"} _DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord_admin"}
def _get_effective_configurable_toolsets(): def _get_effective_configurable_toolsets():

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

@@ -697,3 +697,17 @@ def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets():
enabled = _get_platform_tools({}, "cli") enabled = _get_platform_tools({}, "cli")
assert "search" not in enabled 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

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"],
@@ -326,8 +338,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": []
}, },