feat(plugins): add pre_gateway_dispatch hook

Introduces a new plugin hook `pre_gateway_dispatch` fired once per
incoming MessageEvent in `_handle_message`, after the internal-event
guard but before the auth / pairing chain. Plugins may return a dict
to influence flow:

    {"action": "skip",    "reason": "..."}  -> drop (no reply)
    {"action": "rewrite", "text":   "..."}  -> replace event.text
    {"action": "allow"}  /  None             -> normal dispatch

Motivation: gateway-level message-flow patterns that don't fit cleanly
into any single adapter — e.g. listen-only group-chat windows (buffer
ambient messages, collapse on @mention), or human-handover silent
ingest (record messages while an owner handles the chat manually).
Today these require forking core; with this hook they can live in a
single profile-agnostic plugin.

Hook runs BEFORE auth so plugins can handle unauthorized senders
(e.g. customer-service handover ingest) without triggering the
pairing-code flow. Exceptions in plugin callbacks are caught and
logged; the first non-None action dict wins, remaining results are
ignored.

Includes:
- `VALID_HOOKS` entry + inline doc in `hermes_cli/plugins.py`
- Invocation block in `gateway/run.py::_handle_message`
- 5 new tests in `tests/gateway/test_pre_gateway_dispatch.py`
  (skip, rewrite, allow, exception safety, internal-event bypass)
- 2 additional tests in `tests/hermes_cli/test_plugins.py`
- Table entry in `website/docs/user-guide/features/plugins.md`

Made-with: Cursor
This commit is contained in:
Keira Voss 2026-04-21 17:38:54 +08:00 committed by Teknium
parent 8aa37a0cf9
commit 1ef1e4c669
5 changed files with 260 additions and 1 deletions

View file

@ -330,6 +330,33 @@ class TestPluginHooks:
assert "transform_terminal_output" in VALID_HOOKS
assert "transform_tool_result" in VALID_HOOKS
def test_valid_hooks_include_pre_gateway_dispatch(self):
assert "pre_gateway_dispatch" in VALID_HOOKS
def test_pre_gateway_dispatch_collects_action_dicts(self, tmp_path, monkeypatch):
"""pre_gateway_dispatch callbacks return action dicts (skip/rewrite/allow)."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
_make_plugin_dir(
plugins_dir, "predispatch_plugin",
register_body=(
'ctx.register_hook("pre_gateway_dispatch", '
'lambda **kw: {"action": "skip", "reason": "test"})'
),
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
results = mgr.invoke_hook(
"pre_gateway_dispatch",
event=object(),
gateway=object(),
session_store=object(),
)
assert len(results) == 1
assert results[0] == {"action": "skip", "reason": "test"}
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
"""Registered hooks are called on invoke_hook()."""
plugins_dir = tmp_path / "hermes_test" / "plugins"