Discord slash command registration and limits

NousResearch/hermes-agent · cluster 5480 · gateway

Data as of

Nothing open on GitHub. Fix merged in #5603. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #5603. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #5603. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern registering skills as native Discord slash commands, including approaches for organizing them (direct commands vs. grouped subcommands) and addressing Discord's 100-command limit and related connectivity problems.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5603 feat(discord): register skills as native slash commands via shared gateway logic

    merged to main

    files: gateway/platforms/discord.py, hermes_cli/commands.py, tests/hermes_cli/test_commands.py

    Show the diff
    • gateway/platforms/discord.py
      @@ -1695,6 +1695,47 @@ async def slash_background(interaction: discord.Interaction, prompt: str):
               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
      +        try:
      +            from hermes_cli.commands import discord_skill_commands
      +
      +            existing_names = {cmd.name for cmd in tree.get_commands()}
      +            remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))
      +
      +            skill_entries, skipped = discord_skill_commands(
      +                max_slots=remaining_slots,
      +                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
      +
      +                handler = _make_skill_handler(cmd_key)
      +                handler.__name__ = f"skill_{discord_name.replace('-', '_')}"
      +
      +                cmd = discord.app_commands.Command(
      +                    name=discord_name,
      +                    description=description,
      +                    callback=handler,
      +                )
      +                discord.app_commands.describe(args="Optional arguments for the skill")(cmd)
      +                tree.add_command(cmd)
      +
      +            if skipped:
      +                logger.warning(
      +                    "[%s] Discord slash command limit reached (%d): %d skill(s) not registered",
      +                    self.name, _DISCORD_CMD_LIMIT, skipped,
      +                )
      +        except Exception as exc:
      +            logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc)
      +
           def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
               """Build a MessageEvent from a Discord slash command interaction."""
               is_dm = isinstance(interaction.channel, discord.DMChannel)
    • hermes_cli/commands.py
      @@ -372,7 +372,11 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
           return result
       
       
      -_TG_NAME_LIMIT = 32
      +_CMD_NAME_LIMIT = 32
      +"""Max command name length shared by Telegram and Discord."""
      +
      +# Backward-compat alias — tests and external code may reference the old name.
      +_TG_NAME_LIMIT = _CMD_NAME_LIMIT
       
       # Telegram Bot API allows only lowercase a-z, 0-9, and underscores in
       # command names.  This regex strips everything else after initial conversion.
      @@ -394,13 +398,14 @@ def _sanitize_telegram_name(raw: str) -> str:
           return name.strip("_")
       
       
      -def _clamp_telegram_names(
      +def _clamp_command_names(
           entries: list[tuple[str, str]],
           reserved: set[str],
       ) -> list[tuple[str, str]]:
      -    """Enforce Telegram's 32-char command name limit with collision avoidance.
      +    """Enforce 32-char command name limit with collision avoidance.
       
      -    Names exceeding 32 chars are truncated.  If truncation creates a duplicate
      +    Both Telegram and Discord cap slash command names at 32 characters.
      +    Names exceeding the limit are truncated.  If truncation creates a duplicate
           (against *reserved* names or earlier entries in the same batch), the name is
           shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
           If all 10 digit slots are taken the entry is silently dropped.
      @@ -408,10 +413,10 @@ def _clamp_telegram_names(
           used: set[str] = set(reserved)
           result: list[tuple[str, str]] = []
           for name, desc in entries:
      -        if len(name) > _TG_NAME_LIMIT:
      -            candidate = name[:_TG_NAME_LIMIT]
      +        if len(name) > _CMD_NAME_LIMIT:
      +            candidate = name[:_CMD_NAME_LIMIT]
                   if candidate in used:
      -                prefix = name[:_TG_NAME_LIMIT - 1]
      +                prefix = name[:_CMD_NAME_LIMIT - 1]
                       for digit in range(10):
                           candidate = f"{prefix}{digit}"
                           if candidate not in used:
      @@ -427,63 +432,83 @@ def _clamp_telegram_names(
           return result
       
       
      -def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
      -    """Return Telegram menu commands capped to the Bot API limit.
      +# Backward-compat alias.
      +_clamp_telegram_names = _clamp_command_names
       
      -    Priority order (higher priority = never bumped by overflow):
      -      1. Core CommandDef commands (always included)
      -      2. Plugin slash commands (take precedence over skills)
      -      3. Built-in skill commands (fill remaining slots, alphabetical)
       
      -    Skills are the only tier that gets trimmed when the cap is hit.
      -    User-installed hub skills are excluded — accessible via /skills.
      -    Skills disabled for the ``"telegram"`` platform (via ``hermes skills
      -    config``) are excluded from the menu entirely.
      +# ---------------------------------------------------------------------------
      +# Shared skill/plugin collection for gateway platforms
      +# ---------------------------------------------------------------------------
      +
      +def _collect_gateway_skill_entries(
      +    platform: str,
      +    max_slots: int,
      +    reserved_names: set[str],
      +    desc_limit: int = 100,
      +    sanitize_name: "Callable[[str], str] | None" = None,
      +) -> tuple[list[tuple[str, str, str]], int]:
      +    """Collect plugin + skill entries for a gateway platform.
      +
      +    Priority order:
      +      1. Plugin slash commands (take precedence over skills)
      +      2. Built-in skill commands (fill remaining slots, alphabetical)
      +
      +    Only skills are trimmed when the cap is reached.
      +    Hub-installed skills are excluded.  Per-platform disabled skills are
      +    excluded.
      +
      +    Args:
      +        platform: Platform identifier for per-platform skill filtering
      +            (``"telegram"``, ``"discord"``, etc.).
      +        max_slots: Maximum number of entries to return (remaining slots after
      +            built-in/core commands).
      +        reserved_names: Names already taken by built-in commands.  Mutated
      +            in-place as new names are added.
      +        desc_limit: Max description length (40 for Telegram, 100 for Discord).
      +        sanitize_name: Optional name transform applied before clamping, e.g.
      +            :func:`_sanitize_telegram_name` for Telegram.  May return an
      +            empty string to signal "skip this entry".
       
           Returns:
      -        (menu_commands, hidden_count) where hidden_count is the number of
      -        skill commands omitted due to the cap.
      +        ``(entries, hidden_count)`` where *entries* is a list of
      +        ``(name, description, cmd_key)`` triples and *hidden_count* is the
      +        number of skill entries dropped due to the cap.  ``cmd_key`` is the
      +        original ``/skill-name`` key from :func:`get_skill_commands`.
           """
      -    core_commands = list(telegram_bot_commands())
      -    # Reserve core names so plugin/skill truncation can't collide with them
      -    reserved_names = {n for n, _ in core_commands}
      -    all_commands = list(core_commands)
      +    all_entries: list[tuple[str, str, str]] = []
       
      -    # Plugin slash commands get priority over skills
      -    plugin_entries: list[tuple[str, str]] = []
      +    # --- Tier 1: Plugin slash commands (never trimmed) ---------------------
      +    plugin_pairs: list[tuple[str, str]] = []
           try:
               from hermes_cli.plugins import get_plugin_manager
               pm = get_plugin_manager()
               plugin_cmds = getattr(pm, "_plugin_commands", {})
               for cmd_name in sorted(plugin_cmds):
      -            tg_name = _sanitize_telegram_name(cmd_name)
      -            if not tg_name:
      +            name = sanitize_name(cmd_name) if sanitize_name else cmd_name
      +            if not name:
                       continue
                   desc = "Plugin command"
      -            if len(desc) > 40:
      -                desc = desc[:37] + "..."
      -            plugin_entries.append((tg_name, desc))
      +            if len(desc) > desc_limit:
      +                desc = desc[:desc_limit - 3] + "..."
      +            plugin_pairs.append((name, desc))
           except Exception:
               pass
       
      -    # Clamp plugin names to 32 chars with collision avoidance
      -    plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names)
      -    reserved_names.update(n for n, _ in plugin_entries)
      -    all_commands.extend(plugin_entries)
      +    plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)
      +    reserved_names.update(n for n, _ in plugin_pairs)
      +    # Plugins have no cmd_key — use empty string as placeholder
      +    for n, d in plugin_pairs:
      +        all_entries.append((n, d, ""))
       
      -    # Load per-platform disabled skills so they don't consume menu slots.
      -    # get_skill_commands() already filters the *global* disabled list, but
      -    # per-platform overrides (skills.platform_disabled.telegram) were never
      -    # applied here — that's what this block fixes.
      +    # --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
           _platform_disabled: set[str] = set()
           try:
               from agent.skill_utils import get_disabled_skill_names
      -        _platform_disabled = get_disabled_skill_names(platform="telegram")
      +        _platform_disabled = get_disabled_skill_names(platform=platform)
           except Exception:
               pass
       
      -    # Remaining slots go to built-in skill commands (not hub-installed).
      -    skill_entries: list[tuple[str, str]] = []
      +    skill_triples: list[tuple[str, str, str]] = []
           try:
               from agent.skill_commands import get_skill_commands
               from tools.skills_tool import SKILLS_DIR
      @@ -497,32 +522,103 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str
                       continue
                   if skill_path.startswith(_hub_dir):
                       continue
      -            # Skip skills disabled for telegram
                   skill_name = info.get("name", "")
                   if skill_name in _platform_disabled:
                       continue
      -            name = _sanitize_telegram_name(cmd_key.lstrip("/"))
      +            raw_name = cmd_key.lstrip("/")
      +            name = sanitize_name(raw_name) if sanitize_name else raw_name
                   if not name:
                       continue
                   desc = info.get("description", "")
      -            # Keep descriptions short — setMyCommands has an undocumented
      -            # total payload limit.  40 chars fits 100 commands safely.
      -            if len(desc) > 40:
      -                desc = desc[:37] + "..."
      -            skill_entries.append((name, desc))
      +            if len(desc) > desc_limit:
      +                desc = desc[:desc_limit - 3] + "..."
      +            skill_triples.append((name, desc, cmd_key))
           except Exception:
               pass
       
      -    # Clamp skill names to 32 chars with collision avoidance
      -    skill_entries = _clamp_telegram_names(skill_entries, reserved_names)
      +    # Clamp names; _clamp_command_names works on (name, desc) pairs so we
      +    # need to zip/unzip.
      +    skill_pairs = [(n, d) for n, d, _ in skill_triples]
      +    key_by_pair = {(n, d): k for n, d, k in skill_triples}
      +    skill_pairs = _clamp_command_names(skill_pairs, reserved_names)
      +
      +    # Skills fill remaining slots — only tier that gets trimmed
      +    remaining = max(0, max_slots - len(all_entries))
      +    hidden_count = max(0, len(skill_pairs) - remaining)
      +    for n, d in skill_pairs[:remaining]:
      +        all_entries.append((n, d, key_by_pair.get((n, d), "")))
      +
      +    return all_entries[:max_slots], hidden_count
      +
      +
      +# ---------------------------------------------------------------------------
      +# Platform-specific wrappers
      +# ---------------------------------------------------------------------------
      +
      +def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
      +    """Return Telegram menu commands capped to the Bot API limit.
      +
      +    Priority order (higher priority = never bumped by overflow):
      +      1. Core CommandDef commands (always included)
      +      2. Plugin slash commands (take precedence over skills)
      +      3. Built-in skill commands (fill remaining slots, alphabetical)
      +
      +    Skills are the only tier that gets trimmed when the cap is hit.
      +    User-installed hub skills are excluded — accessible via /skills.
      +    Skills disabled for the ``"telegram"`` platform (via ``hermes skills
      +    config``) are excluded from the menu entirely.
      +
      +    Returns:
      +        (menu_commands, hidden_count) where hidden_count is the number of
      +        skill commands omitted due to the cap.
      +    """
      +    core_commands = list(telegram_bot_commands())
      +    reserved_names = {n for n, _ in core_commands}
      +    all_commands = list(core_commands)
       
      -    # Skills fill remaining slots — they're the only tier that gets trimmed
           remaining_slots = max(0, max_commands - len(all_commands))
      -    hidden_count = max(0, len(skill_entries) - remaining_slots)
      -    all_commands.extend(skill_entries[:remaining_slots])
      +    entries, hidden_count = _collect_gateway_skill_entries(
      +        platform="telegram",
      +        max_slots=remaining_slots,
      +        reserved_names=reserved_names,
      +        desc_limit=40,
      +        sanitize_name=_sanitize_telegram_name,
      +    )
      +    # Drop the cmd_key — Telegram only needs (name, desc) pairs.
      +    all_commands.extend((n, d) for n, d, _k in entries)
           return all_commands[:max_commands], hidden_count
       
       
      +def discord_skill_commands(
      +    max_slots: int,
      +    reserved_names: set[str],
      +) -> tuple[list[tuple[str, str, str]], int]:
      +    """Return skill entries for Discord slash command registration.
      +
      +    Same priority and filtering logic as :func:`telegram_menu_commands`
      +    (plugins > skills, hub excluded, per-platform disabled excluded), but
      +    adapted for Discord's constraints:
      +
      +    - Hyphens are allowed in names (no ``-`` → ``_`` sanitization)
      +    - Descriptions capped at 100 chars (Discord's per-field max)
      +
      +    Args:
      +        max_slots: Available command slots (100 minus existing built-in count).
      +        reserved_names: Names of already-registered built-in commands.
      +
      +    Returns:
      +        ``(entries, hidden_count)`` where *entries* is a list of
      +        ``(discord_name, description, cmd_key)`` triples.  ``cmd_key`` is
      +        the original ``/skill-name`` key needed for the slash handler callback.
      +    """
      +    return _collect_gateway_skill_entries(
      +        platform="discord",
      +        max_slots=max_slots,
      +        reserved_names=set(reserved_names),  # copy — don't mutate caller's set
      +        desc_limit=100,
      +    )
      +
      +
       def slack_subcommand_map() -> dict[str, str]:
           """Return subcommand -> /command mapping for Slack /hermes handler.
       
    • tests/hermes_cli/test_commands.py
      @@ -12,9 +12,12 @@
           SUBCOMMANDS,
           SlashCommandAutoSuggest,
           SlashCommandCompleter,
      +    _CMD_NAME_LIMIT,
           _TG_NAME_LIMIT,
      +    _clamp_command_names,
           _clamp_telegram_names,
           _sanitize_telegram_name,
      +    discord_skill_commands,
           gateway_help_lines,
           resolve_command,
           slack_subcommand_map,
      @@ -751,3 +754,238 @@ def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):
               assert "valid_skill" in menu_names
               # No empty string in menu names
               assert "" not in menu_names
      +
      +
      +# ---------------------------------------------------------------------------
      +# Backward-compat aliases
      +# ---------------------------------------------------------------------------
      +
      +class TestBackwardCompatAliases:
      +    """The renamed constants/functions still exist under the old names."""
      +
      +    def test_tg_name_limit_alias(self):
      +        assert _TG_NAME_LIMIT == _CMD_NAME_LIMIT == 32
      +
      +    def test_clamp_telegram_names_is_clamp_command_names(self):
      +        assert _clamp_telegram_names is _clamp_command_names
      +
      +
      +# ---------------------------------------------------------------------------
      +# Discord skill command registration
      +# ---------------------------------------------------------------------------
      +
      +class TestDiscordSkillCommands:
      +    """Tests for discord_skill_commands() — centralized skill registration."""
      +
      +    def test_returns_skill_entries(self, tmp_path, monkeypatch):
      +        """Skills under SKILLS_DIR (not .hub) should be returned."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        fake_cmds = {
      +            "/gif-search": {
      +                "name": "gif-search",
      +                "description": "Search for GIFs",
      +                "skill_md_path": f"{fake_skills_dir}/gif-search/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/gif-search",
      +            },
      +            "/code-review": {
      +                "name": "code-review",
      +                "description": "Review code changes",
      +                "skill_md_path": f"{fake_skills_dir}/code-review/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/code-review",
      +            },
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, hidden = discord_skill_commands(
      +                max_slots=50, reserved_names=set(),
      +            )
      +
      +        names = {n for n, _d, _k in entries}
      +        assert "gif-search" in names
      +        assert "code-review" in names
      +        assert hidden == 0
      +        # Verify cmd_key is preserved for handler callbacks
      +        keys = {k for _n, _d, k in entries}
      +        assert "/gif-search" in keys
      +        assert "/code-review" in keys
      +
      +    def test_names_allow_hyphens(self, tmp_path, monkeypatch):
      +        """Discord names should keep hyphens (unlike Telegram's _ sanitization)."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        fake_cmds = {
      +            "/my-cool-skill": {
      +                "name": "my-cool-skill",
      +                "description": "A cool skill",
      +                "skill_md_path": f"{fake_skills_dir}/my-cool-skill/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/my-cool-skill",
      +            },
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, _ = discord_skill_commands(
      +                max_slots=50, reserved_names=set(),
      +            )
      +
      +        assert entries[0][0] == "my-cool-skill"  # hyphens preserved
      +
      +    def test_cap_enforcement(self, tmp_path, monkeypatch):
      +        """Entries beyond max_slots should be hidden."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        fake_cmds = {
      +            f"/skill-{i:03d}": {
      +                "name": f"skill-{i:03d}",
      +                "description": f"Skill {i}",
      +                "skill_md_path": f"{fake_skills_dir}/skill-{i:03d}/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/skill-{i:03d}",
      +            }
      +            for i in range(20)
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, hidden = discord_skill_commands(
      +                max_slots=5, reserved_names=set(),
      +            )
      +
      +        assert len(entries) == 5
      +        assert hidden == 15
      +
      +    def test_excludes_discord_disabled_skills(self, tmp_path, monkeypatch):
      +        """Skills disabled for discord should not appear."""
      +        from unittest.mock import patch
      +
      +        config_file = tmp_path / "config.yaml"
      +        config_file.write_text(
      +            "skills:\n"
      +            "  platform_disabled:\n"
      +            "    discord:\n"
      +            "      - secret-skill\n"
      +        )
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        fake_cmds = {
      +            "/secret-skill": {
      +                "name": "secret-skill",
      +                "description": "Should not appear",
      +                "skill_md_path": f"{fake_skills_dir}/secret-skill/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/secret-skill",
      +            },
      +            "/public-skill": {
      +                "name": "public-skill",
      +                "description": "Should appear",
      +                "skill_md_path": f"{fake_skills_dir}/public-skill/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/public-skill",
      +            },
      +        }
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, _ = discord_skill_commands(
      +                max_slots=50, reserved_names=set(),
      +            )
      +
      +        names = {n for n, _d, _k in entries}
      +        assert "secret-skill" not in names
      +        assert "public-skill" in names
      +
      +    def test_reserved_names_not_overwritten(self, tmp_path, monkeypatch):
      +        """Skills whose names collide with built-in commands should be skipped."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        fake_cmds = {
      +            "/status": {
      +                "name": "status",
      +                "description": "Skill that collides with built-in",
      +                "skill_md_path": f"{fake_skills_dir}/status/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/status",
      +            },
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, _ = discord_skill_commands(
      +                max_slots=50, reserved_names={"status"},
      +            )
      +
      +        names = {n for n, _d, _k in entries}
      +        assert "status" not in names
      +
      +    def test_description_truncated_at_100_chars(self, tmp_path, monkeypatch):
      +        """Descriptions exceeding 100 chars should be truncated."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        long_desc = "x" * 150
      +        fake_cmds = {
      +            "/verbose-skill": {
      +                "name": "verbose-skill",
      +                "description": long_desc,
      +                "skill_md_path": f"{fake_skills_dir}/verbose-skill/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/verbose-skill",
      +            },
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, _ = discord_skill_commands(
      +                max_slots=50, reserved_names=set(),
      +            )
      +
      +        assert len(entries[0][1]) == 100
      +        assert entries[0][1].endswith("...")
      +
      +    def test_all_names_within_32_chars(self, tmp_path, monkeypatch):
      +        """All returned names must respect the 32-char Discord limit."""
      +        from unittest.mock import patch
      +
      +        fake_skills_dir = str(tmp_path / "skills")
      +        long_name = "a" * 50
      +        fake_cmds = {
      +            f"/{long_name}": {
      +                "name": long_name,
      +                "description": "Long name skill",
      +                "skill_md_path": f"{fake_skills_dir}/{long_name}/SKILL.md",
      +                "skill_dir": f"{fake_skills_dir}/{long_name}",
      +            },
      +        }
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        (tmp_path / "skills").mkdir(exist_ok=True)
      +        with (
      +            patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
      +            patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
      +        ):
      +            entries, _ = discord_skill_commands(
      +                max_slots=50, reserved_names=set(),
      +            )
      +
      +        for name, _d, _k in entries:
      +            assert len(name) <= _CMD_NAME_LIMIT, (
      +                f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})"
      +            )

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #5498 feat(discord): register installed skills as native slash commands · merged via another change → #5603, #5480
  • #7805 fix(discord): move slash command sync to background to prevent reconnect loop · other → #8334, #7456, #7452, #7979
Closed issues · 2
  • Closed#5480feat(discord): register installed skills as native slash commands — parity with Telegram v0.7.0
  • Closed#7137[Bug]: Fix Discord connectivity (need to reduce registered skills to <100)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#5603
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
6 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.