From 5e851bc6bc5161960548d7ee72899a199a971ad7 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:01:28 +0700 Subject: [PATCH 1/2] fix(discord): cap slash commands at Discord's 100-command limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord enforces a hard cap of 100 global application commands per app. The adapter registers ~27 native commands plus every gateway-available entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated /skill group. On a loaded install (many plugins/quick commands) the desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands() hits error 30032 ("Maximum number of application commands reached") and Discord rejects the ENTIRE batch — silently breaking every slash command, not just the overflow. Cap registration at the 100-command limit: native commands (registered first, highest priority) and the /skill group are always kept; lower- priority auto-registered COMMAND_REGISTRY and plugin commands are added only until the cap is reached, with a single concise warning telling the user how to surface the rest. Since both sync paths read from tree.get_commands(), bounding the tree fixes the root cause for both. --- plugins/platforms/discord/adapter.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 196564dd14f..26daab02c22 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -31,6 +31,12 @@ _DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway" _DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json" _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 +# Discord enforces a hard cap of 100 global application (slash) commands per +# app. Registering more makes the ENTIRE sync fail with error 30032 +# ("Maximum number of application commands reached"), which silently breaks +# every slash command — not just the overflow ones. We keep the desired set +# at or below this limit at registration time. +_DISCORD_MAX_APP_COMMANDS = 100 try: import discord @@ -3518,6 +3524,11 @@ class DiscordAdapter(BasePlatformAdapter): ) already_registered: set[str] = set() + # Native commands above are registered first and are the highest + # priority, so they always survive the 100-command cap. Reserve one + # slot for the consolidated ``/skill`` group registered further below. + slot_cap = _DISCORD_MAX_APP_COMMANDS - 1 + dropped_over_cap = 0 try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates @@ -3535,6 +3546,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = cmd_def.name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( cmd_def.name, cmd_def.description, @@ -3567,6 +3581,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = plugin_name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( plugin_name, plugin_desc, @@ -3589,6 +3606,20 @@ class DiscordAdapter(BasePlatformAdapter): # supporting up to 25 categories × 25 skills = 625 skills. self._register_skill_group(tree) + if dropped_over_cap: + # Staying under the cap keeps the whole sync succeeding; without + # this guard a single over-limit command makes Discord reject the + # entire batch (error 30032), breaking every slash command. + logger.warning( + "[%s] Reached Discord's limit of %d slash commands; skipped %d " + "lower-priority command(s) to keep the command sync working. " + "Disable slash commands you don't need or trim installed plugins " + "to surface them all.", + self.name, + _DISCORD_MAX_APP_COMMANDS, + dropped_over_cap, + ) + # Optional defense-in-depth: hide every slash command from non-admin # guild members in Discord's slash picker. Server-side authorization # (``_check_slash_authorization``) is the actual gate; this is purely From 8f4a718f957d5d8fdb6264552ef46bb1c2ce4047 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:02:21 +0700 Subject: [PATCH 2/2] test(discord): guard slash-command registration against the 100 cap Registers 200 plugin commands on top of the native + COMMAND_REGISTRY set and asserts the tree never exceeds Discord's 100-command limit, that native high-priority commands survive the cap, and that overflow is actually dropped. Regression guard for the recurring error 30032 ("Maximum number of application commands reached") sync failures. --- tests/gateway/test_discord_slash_commands.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 8d44f77302e..5ef6812f537 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -292,6 +292,58 @@ async def test_plugin_command_name_conflict_skipped(adapter): ) +# ------------------------------------------------------------------ +# 100-command cap (Discord error 30032 guard) +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_slash_command_registration_stays_under_discord_limit(adapter): + """Registering far more commands than Discord allows must NOT push the + tree over the 100-command hard cap. + + Discord rejects the ENTIRE command sync with error 30032 once the + desired set exceeds 100 global application commands, silently breaking + every slash command. The adapter must bound the desired set instead. + Regression guard for samuraiheart's recurring + "Maximum number of application commands reached (100)" sync failures. + """ + from plugins.platforms.discord.adapter import _DISCORD_MAX_APP_COMMANDS + + adapter._run_simple_slash = AsyncMock() + + # 200 plugin commands — way past Discord's limit on their own. + many_plugins = { + f"plug{i:03d}": { + "handler": lambda _a: "ok", + "description": f"Plugin command {i}", + "args_hint": "", + "plugin": "stress-plugin", + } + for i in range(200) + } + + with patch("hermes_cli.plugins.get_plugin_commands", return_value=many_plugins): + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # Contract: never exceed Discord's hard cap. + assert len(tree_names) <= _DISCORD_MAX_APP_COMMANDS, ( + f"registered {len(tree_names)} commands — exceeds Discord's " + f"{_DISCORD_MAX_APP_COMMANDS} limit and would fail sync with 30032" + ) + + # Native, high-priority commands are registered first and must survive + # the cap — they are the core UX, not droppable overflow. + for native in ("status", "stop", "new", "model", "help"): + assert native in tree_names, f"/{native} (native) was dropped by the cap" + + # The cap must actually have dropped overflow — not every plugin fit. + registered_plugins = [n for n in tree_names if n.startswith("plug")] + assert len(registered_plugins) < 200, "cap did not drop any overflow commands" + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------