Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions plugins/devagentic-lane-h/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
12 changes: 12 additions & 0 deletions plugins/devagentic-mutations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
15 changes: 15 additions & 0 deletions plugins/devagentic-vertical-preamble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 11 additions & 0 deletions plugins/hermes-github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
143 changes: 143 additions & 0 deletions tests/test_plugin_register_fns.py
Original file line number Diff line number Diff line change
@@ -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/<plugin_dir_name>/__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."
)