feat(gateway): expose plugin slash commands natively on all platforms + decision-capable command hook

Plugin slash commands now surface as first-class commands in every gateway
enumerator — Discord native slash picker, Telegram BotCommand menu, Slack
/hermes subcommand map — without a separate per-platform plugin API.

The existing 'command:<name>' gateway hook gains a decision protocol via
HookRegistry.emit_collect(): handlers that return a dict with
{'decision': 'deny'|'handled'|'rewrite'|'allow'} can intercept slash
command dispatch before core handling runs, unifying what would otherwise
have been a parallel 'pre_gateway_command' hook surface.

Changes:

- gateway/hooks.py: add HookRegistry.emit_collect() that fires the same
  handler set as emit() but collects non-None return values. Backward
  compatible — fire-and-forget telemetry hooks still work via emit().
- hermes_cli/plugins.py: add optional 'args_hint' param to
  register_command() so plugins can opt into argument-aware native UI
  registration (Discord arg picker, future platforms).
- hermes_cli/commands.py: add _iter_plugin_command_entries() helper and
  merge plugin commands into telegram_bot_commands() and
  slack_subcommand_map(). New is_gateway_known_command() recognizes both
  built-in and plugin commands so the gateway hook fires for either.
- gateway/platforms/discord.py: extract _build_auto_slash_command helper
  from the COMMAND_REGISTRY auto-register loop and reuse it for
  plugin-registered commands. Built-in name conflicts are skipped.
- gateway/run.py: before normal slash dispatch, call emit_collect on
  command:<canonical> and honor deny/handled/rewrite/allow decisions.
  Hook now fires for plugin commands too.
- scripts/release.py: AUTHOR_MAP entry for @Magaav.
- Tests: emit_collect semantics, plugin command surfacing per platform,
  decision protocol (deny/handled/rewrite/allow + non-dict tolerance),
  Discord plugin auto-registration + conflict skipping, is_gateway_known_command.

Salvaged from #14131 (@Magaav). Original PR added a parallel
'pre_gateway_command' hook and a platform-keyed plugin command
registry; this re-implementation reuses the existing 'command:<name>'
hook and treats plugin commands as platform-agnostic so the same
capability reaches Telegram and Slack without new API surface.

Co-authored-by: Magaav <73175452+Magaav@users.noreply.github.com>
This commit is contained in:
Teknium 2026-04-22 15:01:50 -07:00 committed by Teknium
parent c96a548bde
commit 51ca575994
11 changed files with 778 additions and 58 deletions

View file

@ -41,7 +41,11 @@ def _make_runner():
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.hooks = SimpleNamespace(
emit=AsyncMock(),
emit_collect=AsyncMock(return_value=[]),
loaded_hooks=False,
)
session_entry = SessionEntry(
session_key=build_session_key(_make_source()),
@ -164,3 +168,206 @@ async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch)
# Whatever /reload_mcp returns, it must not be the unknown-command guard.
if result is not None:
assert "Unknown command" not in result
# ------------------------------------------------------------------
# command:<name> decision hook — deny / handled / rewrite
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_command_hook_can_deny_before_dispatch(monkeypatch):
"""A handler returning {"decision": "deny"} blocks a slash command early."""
import gateway.run as gateway_run
runner = _make_runner()
runner._run_agent = AsyncMock(
side_effect=AssertionError("denied slash command leaked to the agent")
)
runner._handle_status_command = AsyncMock(
side_effect=AssertionError("denied slash command reached its handler")
)
runner.hooks.emit_collect = AsyncMock(
return_value=[{"decision": "deny", "message": "Blocked by ACL"}]
)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
result = await runner._handle_message(_make_event("/status"))
assert result == "Blocked by ACL"
runner._run_agent.assert_not_called()
# The emit_collect call should use the canonical command name.
call_args = runner.hooks.emit_collect.await_args
assert call_args.args[0] == "command:status"
@pytest.mark.asyncio
async def test_command_hook_deny_without_message_uses_default(monkeypatch):
"""A deny decision with no message falls back to a generic blocked string."""
import gateway.run as gateway_run
runner = _make_runner()
runner._handle_status_command = AsyncMock(
side_effect=AssertionError("denied slash command reached its handler")
)
runner.hooks.emit_collect = AsyncMock(return_value=[{"decision": "deny"}])
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
result = await runner._handle_message(_make_event("/status"))
assert result is not None
assert "blocked" in result.lower()
@pytest.mark.asyncio
async def test_command_hook_can_mark_command_as_handled(monkeypatch):
"""A handled decision short-circuits dispatch cleanly with a custom reply."""
import gateway.run as gateway_run
runner = _make_runner()
runner._handle_status_command = AsyncMock(
side_effect=AssertionError("handled slash command reached its handler")
)
runner.hooks.emit_collect = AsyncMock(
return_value=[{"decision": "handled", "message": "Already handled upstream"}]
)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
result = await runner._handle_message(_make_event("/status"))
assert result == "Already handled upstream"
@pytest.mark.asyncio
async def test_command_hook_allow_decision_is_passthrough(monkeypatch):
"""A handler returning {"decision": "allow"} must NOT prevent normal dispatch."""
import gateway.run as gateway_run
runner = _make_runner()
runner._handle_status_command = AsyncMock(return_value="status: ok")
runner.hooks.emit_collect = AsyncMock(
return_value=[{"decision": "allow"}]
)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
result = await runner._handle_message(_make_event("/status"))
assert result == "status: ok"
runner._handle_status_command.assert_awaited_once()
@pytest.mark.asyncio
async def test_command_hook_non_dict_return_values_ignored(monkeypatch):
"""Hook return values that aren't dicts must not break dispatch."""
import gateway.run as gateway_run
runner = _make_runner()
runner._handle_status_command = AsyncMock(return_value="status: ok")
runner.hooks.emit_collect = AsyncMock(
return_value=["some string", 42, None, {}]
)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
result = await runner._handle_message(_make_event("/status"))
assert result == "status: ok"
@pytest.mark.asyncio
async def test_command_hook_fires_for_plugin_registered_command(monkeypatch):
"""Plugin-registered slash commands should also trigger command:<name> hooks."""
import gateway.run as gateway_run
runner = _make_runner()
runner._run_agent = AsyncMock(
side_effect=AssertionError("plugin command leaked to the agent")
)
runner.hooks.emit_collect = AsyncMock(
return_value=[{"decision": "handled", "message": "intercepted"}]
)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
# Stub plugin command lookup so is_gateway_known_command() recognizes /metricas.
from hermes_cli import plugins as _plugins_mod
monkeypatch.setattr(
_plugins_mod,
"get_plugin_commands",
lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}},
)
result = await runner._handle_message(_make_event("/metricas dias:7"))
assert result == "intercepted"
# Hook event name uses the plugin command as canonical.
call_args = runner.hooks.emit_collect.await_args
assert call_args.args[0] == "command:metricas"
# Args are passed through in both "args" and "raw_args" keys.
ctx = call_args.args[1]
assert ctx["raw_args"] == "dias:7"
@pytest.mark.asyncio
async def test_command_hook_rewrite_routes_to_plugin(monkeypatch):
"""A rewrite decision should re-resolve the command and route to the new one."""
import gateway.run as gateway_run
runner = _make_runner()
runner._run_agent = AsyncMock(
side_effect=AssertionError("rewritten command leaked to the agent")
)
call_log = []
async def _emit_collect(event_type, ctx):
call_log.append(event_type)
if event_type == "command:status":
return [
{
"decision": "rewrite",
"command_name": "metricas",
"raw_args": "dias:7",
}
]
return []
runner.hooks.emit_collect = AsyncMock(side_effect=_emit_collect)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
from hermes_cli import plugins as _plugins_mod
monkeypatch.setattr(
_plugins_mod,
"get_plugin_commands",
lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}},
)
monkeypatch.setattr(
_plugins_mod,
"get_plugin_command_handler",
lambda name: (lambda args: f"metrics {args}") if name == "metricas" else None,
)
result = await runner._handle_message(_make_event("/status"))
assert result == "metrics dias:7"
# First emit_collect fires on the original command; after rewrite the
# dispatcher does NOT re-fire for the new command (one decision per turn).
assert call_log == ["command:status"]