diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 0adee9eb6..51ef0a868 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1736,46 +1736,90 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_btw(interaction: discord.Interaction, question: str): await self._run_simple_slash(interaction, f"/btw {question}") - # Register installed skills as native slash commands (parity with - # Telegram, which uses telegram_menu_commands() in commands.py). - # Discord allows up to 100 application commands globally. - _DISCORD_CMD_LIMIT = 100 + # Register skills under a single /skill command group with category + # subcommand groups. This uses 1 top-level slot instead of N, + # supporting up to 25 categories × 25 skills = 625 skills. + self._register_skill_group(tree) + + def _register_skill_group(self, tree) -> None: + """Register a ``/skill`` command group with category subcommand groups. + + Skills are organized by their directory category under ``SKILLS_DIR``. + Each category becomes a subcommand group; root-level skills become + direct subcommands. Discord supports 25 subcommand groups × 25 + subcommands each = 625 skills — well beyond the old 100-command cap. + """ try: - from hermes_cli.commands import discord_skill_commands + from hermes_cli.commands import discord_skill_commands_by_category - existing_names = {cmd.name for cmd in tree.get_commands()} - remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names)) + existing_names = set() + try: + existing_names = {cmd.name for cmd in tree.get_commands()} + except Exception: + pass - skill_entries, skipped = discord_skill_commands( - max_slots=remaining_slots, + categories, uncategorized, hidden = discord_skill_commands_by_category( reserved_names=existing_names, ) - for discord_name, description, cmd_key in skill_entries: - # Closure factory to capture cmd_key per iteration - def _make_skill_handler(_key: str): - async def _skill_slash(interaction: discord.Interaction, args: str = ""): - await self._run_simple_slash(interaction, f"{_key} {args}".strip()) - return _skill_slash + if not categories and not uncategorized: + return - handler = _make_skill_handler(cmd_key) - handler.__name__ = f"skill_{discord_name.replace('-', '_')}" + skill_group = discord.app_commands.Group( + name="skill", + description="Run a Hermes skill", + ) + # ── Helper: build a callback for a skill command key ── + def _make_handler(_key: str): + @discord.app_commands.describe(args="Optional arguments for the skill") + async def _handler(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash(interaction, f"{_key} {args}".strip()) + _handler.__name__ = f"skill_{_key.lstrip('/').replace('-', '_')}" + return _handler + + # ── Uncategorized (root-level) skills → direct subcommands ── + for discord_name, description, cmd_key in uncategorized: cmd = discord.app_commands.Command( name=discord_name, - description=description, - callback=handler, + description=description or f"Run the {discord_name} skill", + callback=_make_handler(cmd_key), ) - discord.app_commands.describe(args="Optional arguments for the skill")(cmd) - tree.add_command(cmd) + skill_group.add_command(cmd) - if skipped: + # ── Category subcommand groups ── + for cat_name in sorted(categories): + cat_desc = f"{cat_name.replace('-', ' ').title()} skills" + if len(cat_desc) > 100: + cat_desc = cat_desc[:97] + "..." + cat_group = discord.app_commands.Group( + name=cat_name, + description=cat_desc, + parent=skill_group, + ) + for discord_name, description, cmd_key in categories[cat_name]: + cmd = discord.app_commands.Command( + name=discord_name, + description=description or f"Run the {discord_name} skill", + callback=_make_handler(cmd_key), + ) + cat_group.add_command(cmd) + + tree.add_command(skill_group) + + total = sum(len(v) for v in categories.values()) + len(uncategorized) + logger.info( + "[%s] Registered /skill group: %d skill(s) across %d categories" + " + %d uncategorized", + self.name, total, len(categories), len(uncategorized), + ) + if hidden: logger.warning( - "[%s] Discord slash command limit reached (%d): %d skill(s) not registered", - self.name, _DISCORD_CMD_LIMIT, skipped, + "[%s] %d skill(s) not registered (Discord subcommand limits)", + self.name, hidden, ) except Exception as exc: - logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc) + logger.warning("[%s] Failed to register /skill group: %s", self.name, exc) def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent: """Build a MessageEvent from a Discord slash command interaction.""" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e62c7e610..e08aacf64 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -582,6 +582,116 @@ def discord_skill_commands( ) +def discord_skill_commands_by_category( + reserved_names: set[str], +) -> tuple[dict[str, list[tuple[str, str, str]]], list[tuple[str, str, str]], int]: + """Return skill entries organized by category for Discord ``/skill`` subcommand groups. + + Skills whose directory is nested at least 2 levels under ``SKILLS_DIR`` + (e.g. ``creative/ascii-art/SKILL.md``) are grouped by their top-level + category. Root-level skills (e.g. ``dogfood/SKILL.md``) are returned as + *uncategorized* — the caller should register them as direct subcommands + of the ``/skill`` group. + + The same filtering as :func:`discord_skill_commands` is applied: hub + skills excluded, per-platform disabled excluded, names clamped. + + Returns: + ``(categories, uncategorized, hidden_count)`` + + - *categories*: ``{category_name: [(name, description, cmd_key), ...]}`` + - *uncategorized*: ``[(name, description, cmd_key), ...]`` + - *hidden_count*: skills dropped due to Discord group limits + (25 subcommand groups, 25 subcommands per group) + """ + from pathlib import Path as _P + + _platform_disabled: set[str] = set() + try: + from agent.skill_utils import get_disabled_skill_names + _platform_disabled = get_disabled_skill_names(platform="discord") + except Exception: + pass + + # Collect raw skill data -------------------------------------------------- + categories: dict[str, list[tuple[str, str, str]]] = {} + uncategorized: list[tuple[str, str, str]] = [] + _names_used: set[str] = set(reserved_names) + hidden = 0 + + try: + from agent.skill_commands import get_skill_commands + from tools.skills_tool import SKILLS_DIR + _skills_dir = SKILLS_DIR.resolve() + _hub_dir = (SKILLS_DIR / ".hub").resolve() + skill_cmds = get_skill_commands() + + for cmd_key in sorted(skill_cmds): + info = skill_cmds[cmd_key] + skill_path = info.get("skill_md_path", "") + if not skill_path: + continue + sp = _P(skill_path).resolve() + # Skip skills outside SKILLS_DIR or from the hub + if not str(sp).startswith(str(_skills_dir)): + continue + if str(sp).startswith(str(_hub_dir)): + continue + + skill_name = info.get("name", "") + if skill_name in _platform_disabled: + continue + + raw_name = cmd_key.lstrip("/") + # Clamp to 32 chars (Discord limit) + discord_name = raw_name[:32] + if discord_name in _names_used: + continue + _names_used.add(discord_name) + + desc = info.get("description", "") + if len(desc) > 100: + desc = desc[:97] + "..." + + # Determine category from the relative path within SKILLS_DIR. + # e.g. creative/ascii-art/SKILL.md → parts = ("creative", "ascii-art") + try: + rel = sp.parent.relative_to(_skills_dir) + except ValueError: + continue + parts = rel.parts + if len(parts) >= 2: + cat = parts[0] + categories.setdefault(cat, []).append((discord_name, desc, cmd_key)) + else: + uncategorized.append((discord_name, desc, cmd_key)) + except Exception: + pass + + # Enforce Discord limits: 25 subcommand groups, 25 subcommands each ------ + _MAX_GROUPS = 25 + _MAX_PER_GROUP = 25 + + trimmed_categories: dict[str, list[tuple[str, str, str]]] = {} + group_count = 0 + for cat in sorted(categories): + if group_count >= _MAX_GROUPS: + hidden += len(categories[cat]) + continue + entries = categories[cat][:_MAX_PER_GROUP] + hidden += max(0, len(categories[cat]) - _MAX_PER_GROUP) + trimmed_categories[cat] = entries + group_count += 1 + + # Uncategorized skills also count against the 25 top-level limit + remaining_slots = _MAX_GROUPS - group_count + if len(uncategorized) > remaining_slots: + hidden += len(uncategorized) - remaining_slots + uncategorized = uncategorized[:remaining_slots] + + return trimmed_categories, uncategorized, hidden + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index f7ed64639..b7967c69a 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -19,10 +19,34 @@ def _ensure_discord_mock(): discord_mod.Thread = type("Thread", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.Interaction = object + + # Lightweight mock for app_commands.Group and Command used by + # _register_skill_group. + class _FakeGroup: + def __init__(self, *, name, description, parent=None): + self.name = name + self.description = description + self.parent = parent + self._children: dict[str, object] = {} + if parent is not None: + parent.add_command(self) + + def add_command(self, cmd): + self._children[cmd.name] = cmd + + class _FakeCommand: + def __init__(self, *, name, description, callback, parent=None): + self.name = name + self.description = description + self.callback = callback + self.parent = parent + discord_mod.app_commands = SimpleNamespace( describe=lambda **kwargs: (lambda fn: fn), choices=lambda **kwargs: (lambda fn: fn), Choice=lambda **kwargs: SimpleNamespace(**kwargs), + Group=_FakeGroup, + Command=_FakeCommand, ) ext_mod = MagicMock() @@ -51,6 +75,12 @@ class FakeTree: return decorator + def add_command(self, cmd): + self.commands[cmd.name] = cmd + + def get_commands(self): + return [SimpleNamespace(name=n) for n in self.commands] + @pytest.fixture def adapter(): @@ -498,3 +528,79 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path): import os assert os.getenv("DISCORD_AUTO_THREAD") == "true" + + +# ------------------------------------------------------------------ +# /skill group registration +# ------------------------------------------------------------------ + + +def test_register_skill_group_creates_group(adapter): + """_register_skill_group should register a '/skill' Group on the tree.""" + mock_categories = { + "creative": [ + ("ascii-art", "Generate ASCII art", "/ascii-art"), + ("excalidraw", "Hand-drawn diagrams", "/excalidraw"), + ], + "media": [ + ("gif-search", "Search for GIFs", "/gif-search"), + ], + } + mock_uncategorized = [ + ("dogfood", "Exploratory QA testing", "/dogfood"), + ] + + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=(mock_categories, mock_uncategorized, 0), + ): + adapter._register_slash_commands() + + tree = adapter._client.tree + assert "skill" in tree.commands, "Expected /skill group to be registered" + skill_group = tree.commands["skill"] + assert skill_group.name == "skill" + # Should have 2 category subgroups + 1 uncategorized subcommand + children = skill_group._children + assert "creative" in children + assert "media" in children + assert "dogfood" in children + # Category groups should have their skills + assert "ascii-art" in children["creative"]._children + assert "excalidraw" in children["creative"]._children + assert "gif-search" in children["media"]._children + + +def test_register_skill_group_empty_skills_no_group(adapter): + """No /skill group should be added when there are zero skills.""" + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=({}, [], 0), + ): + adapter._register_slash_commands() + + tree = adapter._client.tree + assert "skill" not in tree.commands + + +def test_register_skill_group_handler_dispatches_command(adapter): + """Skill subcommand handlers should dispatch the correct /cmd-key text.""" + mock_categories = { + "media": [ + ("gif-search", "Search for GIFs", "/gif-search"), + ], + } + + with patch( + "hermes_cli.commands.discord_skill_commands_by_category", + return_value=(mock_categories, [], 0), + ): + adapter._register_slash_commands() + + skill_group = adapter._client.tree.commands["skill"] + media_group = skill_group._children["media"] + gif_cmd = media_group._children["gif-search"] + assert gif_cmd.callback is not None + # The callback name should reflect the skill + assert "gif_search" in gif_cmd.callback.__name__ + diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 30c2f22c2..5912194b5 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1028,3 +1028,154 @@ class TestDiscordSkillCommands: assert len(name) <= _CMD_NAME_LIMIT, ( f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})" ) + + +# --------------------------------------------------------------------------- +# Discord skill commands grouped by category +# --------------------------------------------------------------------------- + +from hermes_cli.commands import discord_skill_commands_by_category # noqa: E402 + + +class TestDiscordSkillCommandsByCategory: + """Tests for discord_skill_commands_by_category() — /skill group registration.""" + + def test_groups_skills_by_category(self, tmp_path, monkeypatch): + """Skills nested 2+ levels deep should be grouped by top-level category.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + # Create the directory structure so resolve() works + for p in [ + "skills/creative/ascii-art", + "skills/creative/excalidraw", + "skills/media/gif-search", + ]: + (tmp_path / p).mkdir(parents=True, exist_ok=True) + (tmp_path / p / "SKILL.md").write_text("---\nname: test\n---\n") + + fake_cmds = { + "/ascii-art": { + "name": "ascii-art", + "description": "Generate ASCII art", + "skill_md_path": f"{fake_skills_dir}/creative/ascii-art/SKILL.md", + }, + "/excalidraw": { + "name": "excalidraw", + "description": "Hand-drawn diagrams", + "skill_md_path": f"{fake_skills_dir}/creative/excalidraw/SKILL.md", + }, + "/gif-search": { + "name": "gif-search", + "description": "Search for GIFs", + "skill_md_path": f"{fake_skills_dir}/media/gif-search/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert "creative" in categories + assert "media" in categories + assert len(categories["creative"]) == 2 + assert len(categories["media"]) == 1 + assert uncategorized == [] + assert hidden == 0 + + def test_root_level_skills_are_uncategorized(self, tmp_path, monkeypatch): + """Skills directly under SKILLS_DIR (only 1 path component) → uncategorized.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / "dogfood").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "dogfood" / "SKILL.md").write_text("") + + fake_cmds = { + "/dogfood": { + "name": "dogfood", + "description": "QA testing", + "skill_md_path": f"{fake_skills_dir}/dogfood/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert categories == {} + assert len(uncategorized) == 1 + assert uncategorized[0][0] == "dogfood" + + def test_hub_skills_excluded(self, tmp_path, monkeypatch): + """Skills under .hub should be excluded.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / ".hub" / "some-skill").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / ".hub" / "some-skill" / "SKILL.md").write_text("") + + fake_cmds = { + "/some-skill": { + "name": "some-skill", + "description": "Hub skill", + "skill_md_path": f"{fake_skills_dir}/.hub/some-skill/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + assert categories == {} + assert uncategorized == [] + + def test_deep_nested_skills_use_top_category(self, tmp_path, monkeypatch): + """Skills like mlops/training/axolotl should group under 'mlops'.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + (tmp_path / "skills" / "mlops" / "training" / "axolotl").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "mlops" / "training" / "axolotl" / "SKILL.md").write_text("") + (tmp_path / "skills" / "mlops" / "inference" / "vllm").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "mlops" / "inference" / "vllm" / "SKILL.md").write_text("") + + fake_cmds = { + "/axolotl": { + "name": "axolotl", + "description": "Fine-tuning with Axolotl", + "skill_md_path": f"{fake_skills_dir}/mlops/training/axolotl/SKILL.md", + }, + "/vllm": { + "name": "vllm", + "description": "vLLM inference", + "skill_md_path": f"{fake_skills_dir}/mlops/inference/vllm/SKILL.md", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + categories, uncategorized, hidden = discord_skill_commands_by_category( + reserved_names=set(), + ) + + # Both should be under 'mlops' regardless of sub-category + assert "mlops" in categories + names = {n for n, _d, _k in categories["mlops"]} + assert "axolotl" in names + assert "vllm" in names + assert len(uncategorized) == 0