From 1ef1e4c66989bf409de31bdbe94a0f18f98ac31c Mon Sep 17 00:00:00 2001 From: Keira Voss Date: Tue, 21 Apr 2026 17:38:54 +0800 Subject: [PATCH] feat(plugins): add pre_gateway_dispatch hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gateway/run.py | 46 ++++- hermes_cli/plugins.py | 8 + tests/gateway/test_pre_gateway_dispatch.py | 179 ++++++++++++++++++++ tests/hermes_cli/test_plugins.py | 27 +++ website/docs/user-guide/features/plugins.md | 1 + 5 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_pre_gateway_dispatch.py diff --git a/gateway/run.py b/gateway/run.py index 9409e245c..206f6e399 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14,6 +14,7 @@ Usage: """ import asyncio +import dataclasses import json import logging import os @@ -3145,7 +3146,50 @@ class GatewayRunner: # Internal events (e.g. background-process completion notifications) # are system-generated and must skip user authorization. - if getattr(event, "internal", False): + is_internal = bool(getattr(event, "internal", False)) + + # Fire pre_gateway_dispatch plugin hook for user-originated messages. + # Plugins receive the MessageEvent and may return a dict influencing flow: + # {"action": "skip", "reason": ...} -> drop (no reply, plugin handled) + # {"action": "rewrite", "text": ...} -> replace event.text, continue + # {"action": "allow"} / None -> normal dispatch + # Hook runs BEFORE auth so plugins can handle unauthorized senders + # (e.g. customer handover ingest) without triggering the pairing flow. + if not is_internal: + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _hook_results = _invoke_hook( + "pre_gateway_dispatch", + event=event, + gateway=self, + session_store=self.session_store, + ) + except Exception as _hook_exc: + logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc) + _hook_results = [] + + for _result in _hook_results: + if not isinstance(_result, dict): + continue + _action = _result.get("action") + if _action == "skip": + logger.info( + "pre_gateway_dispatch skip: reason=%s platform=%s chat=%s", + _result.get("reason"), + source.platform.value if source.platform else "unknown", + source.chat_id or "unknown", + ) + return None + if _action == "rewrite": + _new_text = _result.get("text") + if isinstance(_new_text, str): + event = dataclasses.replace(event, text=_new_text) + source = event.source + break + if _action == "allow": + break + + if is_internal: pass elif source.user_id is None: # Messages with no user identity (Telegram service messages, diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 28cb3b1b5..81856877f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -71,6 +71,14 @@ VALID_HOOKS: Set[str] = { "on_session_finalize", "on_session_reset", "subagent_stop", + # Gateway pre-dispatch hook. Fired once per incoming MessageEvent + # after core auth/internal-event guards but before command handling + # and agent dispatch. Plugins may return a dict to influence flow: + # {"action": "skip", "reason": "..."} -> drop message (no reply) + # {"action": "rewrite", "text": "..."} -> replace event.text, continue + # {"action": "allow"} / None -> normal dispatch + # Kwargs: event: MessageEvent, gateway: HermesGateway, session_store. + "pre_gateway_dispatch", } ENTRY_POINTS_GROUP = "hermes_agent.plugins" diff --git a/tests/gateway/test_pre_gateway_dispatch.py b/tests/gateway/test_pre_gateway_dispatch.py new file mode 100644 index 000000000..530224807 --- /dev/null +++ b/tests/gateway/test_pre_gateway_dispatch.py @@ -0,0 +1,179 @@ +"""Tests for the pre_gateway_dispatch plugin hook. + +The hook allows plugins to intercept incoming messages before auth and +agent dispatch. It runs in _handle_message and acts on returned action +dicts: {"action": "skip"|"rewrite"|"allow"}. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _clear_auth_env(monkeypatch) -> None: + for key in ( + "TELEGRAM_ALLOWED_USERS", + "WHATSAPP_ALLOWED_USERS", + "GATEWAY_ALLOWED_USERS", + "TELEGRAM_ALLOW_ALL_USERS", + "WHATSAPP_ALLOW_ALL_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(key, raising=False) + + +def _make_event(text: str = "hello", platform: Platform = Platform.WHATSAPP) -> MessageEvent: + return MessageEvent( + text=text, + message_id="m1", + source=SessionSource( + platform=platform, + user_id="15551234567@s.whatsapp.net", + chat_id="15551234567@s.whatsapp.net", + user_name="tester", + chat_type="dm", + ), + ) + + +def _make_runner(platform: Platform): + from gateway.run import GatewayRunner + + config = GatewayConfig( + platforms={platform: PlatformConfig(enabled=True)}, + ) + runner = object.__new__(GatewayRunner) + runner.config = config + adapter = SimpleNamespace(send=AsyncMock()) + runner.adapters = {platform: adapter} + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False + runner.session_store = MagicMock() + runner._running_agents = {} + runner._update_prompt_pending = {} + return runner, adapter + + +@pytest.mark.asyncio +async def test_hook_skip_short_circuits_dispatch(monkeypatch): + """A plugin returning {'action': 'skip'} drops the message before auth.""" + _clear_auth_env(monkeypatch) + + def _fake_hook(name, **kwargs): + if name == "pre_gateway_dispatch": + return [{"action": "skip", "reason": "plugin-handled"}] + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _fake_hook) + + runner, adapter = _make_runner(Platform.WHATSAPP) + + result = await runner._handle_message(_make_event("hi")) + + assert result is None + adapter.send.assert_not_awaited() + runner.pairing_store.generate_code.assert_not_called() + + +@pytest.mark.asyncio +async def test_hook_rewrite_replaces_event_text(monkeypatch): + """A plugin returning {'action': 'rewrite', 'text': ...} mutates event.text.""" + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "*") + + seen_text = {} + + def _fake_hook(name, **kwargs): + if name == "pre_gateway_dispatch": + return [{"action": "rewrite", "text": "REWRITTEN"}] + return [] + + async def _capture(event, source, _quick_key, _run_generation): + seen_text["value"] = event.text + return "ok" + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _fake_hook) + + runner, _adapter = _make_runner(Platform.WHATSAPP) + runner._handle_message_with_agent = _capture # noqa: SLF001 + + await runner._handle_message(_make_event("original")) + + assert seen_text.get("value") == "REWRITTEN" + + +@pytest.mark.asyncio +async def test_hook_allow_falls_through_to_auth(monkeypatch): + """A plugin returning {'action': 'allow'} continues to normal dispatch.""" + _clear_auth_env(monkeypatch) + # No allowed users set → auth fails → pairing flow triggers. + monkeypatch.delenv("WHATSAPP_ALLOWED_USERS", raising=False) + + def _fake_hook(name, **kwargs): + if name == "pre_gateway_dispatch": + return [{"action": "allow"}] + return [] + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _fake_hook) + + runner, adapter = _make_runner(Platform.WHATSAPP) + runner.pairing_store.generate_code.return_value = "12345" + + result = await runner._handle_message(_make_event("hi")) + + # auth chain ran → pairing code was generated + assert result is None + runner.pairing_store.generate_code.assert_called_once() + + +@pytest.mark.asyncio +async def test_hook_exception_does_not_break_dispatch(monkeypatch): + """A raising plugin hook does not break the gateway.""" + _clear_auth_env(monkeypatch) + monkeypatch.delenv("WHATSAPP_ALLOWED_USERS", raising=False) + + def _fake_hook(name, **kwargs): + raise RuntimeError("plugin blew up") + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _fake_hook) + + runner, _adapter = _make_runner(Platform.WHATSAPP) + runner.pairing_store.generate_code.return_value = None + + # Should not raise; falls through to auth chain. + result = await runner._handle_message(_make_event("hi")) + assert result is None + + +@pytest.mark.asyncio +async def test_internal_events_bypass_hook(monkeypatch): + """Internal events (event.internal=True) skip the plugin hook entirely.""" + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "*") + + called = {"count": 0} + + def _fake_hook(name, **kwargs): + called["count"] += 1 + return [{"action": "skip"}] + + async def _capture(event, source, _quick_key, _run_generation): + return "ok" + + monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _fake_hook) + + runner, _adapter = _make_runner(Platform.WHATSAPP) + runner._handle_message_with_agent = _capture # noqa: SLF001 + + event = _make_event("hi") + event.internal = True + + # Even though the hook would say skip, internal events bypass it. + await runner._handle_message(event) + assert called["count"] == 0 diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 2455547de..28d3abac7 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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" diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 19d00f906..62338ce6a 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -141,6 +141,7 @@ Plugins can register callbacks for these lifecycle events. See the **[Event Hook | [`post_llm_call`](/docs/user-guide/features/hooks#post_llm_call) | Once per turn, after the LLM loop (successful turns only) | | [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) | | [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit handler | +| `pre_gateway_dispatch` | Gateway received a user message, before auth + dispatch. Return `{"action": "skip" \| "rewrite" \| "allow", ...}` to influence flow. | ## Plugin types