From cec149f08ea5a871b5fbeb2fb38cfab9fed82d97 Mon Sep 17 00:00:00 2001 From: devagentic-dev Date: Sun, 24 May 2026 07:16:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20add=20register(ctx)=20to=20G1/G?= =?UTF-8?q?2/G3/G4=20=E2=80=94=20loader=20was=20silently=20skipping=20(clo?= =?UTF-8?q?ses=20#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/devagentic-lane-h/__init__.py | 12 ++ plugins/devagentic-mutations/__init__.py | 12 ++ .../devagentic-vertical-preamble/__init__.py | 15 ++ plugins/hermes-github/__init__.py | 11 ++ tests/test_plugin_register_fns.py | 143 ++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 tests/test_plugin_register_fns.py diff --git a/plugins/devagentic-lane-h/__init__.py b/plugins/devagentic-lane-h/__init__.py index 357ba7deafad2..3d8d9802cf9c7 100644 --- a/plugins/devagentic-lane-h/__init__.py +++ b/plugins/devagentic-lane-h/__init__.py @@ -34,3 +34,15 @@ logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Plugin loader entrypoint (hermes-agent#78). MCP-only plugin — + ``lane_h_list`` + ``lane_h_fetch`` + ``grafted_context_fetch`` + tools are registered server-side in + ``mcp_serve.py::_register_lane_h_tools``. Nothing to wire through + the plugin loader directly. + + register() exists so the loader doesn't warn `no register()` and + skip the plugin.""" + logger.debug("devagentic-lane-h: loaded (MCP-only; tools via mcp_serve)") diff --git a/plugins/devagentic-mutations/__init__.py b/plugins/devagentic-mutations/__init__.py index 7feddd1d854c1..fdeb45210625e 100644 --- a/plugins/devagentic-mutations/__init__.py +++ b/plugins/devagentic-mutations/__init__.py @@ -23,3 +23,15 @@ logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Plugin loader entrypoint (hermes-agent#78). MCP-only plugin — + ``silo_query`` + ``confer_run`` tools are registered server-side in + ``mcp_serve.py::_register_devagentic_mutation_tools`` (callable + from MCP clients including hermes-internal MCP-client bridges). + Nothing to wire through the plugin loader directly. + + register() exists so the loader doesn't warn `no register()` and + skip the plugin; the plugin-yaml declares the surface as MCP.""" + logger.debug("devagentic-mutations: loaded (MCP-only; tools via mcp_serve)") diff --git a/plugins/devagentic-vertical-preamble/__init__.py b/plugins/devagentic-vertical-preamble/__init__.py index f9797399a13d3..27ee93aa72155 100644 --- a/plugins/devagentic-vertical-preamble/__init__.py +++ b/plugins/devagentic-vertical-preamble/__init__.py @@ -25,5 +25,20 @@ import logging +from . import preamble as _preamble + logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Plugin loader entrypoint (hermes-agent#78). Wires the + ``pre_llm_call`` hook to ``preamble.on_pre_llm_call`` — without + this, the loader detects no register() and the plugin is silently + inert (warns + skips wiring per ``hermes_cli/plugins.py:1184``). + + The hook auto-loads vertical-spec + grafted-context index + + worker-guardrails on the first LLM call of the process. Per-process + gate inside the hook keeps it from re-injecting on every turn. + See plugin.yaml `hooks: [pre_llm_call]` for the manifest contract.""" + ctx.register_hook("pre_llm_call", _preamble.on_pre_llm_call) diff --git a/plugins/hermes-github/__init__.py b/plugins/hermes-github/__init__.py index 18e7969848bb6..cfc919c27da9c 100644 --- a/plugins/hermes-github/__init__.py +++ b/plugins/hermes-github/__init__.py @@ -20,3 +20,14 @@ logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Plugin loader entrypoint (hermes-agent#78). MCP-only plugin — + ``file_issue`` tool is registered server-side in + ``mcp_serve.py::_register_github_tools``. Nothing to wire through + the plugin loader directly. + + register() exists so the loader doesn't warn `no register()` and + skip the plugin.""" + logger.debug("hermes-github: loaded (MCP-only; file_issue via mcp_serve)") diff --git a/tests/test_plugin_register_fns.py b/tests/test_plugin_register_fns.py new file mode 100644 index 0000000000000..7688039a53d2f --- /dev/null +++ b/tests/test_plugin_register_fns.py @@ -0,0 +1,143 @@ +"""Contract tests for G1/G2/G3/G4 plugin register() functions (hermes-agent#78). + +For each of the four devagentic-* plugins, asserts: +1. `__init__.py` contains a `register(ctx)` function at module level. +2. The function is importable + callable. +3. G1 specifically: calling register() wires the pre_llm_call hook to + preamble.on_pre_llm_call (the actual fix — without this the worker + boots with no preamble). +4. G2/G3/G4: calling register() doesn't raise; doesn't register + tools/hooks via the loader (MCP-only by design). +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +REPO = Path(__file__).resolve().parents[1] +PLUGINS_DIR = REPO / "plugins" + + +class _StubCtx: + """Records what register() asks for. Mirrors the PluginContext + surface we test against (hook + tool + command + skill).""" + def __init__(self): + self.hooks: list[tuple[str, object]] = [] + self.tools: list[str] = [] + self.commands: list[str] = [] + self.skills: list[str] = [] + + def register_hook(self, name, callback): + self.hooks.append((name, callback)) + + def register_tool(self, *, name, **kw): + self.tools.append(name) + + def register_command(self, *, name, **kw): + self.commands.append(name) + + def register_skill(self, *, name, **kw): + self.skills.append(name) + + +def _load_plugin_module(plugin_dir_name: str): + """Load `plugins//__init__.py` by path. Uses a + unique sys.modules key per test so the relative import inside the + plugin doesn't collide across tests.""" + plugin_dir = PLUGINS_DIR / plugin_dir_name + pkg_name = f"plugin_under_test_{plugin_dir_name.replace('-', '_')}" + + # Set up a synthetic package so `from . import preamble` resolves. + pkg = SimpleNamespace(__path__=[str(plugin_dir)], __name__=pkg_name) + sys.modules[pkg_name] = pkg # type: ignore[assignment] + + # Pre-load sibling modules so the package's relative imports find them. + for sib in plugin_dir.glob("*.py"): + if sib.name == "__init__.py": + continue + sib_name = f"{pkg_name}.{sib.stem}" + spec = importlib.util.spec_from_file_location(sib_name, sib) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[sib_name] = mod + spec.loader.exec_module(mod) + + init_spec = importlib.util.spec_from_file_location( + pkg_name, plugin_dir / "__init__.py", + submodule_search_locations=[str(plugin_dir)], + ) + assert init_spec is not None and init_spec.loader is not None + init_mod = importlib.util.module_from_spec(init_spec) + sys.modules[pkg_name] = init_mod + init_spec.loader.exec_module(init_mod) + return init_mod + + +# ─── G1 vertical-preamble — the actual fix ──────────────────── + +def test_g1_register_wires_pre_llm_call_hook(): + """register() must wire pre_llm_call → preamble.on_pre_llm_call. + Without this hook, the worker boots with no vertical preamble + (the symptom that surfaced #78).""" + mod = _load_plugin_module("devagentic-vertical-preamble") + assert callable(getattr(mod, "register", None)), "G1 missing register()" + ctx = _StubCtx() + mod.register(ctx) + hook_names = [h[0] for h in ctx.hooks] + assert "pre_llm_call" in hook_names, ( + f"G1 register() must wire pre_llm_call hook; got {hook_names}" + ) + # And specifically to preamble.on_pre_llm_call: + pre_llm_hook = [h[1] for h in ctx.hooks if h[0] == "pre_llm_call"][0] + assert getattr(pre_llm_hook, "__name__", "") == "on_pre_llm_call", ( + f"hook callback must be on_pre_llm_call; got {pre_llm_hook!r}" + ) + + +# ─── G2/G3/G4 — stub register() doesn't raise ─────────────── + +@pytest.mark.parametrize("plugin_name,description", [ + ("devagentic-mutations", "G2 mutations"), + ("hermes-github", "G3 github"), + ("devagentic-lane-h", "G4 lane-h"), +]) +def test_g2_g3_g4_register_stub_is_callable_and_no_op(plugin_name, description): + """MCP-only plugins should have register() that loads cleanly but + doesn't wire anything via the plugin loader (tools are server-side + in mcp_serve.py).""" + mod = _load_plugin_module(plugin_name) + assert callable(getattr(mod, "register", None)), ( + f"{description} missing register()" + ) + ctx = _StubCtx() + # Must not raise. + mod.register(ctx) + # MCP-only: nothing registered via the loader. + assert ctx.hooks == [], f"{description} unexpectedly registered hooks: {ctx.hooks}" + assert ctx.tools == [], f"{description} unexpectedly registered tools: {ctx.tools}" + assert ctx.commands == [], f"{description} unexpectedly registered commands" + + +# ─── All four contain register() at module level (regression catcher) ─ + +@pytest.mark.parametrize("plugin_name", [ + "devagentic-vertical-preamble", + "devagentic-mutations", + "hermes-github", + "devagentic-lane-h", +]) +def test_plugin_init_contains_register_function(plugin_name): + """Source-level regression: __init__.py must literally contain + `def register(ctx)`. Catches the bug where the function silently + disappears from the source (the original cause of this issue).""" + init = PLUGINS_DIR / plugin_name / "__init__.py" + src = init.read_text() + assert "def register(ctx" in src, ( + f"{plugin_name}/__init__.py must define register(ctx) at " + f"module level; loader skips plugin silently otherwise." + )