mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: implement register_command() on plugin context (#10626)
Complete the half-built plugin slash command system. The dispatch code in cli.py and gateway/run.py already called get_plugin_command_handler() but the registration side was never implemented. Changes: - Add register_command() to PluginContext — stores handler, description, and plugin name; normalizes names; rejects conflicts with built-in commands - Add _plugin_commands dict to PluginManager - Add commands_registered tracking on LoadedPlugin - Add get_plugin_command_handler() and get_plugin_commands() module-level convenience functions - Fix commands.py to use actual plugin description in Telegram bot menu (was hardcoded 'Plugin command') - Add plugin commands to SlashCommandCompleter autocomplete - Show command count in /plugins display - 12 new tests covering registration, conflict detection, normalization, handler dispatch, and introspection Closes #10495
This commit is contained in:
parent
df714add9d
commit
498b995c13
4 changed files with 246 additions and 6 deletions
3
cli.py
3
cli.py
|
|
@ -5484,7 +5484,8 @@ class HermesCLI:
|
|||
version = f" v{p['version']}" if p["version"] else ""
|
||||
tools = f"{p['tools']} tools" if p["tools"] else ""
|
||||
hooks = f"{p['hooks']} hooks" if p["hooks"] else ""
|
||||
parts = [x for x in [tools, hooks] if x]
|
||||
commands = f"{p['commands']} commands" if p.get("commands") else ""
|
||||
parts = [x for x in [tools, hooks, commands] if x]
|
||||
detail = f" ({', '.join(parts)})" if parts else ""
|
||||
error = f" — {p['error']}" if p["error"] else ""
|
||||
print(f" {status} {p['name']}{version}{detail}{error}")
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ def _collect_gateway_skill_entries(
|
|||
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
||||
if not name:
|
||||
continue
|
||||
desc = "Plugin command"
|
||||
desc = plugin_cmds[cmd_name].get("description", "Plugin command")
|
||||
if len(desc) > desc_limit:
|
||||
desc = desc[:desc_limit - 3] + "..."
|
||||
plugin_pairs.append((name, desc))
|
||||
|
|
@ -1139,6 +1139,22 @@ class SlashCommandCompleter(Completer):
|
|||
display_meta=f"⚡ {short_desc}",
|
||||
)
|
||||
|
||||
# Plugin-registered slash commands
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_commands
|
||||
for cmd_name, cmd_info in get_plugin_commands().items():
|
||||
if cmd_name.startswith(word):
|
||||
desc = str(cmd_info.get("description", "Plugin command"))
|
||||
short_desc = desc[:50] + ("..." if len(desc) > 50 else "")
|
||||
yield Completion(
|
||||
self._completion_text(cmd_name, word),
|
||||
start_position=-len(word),
|
||||
display=f"/{cmd_name}",
|
||||
display_meta=f"🔌 {short_desc}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline auto-suggest (ghost text) for slash commands
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ class LoadedPlugin:
|
|||
module: Optional[types.ModuleType] = None
|
||||
tools_registered: List[str] = field(default_factory=list)
|
||||
hooks_registered: List[str] = field(default_factory=list)
|
||||
commands_registered: List[str] = field(default_factory=list)
|
||||
enabled: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
|
|
@ -211,6 +212,53 @@ class PluginContext:
|
|||
}
|
||||
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
|
||||
|
||||
# -- slash command registration -------------------------------------------
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str = "",
|
||||
) -> None:
|
||||
"""Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions.
|
||||
|
||||
The handler signature is ``fn(raw_args: str) -> str | None``.
|
||||
It may also be an async callable — the gateway dispatch handles both.
|
||||
|
||||
Unlike ``register_cli_command()`` (which creates ``hermes <subcommand>``
|
||||
terminal commands), this registers in-session slash commands that users
|
||||
invoke during a conversation.
|
||||
|
||||
Names conflicting with built-in commands are rejected with a warning.
|
||||
"""
|
||||
clean = name.lower().strip().lstrip("/").replace(" ", "-")
|
||||
if not clean:
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a command with an empty name.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
|
||||
# Reject if it conflicts with a built-in command
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
if resolve_command(clean) is not None:
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register command '/%s' which conflicts "
|
||||
"with a built-in command. Skipping.",
|
||||
self.manifest.name, clean,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass # If commands module isn't available, skip the check
|
||||
|
||||
self._manager._plugin_commands[clean] = {
|
||||
"handler": handler,
|
||||
"description": description or "Plugin command",
|
||||
"plugin": self.manifest.name,
|
||||
}
|
||||
logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
|
||||
|
||||
# -- context engine registration -----------------------------------------
|
||||
|
||||
def register_context_engine(self, engine) -> None:
|
||||
|
|
@ -323,6 +371,7 @@ class PluginManager:
|
|||
self._plugin_tool_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
|
|
@ -485,6 +534,10 @@ class PluginManager:
|
|||
for h in p.hooks_registered
|
||||
}
|
||||
)
|
||||
loaded.commands_registered = [
|
||||
c for c in self._plugin_commands
|
||||
if self._plugin_commands[c].get("plugin") == manifest.name
|
||||
]
|
||||
loaded.enabled = True
|
||||
|
||||
except Exception as exc:
|
||||
|
|
@ -598,6 +651,7 @@ class PluginManager:
|
|||
"enabled": loaded.enabled,
|
||||
"tools": len(loaded.tools_registered),
|
||||
"hooks": len(loaded.hooks_registered),
|
||||
"commands": len(loaded.commands_registered),
|
||||
"error": loaded.error,
|
||||
}
|
||||
)
|
||||
|
|
@ -699,6 +753,20 @@ def get_plugin_context_engine():
|
|||
return get_plugin_manager()._context_engine
|
||||
|
||||
|
||||
def get_plugin_command_handler(name: str) -> Optional[Callable]:
|
||||
"""Return the handler for a plugin-registered slash command, or ``None``."""
|
||||
entry = get_plugin_manager()._plugin_commands.get(name)
|
||||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
def get_plugin_commands() -> Dict[str, dict]:
|
||||
"""Return the full plugin commands dict (name → {handler, description, plugin}).
|
||||
|
||||
Safe to call before discovery — returns an empty dict if no plugins loaded.
|
||||
"""
|
||||
return get_plugin_manager()._plugin_commands
|
||||
|
||||
|
||||
def get_plugin_toolsets() -> List[tuple]:
|
||||
"""Return plugin toolsets as ``(key, label, description)`` tuples.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from hermes_cli.plugins import (
|
|||
PluginManager,
|
||||
PluginManifest,
|
||||
get_plugin_manager,
|
||||
get_plugin_command_handler,
|
||||
get_plugin_commands,
|
||||
get_pre_tool_call_block_message,
|
||||
discover_plugins,
|
||||
invoke_hook,
|
||||
|
|
@ -605,7 +607,160 @@ class TestPreLlmCallTargetRouting:
|
|||
assert "plain text C" in _plugin_user_context
|
||||
|
||||
|
||||
# NOTE: TestPluginCommands removed – register_command() was never implemented
|
||||
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
|
||||
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
|
||||
# integration — all of which are unimplemented features.
|
||||
# ── TestPluginCommands ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPluginCommands:
|
||||
"""Tests for plugin slash command registration via register_command()."""
|
||||
|
||||
def test_register_command_basic(self):
|
||||
"""register_command() stores handler, description, and plugin name."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
handler = lambda args: f"echo {args}"
|
||||
ctx.register_command("mycmd", handler, description="My custom command")
|
||||
|
||||
assert "mycmd" in mgr._plugin_commands
|
||||
entry = mgr._plugin_commands["mycmd"]
|
||||
assert entry["handler"] is handler
|
||||
assert entry["description"] == "My custom command"
|
||||
assert entry["plugin"] == "test-plugin"
|
||||
|
||||
def test_register_command_normalizes_name(self):
|
||||
"""Names are lowercased, stripped, and leading slashes removed."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command("/MyCmd ", lambda a: a, description="test")
|
||||
assert "mycmd" in mgr._plugin_commands
|
||||
assert "/MyCmd " not in mgr._plugin_commands
|
||||
|
||||
def test_register_command_empty_name_rejected(self, caplog):
|
||||
"""Empty name after normalization is rejected with a warning."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ctx.register_command("", lambda a: a)
|
||||
assert len(mgr._plugin_commands) == 0
|
||||
assert "empty name" in caplog.text
|
||||
|
||||
def test_register_command_builtin_conflict_rejected(self, caplog):
|
||||
"""Commands that conflict with built-in names are rejected."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ctx.register_command("help", lambda a: a)
|
||||
assert "help" not in mgr._plugin_commands
|
||||
assert "conflicts" in caplog.text.lower()
|
||||
|
||||
def test_register_command_default_description(self):
|
||||
"""Missing description defaults to 'Plugin command'."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
ctx.register_command("status-cmd", lambda a: a)
|
||||
assert mgr._plugin_commands["status-cmd"]["description"] == "Plugin command"
|
||||
|
||||
def test_get_plugin_command_handler_found(self):
|
||||
"""get_plugin_command_handler() returns the handler for a registered command."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
handler = lambda args: f"result: {args}"
|
||||
ctx.register_command("mycmd", handler, description="test")
|
||||
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
result = get_plugin_command_handler("mycmd")
|
||||
assert result is handler
|
||||
|
||||
def test_get_plugin_command_handler_not_found(self):
|
||||
"""get_plugin_command_handler() returns None for unregistered commands."""
|
||||
mgr = PluginManager()
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
assert get_plugin_command_handler("nonexistent") is None
|
||||
|
||||
def test_get_plugin_commands_returns_dict(self):
|
||||
"""get_plugin_commands() returns the full commands dict."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
ctx.register_command("cmd-a", lambda a: a, description="A")
|
||||
ctx.register_command("cmd-b", lambda a: a, description="B")
|
||||
|
||||
with patch("hermes_cli.plugins._plugin_manager", mgr):
|
||||
cmds = get_plugin_commands()
|
||||
assert "cmd-a" in cmds
|
||||
assert "cmd-b" in cmds
|
||||
assert cmds["cmd-a"]["description"] == "A"
|
||||
|
||||
def test_commands_tracked_on_loaded_plugin(self, tmp_path, monkeypatch):
|
||||
"""Commands registered during discover_and_load() are tracked on LoadedPlugin."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "cmd-plugin",
|
||||
register_body=(
|
||||
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
loaded = mgr._plugins["cmd-plugin"]
|
||||
assert loaded.enabled
|
||||
assert "mycmd" in loaded.commands_registered
|
||||
|
||||
def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch):
|
||||
"""list_plugins() includes command count."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "cmd-plugin",
|
||||
register_body=(
|
||||
'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
info = mgr.list_plugins()
|
||||
assert len(info) == 1
|
||||
assert info[0]["commands"] == 1
|
||||
|
||||
def test_handler_receives_raw_args(self):
|
||||
"""The handler is called with the raw argument string."""
|
||||
mgr = PluginManager()
|
||||
manifest = PluginManifest(name="test-plugin", source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
|
||||
received = []
|
||||
ctx.register_command("echo", lambda args: received.append(args) or "ok")
|
||||
|
||||
handler = mgr._plugin_commands["echo"]["handler"]
|
||||
handler("hello world")
|
||||
assert received == ["hello world"]
|
||||
|
||||
def test_multiple_plugins_register_different_commands(self):
|
||||
"""Multiple plugins can each register their own commands."""
|
||||
mgr = PluginManager()
|
||||
|
||||
for plugin_name, cmd_name in [("plugin-a", "cmd-a"), ("plugin-b", "cmd-b")]:
|
||||
manifest = PluginManifest(name=plugin_name, source="user")
|
||||
ctx = PluginContext(manifest, mgr)
|
||||
ctx.register_command(cmd_name, lambda a: a, description=f"From {plugin_name}")
|
||||
|
||||
assert "cmd-a" in mgr._plugin_commands
|
||||
assert "cmd-b" in mgr._plugin_commands
|
||||
assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a"
|
||||
assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue