mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
97d6813f51
commit
5ceed021dc
73 changed files with 163 additions and 4 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
114
gateway/run.py
114
gateway/run.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue