feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap (#3934)

* feat(gateway): skill-aware slash commands, paginated /commands, Telegram 100-cap

Map active skills to Telegram's slash command menu so users can
discover and invoke skills directly. Three changes:

1. Telegram menu now includes active skill commands alongside built-in
   commands, capped at 100 entries (Telegram Bot API limit). Overflow
   commands remain callable but hidden from the picker. Logged at
   startup when cap is hit.

2. New /commands [page] gateway command for paginated browsing of all
   commands + skills. /help now shows first 10 skill commands and
   points to /commands for the full list.

3. When a user types a slash command that matches a disabled or
   uninstalled skill, they get actionable guidance:
   - Disabled: 'Enable it with: hermes skills config'
   - Optional (not installed): 'Install with: hermes skills install official/<path>'

Built on ideas from PR #3921 by @kshitijk4poor.

* chore: move 21 niche skills to optional-skills

Move specialized/niche skills from built-in (skills/) to optional
(optional-skills/) to reduce the default skill count. Users can
install them with: hermes skills install official/<category>/<name>

Moved skills (21):
- mlops: accelerate, chroma, faiss, flash-attention,
  hermes-atropos-environments, huggingface-tokenizers, instructor,
  lambda-labs, llava, nemo-curator, pinecone, pytorch-lightning,
  qdrant, saelens, simpo, slime, tensorrt-llm, torchtitan
- research: domain-intel, duckduckgo-search
- devops: inference-sh cli

Built-in skills: 96 → 75
Optional skills: 22 → 43

* fix: only include repo built-in skills in Telegram menu, not user-installed

User-installed skills (from hub or manually added) stay accessible via
/skills and by typing the command directly, but don't get registered
in the Telegram slash command picker. Only skills whose SKILL.md is
under the repo's skills/ directory are included in the menu.

This keeps the Telegram menu focused on the curated built-in set while
user-installed skills remain discoverable through /skills and /commands.
This commit is contained in:
Teknium 2026-03-30 10:57:30 -07:00 committed by GitHub
parent 97d6813f51
commit 5ceed021dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 163 additions and 4 deletions

View file

@ -622,10 +622,16 @@ class TelegramAdapter(BasePlatformAdapter):
# gateway command there automatically adds it to the Telegram menu. # gateway command there automatically adds it to the Telegram menu.
try: try:
from telegram import BotCommand from telegram import BotCommand
from hermes_cli.commands import telegram_bot_commands from hermes_cli.commands import telegram_menu_commands
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
await self._bot.set_my_commands([ await self._bot.set_my_commands([
BotCommand(name, desc) for name, desc in telegram_bot_commands() BotCommand(name, desc) for name, desc in menu_commands
]) ])
if hidden_count:
logger.info(
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
self.name, len(menu_commands), hidden_count,
)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"[%s] Could not register Telegram command menu: %s", "[%s] Could not register Telegram command menu: %s",

View file

@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict:
} }
def _check_unavailable_skill(command_name: str) -> str | None:
"""Check if a command matches a known-but-inactive skill.
Returns a helpful message if the skill exists but is disabled or only
available as an optional install. Returns None if no match found.
"""
# Normalize: command uses hyphens, skill names may use hyphens or underscores
normalized = command_name.lower().replace("_", "-")
try:
from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names
disabled = _get_disabled_skill_names()
# Check disabled built-in skills
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
name = skill_md.parent.name.lower().replace("_", "-")
if name == normalized and name in disabled:
return (
f"The **{command_name}** skill is installed but disabled.\n"
f"Enable it with: `hermes skills config`"
)
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_hermes_home
repo_root = Path(__file__).resolve().parent.parent
optional_dir = repo_root / "optional-skills"
if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"):
name = skill_md.parent.name.lower().replace("_", "-")
if name == normalized:
# Build install path: official/<category>/<name>
rel = skill_md.parent.relative_to(optional_dir)
parts = list(rel.parts)
install_path = f"official/{'/'.join(parts)}"
return (
f"The **{command_name}** skill is available but not installed.\n"
f"Install it with: `hermes skills install {install_path}`"
)
except Exception:
pass
return None
def _platform_config_key(platform: "Platform") -> str: def _platform_config_key(platform: "Platform") -> str:
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value).""" """Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
return "cli" if platform == Platform.LOCAL else platform.value return "cli" if platform == Platform.LOCAL else platform.value
@ -1818,6 +1862,9 @@ class GatewayRunner:
if canonical == "help": if canonical == "help":
return await self._handle_help_command(event) return await self._handle_help_command(event)
if canonical == "commands":
return await self._handle_commands_command(event)
if canonical == "status": if canonical == "status":
return await self._handle_status_command(event) return await self._handle_status_command(event)
@ -1974,6 +2021,12 @@ class GatewayRunner:
if msg: if msg:
event.text = msg event.text = msg
# Fall through to normal message processing with skill content # Fall through to normal message processing with skill content
else:
# Not an active skill — check if it's a known-but-disabled or
# uninstalled skill and give actionable guidance.
_unavail_msg = _check_unavailable_skill(command)
if _unavail_msg:
return _unavail_msg
except Exception as e: except Exception as e:
logger.debug("Skill command check failed (non-fatal): %s", e) logger.debug("Skill command check failed (non-fatal): %s", e)
@ -3065,13 +3118,70 @@ class GatewayRunner:
from agent.skill_commands import get_skill_commands from agent.skill_commands import get_skill_commands
skill_cmds = get_skill_commands() skill_cmds = get_skill_commands()
if skill_cmds: if skill_cmds:
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):") lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):")
for cmd in sorted(skill_cmds): # Show first 10, then point to /commands for the rest
sorted_cmds = sorted(skill_cmds)
for cmd in sorted_cmds[:10]:
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
if len(sorted_cmds) > 10:
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
except Exception: except Exception:
pass pass
return "\n".join(lines) return "\n".join(lines)
async def _handle_commands_command(self, event: MessageEvent) -> str:
"""Handle /commands [page] - paginated list of all commands and skills."""
from hermes_cli.commands import gateway_help_lines
raw_args = event.get_command_args().strip()
if raw_args:
try:
requested_page = int(raw_args)
except ValueError:
return "Usage: `/commands [page]`"
else:
requested_page = 1
# Build combined entry list: built-in commands + skill commands
entries = list(gateway_help_lines())
try:
from agent.skill_commands import get_skill_commands
skill_cmds = get_skill_commands()
if skill_cmds:
entries.append("")
entries.append("⚡ **Skill Commands**:")
for cmd in sorted(skill_cmds):
desc = skill_cmds[cmd].get("description", "").strip() or "Skill command"
entries.append(f"`{cmd}` — {desc}")
except Exception:
pass
if not entries:
return "No commands available."
from gateway.config import Platform
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
page = max(1, min(requested_page, total_pages))
start = (page - 1) * page_size
page_entries = entries[start:start + page_size]
lines = [
f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})",
"",
*page_entries,
]
if total_pages > 1:
nav_parts = []
if page > 1:
nav_parts.append(f"`/commands {page - 1}` ← prev")
if page < total_pages:
nav_parts.append(f"next → `/commands {page + 1}`")
lines.extend(["", " | ".join(nav_parts)])
if page != requested_page:
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
return "\n".join(lines)
async def _handle_provider_command(self, event: MessageEvent) -> str: async def _handle_provider_command(self, event: MessageEvent) -> str:
"""Handle /provider command - show available providers.""" """Handle /provider command - show available providers."""
import yaml import yaml

View file

@ -118,6 +118,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
"Tools & Skills", cli_only=True), "Tools & Skills", cli_only=True),
# Info # Info
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
gateway_only=True, args_hint="[page]"),
CommandDef("help", "Show available commands", "Info"), CommandDef("help", "Show available commands", "Info"),
CommandDef("usage", "Show token usage for the current session", "Info"), CommandDef("usage", "Show token usage for the current session", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info", CommandDef("insights", "Show usage insights and analytics", "Info",
@ -361,6 +363,47 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
return result return result
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
"""Return Telegram menu commands (built-in + active skills), capped to the Bot API limit.
Built-in commands come first, then active skill commands. Commands beyond
``max_commands`` remain callable in the gateway; they are just omitted from
Telegram's native slash-command picker.
Returns:
(menu_commands, hidden_count) where hidden_count is the number of
commands omitted due to the cap.
"""
all_commands = list(telegram_bot_commands())
# Append active BUILT-IN skill commands only (not user-installed hub skills).
# User-installed skills stay accessible via /skills and by typing the command
# directly, but don't clutter the Telegram menu.
try:
from agent.skill_commands import get_skill_commands
from pathlib import Path
# The repo's built-in skills live under <repo>/skills/
_repo_skills_dir = str(Path(__file__).resolve().parent.parent / "skills")
skill_cmds = get_skill_commands()
for cmd_key in sorted(skill_cmds):
info = skill_cmds[cmd_key]
# Only include skills whose SKILL.md is in the repo's skills/ dir
skill_path = info.get("skill_md_path", "")
if not skill_path.startswith(_repo_skills_dir):
continue
name = cmd_key.lstrip("/").replace("-", "_")
desc = info.get("description", "")
# Telegram descriptions max 256 chars
if len(desc) > 256:
desc = desc[:253] + "..."
all_commands.append((name, desc))
except Exception:
pass
hidden_count = max(0, len(all_commands) - max_commands)
return all_commands[:max_commands], hidden_count
def slack_subcommand_map() -> dict[str, str]: def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack /hermes handler. """Return subcommand -> /command mapping for Slack /hermes handler.