feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
"""Tests for the Hermes plugin system (hermes_cli.plugins)."""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import types
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
|
|
from hermes_cli.plugins import (
|
|
|
|
|
|
ENTRY_POINTS_GROUP,
|
|
|
|
|
|
VALID_HOOKS,
|
|
|
|
|
|
LoadedPlugin,
|
|
|
|
|
|
PluginContext,
|
|
|
|
|
|
PluginManager,
|
|
|
|
|
|
PluginManifest,
|
|
|
|
|
|
get_plugin_manager,
|
|
|
|
|
|
get_plugin_tool_names,
|
|
|
|
|
|
discover_plugins,
|
|
|
|
|
|
invoke_hook,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
|
|
|
|
|
manifest_extra: dict | None = None) -> Path:
|
|
|
|
|
|
"""Create a minimal plugin directory with plugin.yaml + __init__.py."""
|
|
|
|
|
|
plugin_dir = base / name
|
|
|
|
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
manifest = {"name": name, "version": "0.1.0", "description": f"Test plugin {name}"}
|
|
|
|
|
|
if manifest_extra:
|
|
|
|
|
|
manifest.update(manifest_extra)
|
|
|
|
|
|
|
|
|
|
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
|
|
|
|
|
|
(plugin_dir / "__init__.py").write_text(
|
|
|
|
|
|
f"def register(ctx):\n {register_body}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
return plugin_dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── TestPluginDiscovery ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginDiscovery:
|
|
|
|
|
|
"""Tests for plugin discovery from directories and entry points."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_discover_user_plugins(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plugins in ~/.hermes/plugins/ are discovered."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "hello_plugin")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "hello_plugin" in mgr._plugins
|
|
|
|
|
|
assert mgr._plugins["hello_plugin"].enabled
|
|
|
|
|
|
|
|
|
|
|
|
def test_discover_project_plugins(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plugins in ./.hermes/plugins/ are discovered."""
|
|
|
|
|
|
project_dir = tmp_path / "project"
|
|
|
|
|
|
project_dir.mkdir()
|
|
|
|
|
|
monkeypatch.chdir(project_dir)
|
2026-03-20 20:50:30 -07:00
|
|
|
|
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "true")
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
plugins_dir = project_dir / ".hermes" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "proj_plugin")
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "proj_plugin" in mgr._plugins
|
|
|
|
|
|
assert mgr._plugins["proj_plugin"].enabled
|
|
|
|
|
|
|
2026-03-20 20:50:30 -07:00
|
|
|
|
def test_discover_project_plugins_skipped_by_default(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Project plugins are not discovered unless explicitly enabled."""
|
|
|
|
|
|
project_dir = tmp_path / "project"
|
|
|
|
|
|
project_dir.mkdir()
|
|
|
|
|
|
monkeypatch.chdir(project_dir)
|
|
|
|
|
|
plugins_dir = project_dir / ".hermes" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "proj_plugin")
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "proj_plugin" not in mgr._plugins
|
|
|
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
def test_discover_is_idempotent(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Calling discover_and_load() twice does not duplicate plugins."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "once_plugin")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
mgr.discover_and_load() # second call should no-op
|
|
|
|
|
|
|
|
|
|
|
|
assert len(mgr._plugins) == 1
|
|
|
|
|
|
|
|
|
|
|
|
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Directories without plugin.yaml are silently skipped."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
(plugins_dir / "no_manifest").mkdir(parents=True)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert len(mgr._plugins) == 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_entry_points_scanned(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Entry-point based plugins are discovered (mocked)."""
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
fake_module = types.ModuleType("fake_ep_plugin")
|
|
|
|
|
|
fake_module.register = lambda ctx: None # type: ignore[attr-defined]
|
|
|
|
|
|
|
|
|
|
|
|
fake_ep = MagicMock()
|
|
|
|
|
|
fake_ep.name = "ep_plugin"
|
|
|
|
|
|
fake_ep.value = "fake_ep_plugin:register"
|
|
|
|
|
|
fake_ep.group = ENTRY_POINTS_GROUP
|
|
|
|
|
|
fake_ep.load.return_value = fake_module
|
|
|
|
|
|
|
|
|
|
|
|
def fake_entry_points():
|
|
|
|
|
|
result = MagicMock()
|
|
|
|
|
|
result.select = MagicMock(return_value=[fake_ep])
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
with patch("importlib.metadata.entry_points", fake_entry_points):
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "ep_plugin" in mgr._plugins
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── TestPluginLoading ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginLoading:
|
|
|
|
|
|
"""Tests for plugin module loading."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_missing_init(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plugin dir without __init__.py records an error."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
plugin_dir = plugins_dir / "bad_plugin"
|
|
|
|
|
|
plugin_dir.mkdir(parents=True)
|
|
|
|
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "bad_plugin"}))
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "bad_plugin" in mgr._plugins
|
|
|
|
|
|
assert not mgr._plugins["bad_plugin"].enabled
|
|
|
|
|
|
assert mgr._plugins["bad_plugin"].error is not None
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_missing_register_fn(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plugin without register() function records an error."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
plugin_dir = plugins_dir / "no_reg"
|
|
|
|
|
|
plugin_dir.mkdir(parents=True)
|
|
|
|
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "no_reg"}))
|
|
|
|
|
|
(plugin_dir / "__init__.py").write_text("# no register function\n")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "no_reg" in mgr._plugins
|
|
|
|
|
|
assert not mgr._plugins["no_reg"].enabled
|
|
|
|
|
|
assert "no register()" in mgr._plugins["no_reg"].error
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_registers_namespace_module(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Directory plugins are importable under hermes_plugins.<name>."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "ns_plugin")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
# Clean up any prior namespace module
|
|
|
|
|
|
sys.modules.pop("hermes_plugins.ns_plugin", None)
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "hermes_plugins.ns_plugin" in sys.modules
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── TestPluginHooks ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginHooks:
|
|
|
|
|
|
"""Tests for lifecycle hook registration and invocation."""
|
|
|
|
|
|
|
2026-04-06 10:33:13 +05:30
|
|
|
|
def test_valid_hooks_include_request_scoped_api_hooks(self):
|
|
|
|
|
|
assert "pre_api_request" in VALID_HOOKS
|
|
|
|
|
|
assert "post_api_request" in VALID_HOOKS
|
2026-03-29 12:26:44 +05:30
|
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Registered hooks are called on invoke_hook()."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "hook_plugin",
|
|
|
|
|
|
register_body='ctx.register_hook("pre_tool_call", lambda **kw: None)',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
# Should not raise
|
|
|
|
|
|
mgr.invoke_hook("pre_tool_call", tool_name="test", args={}, task_id="t1")
|
|
|
|
|
|
|
|
|
|
|
|
def test_hook_exception_does_not_propagate(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""A hook callback that raises does NOT crash the caller."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "bad_hook",
|
|
|
|
|
|
register_body='ctx.register_hook("post_tool_call", lambda **kw: 1/0)',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
# Should not raise despite 1/0
|
|
|
|
|
|
mgr.invoke_hook("post_tool_call", tool_name="x", args={}, result="r", task_id="")
|
|
|
|
|
|
|
2026-03-28 11:14:54 -07:00
|
|
|
|
def test_hook_return_values_collected(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""invoke_hook() collects non-None return values from callbacks."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "ctx_plugin",
|
|
|
|
|
|
register_body=(
|
|
|
|
|
|
'ctx.register_hook("pre_llm_call", '
|
|
|
|
|
|
'lambda **kw: {"context": "memory from plugin"})'
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook("pre_llm_call", session_id="s1", user_message="hi",
|
|
|
|
|
|
conversation_history=[], is_first_turn=True, model="test")
|
|
|
|
|
|
assert len(results) == 1
|
|
|
|
|
|
assert results[0] == {"context": "memory from plugin"}
|
|
|
|
|
|
|
|
|
|
|
|
def test_hook_none_returns_excluded(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""invoke_hook() excludes None returns from the result list."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "none_hook",
|
|
|
|
|
|
register_body='ctx.register_hook("post_llm_call", lambda **kw: None)',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook("post_llm_call", session_id="s1",
|
|
|
|
|
|
user_message="hi", assistant_response="bye", model="test")
|
|
|
|
|
|
assert results == []
|
|
|
|
|
|
|
2026-03-29 12:26:44 +05:30
|
|
|
|
def test_request_hooks_are_invokeable(self, tmp_path, monkeypatch):
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "request_hook",
|
2026-04-06 10:33:13 +05:30
|
|
|
|
register_body=(
|
|
|
|
|
|
'ctx.register_hook("pre_api_request", '
|
|
|
|
|
|
'lambda **kw: {"seen": kw.get("api_call_count"), '
|
|
|
|
|
|
'"mc": kw.get("message_count"), "tc": kw.get("tool_count")})'
|
|
|
|
|
|
),
|
2026-03-29 12:26:44 +05:30
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook(
|
2026-04-06 10:33:13 +05:30
|
|
|
|
"pre_api_request",
|
2026-03-29 12:26:44 +05:30
|
|
|
|
session_id="s1",
|
|
|
|
|
|
task_id="t1",
|
|
|
|
|
|
model="test",
|
|
|
|
|
|
api_call_count=2,
|
2026-04-06 10:33:13 +05:30
|
|
|
|
message_count=5,
|
|
|
|
|
|
tool_count=3,
|
|
|
|
|
|
approx_input_tokens=100,
|
|
|
|
|
|
request_char_count=400,
|
|
|
|
|
|
max_tokens=8192,
|
2026-03-29 12:26:44 +05:30
|
|
|
|
)
|
2026-04-06 10:33:13 +05:30
|
|
|
|
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
2026-03-29 12:26:44 +05:30
|
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
|
|
|
|
|
"""Registering an unknown hook name logs a warning."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, "warn_plugin",
|
|
|
|
|
|
register_body='ctx.register_hook("on_banana", lambda **kw: None)',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert any("on_banana" in record.message for record in caplog.records)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── TestPluginContext ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginContext:
|
|
|
|
|
|
"""Tests for the PluginContext facade."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_register_tool_adds_to_registry(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""PluginContext.register_tool() puts the tool in the global registry."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
plugin_dir = plugins_dir / "tool_plugin"
|
|
|
|
|
|
plugin_dir.mkdir(parents=True)
|
|
|
|
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "tool_plugin"}))
|
|
|
|
|
|
(plugin_dir / "__init__.py").write_text(
|
|
|
|
|
|
'def register(ctx):\n'
|
|
|
|
|
|
' ctx.register_tool(\n'
|
|
|
|
|
|
' name="plugin_echo",\n'
|
|
|
|
|
|
' toolset="plugin_tool_plugin",\n'
|
|
|
|
|
|
' schema={"name": "plugin_echo", "description": "Echo", "parameters": {"type": "object", "properties": {}}},\n'
|
|
|
|
|
|
' handler=lambda args, **kw: "echo",\n'
|
|
|
|
|
|
' )\n'
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
assert "plugin_echo" in mgr._plugin_tool_names
|
|
|
|
|
|
|
|
|
|
|
|
from tools.registry import registry
|
|
|
|
|
|
assert "plugin_echo" in registry._tools
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── TestPluginToolVisibility ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginToolVisibility:
|
|
|
|
|
|
"""Plugin-registered tools appear in get_tool_definitions()."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_plugin_tools_in_definitions(self, tmp_path, monkeypatch):
|
2026-03-22 04:55:34 -07:00
|
|
|
|
"""Plugin tools are included when their toolset is in enabled_toolsets."""
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
|
|
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
plugin_dir = plugins_dir / "vis_plugin"
|
|
|
|
|
|
plugin_dir.mkdir(parents=True)
|
|
|
|
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "vis_plugin"}))
|
|
|
|
|
|
(plugin_dir / "__init__.py").write_text(
|
|
|
|
|
|
'def register(ctx):\n'
|
|
|
|
|
|
' ctx.register_tool(\n'
|
|
|
|
|
|
' name="vis_tool",\n'
|
|
|
|
|
|
' toolset="plugin_vis_plugin",\n'
|
|
|
|
|
|
' schema={"name": "vis_tool", "description": "Visible", "parameters": {"type": "object", "properties": {}}},\n'
|
|
|
|
|
|
' handler=lambda args, **kw: "ok",\n'
|
|
|
|
|
|
' )\n'
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
|
|
|
|
|
|
|
|
|
|
|
from model_tools import get_tool_definitions
|
2026-03-22 04:55:34 -07:00
|
|
|
|
|
|
|
|
|
|
# Plugin tools are included when their toolset is explicitly enabled
|
|
|
|
|
|
tools = get_tool_definitions(enabled_toolsets=["terminal", "plugin_vis_plugin"], quiet_mode=True)
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
tool_names = [t["function"]["name"] for t in tools]
|
|
|
|
|
|
assert "vis_tool" in tool_names
|
|
|
|
|
|
|
2026-03-22 04:55:34 -07:00
|
|
|
|
# Plugin tools are excluded when only other toolsets are enabled
|
|
|
|
|
|
tools2 = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True)
|
|
|
|
|
|
tool_names2 = [t["function"]["name"] for t in tools2]
|
|
|
|
|
|
assert "vis_tool" not in tool_names2
|
|
|
|
|
|
|
|
|
|
|
|
# Plugin tools are included when no toolset filter is active (all enabled)
|
|
|
|
|
|
tools3 = get_tool_definitions(quiet_mode=True)
|
|
|
|
|
|
tool_names3 = [t["function"]["name"] for t in tools3]
|
|
|
|
|
|
assert "vis_tool" in tool_names3
|
|
|
|
|
|
|
feat: first-class plugin architecture (#1555)
Plugin system for extending Hermes with custom tools, hooks, and
integrations — no source code changes required.
Core system (hermes_cli/plugins.py):
- Plugin discovery from ~/.hermes/plugins/, .hermes/plugins/, and
pip entry_points (hermes_agent.plugins group)
- PluginContext with register_tool() and register_hook()
- 6 lifecycle hooks: pre/post tool_call, pre/post llm_call,
on_session_start/end
- Namespace package handling for relative imports in plugins
- Graceful error isolation — broken plugins never crash the agent
Integration (model_tools.py):
- Plugin discovery runs after built-in + MCP tools
- Plugin tools bypass toolset filter via get_plugin_tool_names()
- Pre/post tool call hooks fire in handle_function_call()
CLI:
- /plugins command shows loaded plugins, tool counts, status
- Added to COMMANDS dict for autocomplete
Docs:
- Getting started guide (build-a-hermes-plugin.md) — full tutorial
building a calculator plugin step by step
- Reference page (features/plugins.md) — quick overview + tables
- Covers: file structure, schemas, handlers, hooks, data files,
bundled skills, env var gating, pip distribution, common mistakes
Tests: 16 tests covering discovery, loading, hooks, tool visibility.
2026-03-16 07:17:36 -07:00
|
|
|
|
|
|
|
|
|
|
# ── TestPluginManagerList ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginManagerList:
|
|
|
|
|
|
"""Tests for PluginManager.list_plugins()."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_empty(self):
|
|
|
|
|
|
"""Empty manager returns empty list."""
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
assert mgr.list_plugins() == []
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_returns_sorted(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""list_plugins() returns results sorted by name."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "zulu")
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "alpha")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
listing = mgr.list_plugins()
|
|
|
|
|
|
names = [p["name"] for p in listing]
|
|
|
|
|
|
assert names == sorted(names)
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_with_plugins(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""list_plugins() returns info dicts for each discovered plugin."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "alpha")
|
|
|
|
|
|
_make_plugin_dir(plugins_dir, "beta")
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
listing = mgr.list_plugins()
|
|
|
|
|
|
names = [p["name"] for p in listing]
|
|
|
|
|
|
assert "alpha" in names
|
|
|
|
|
|
assert "beta" in names
|
|
|
|
|
|
for p in listing:
|
|
|
|
|
|
assert "enabled" in p
|
|
|
|
|
|
assert "tools" in p
|
|
|
|
|
|
assert "hooks" in p
|
2026-03-21 16:00:30 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix: move pre_llm_call plugin context to user message, preserve prompt cache (#5146)
Plugin context from pre_llm_call hooks was injected into the system
prompt, breaking the prompt cache prefix every turn when content
changed (typical for memory plugins). Now all plugin context goes
into the current turn's user message — the system prompt stays
identical across turns, preserving cached tokens.
The system prompt is reserved for Hermes internals. Plugins
contribute context alongside the user's input.
Also adds comprehensive documentation for all 6 plugin hooks:
pre_tool_call, post_tool_call, pre_llm_call, post_llm_call,
on_session_start, on_session_end — each with full callback
signatures, parameter tables, firing conditions, and examples.
Supersedes #5138 which identified the same cache-busting bug
and proposed an uncached system suffix approach. This fix goes
further by removing system prompt injection entirely.
Co-identified-by: OutThisLife (PR #5138)
2026-04-04 16:55:44 -07:00
|
|
|
|
class TestPreLlmCallTargetRouting:
|
|
|
|
|
|
"""Tests for pre_llm_call hook return format with target-aware routing.
|
|
|
|
|
|
|
|
|
|
|
|
The routing logic lives in run_agent.py, but the return format is collected
|
|
|
|
|
|
by invoke_hook(). These tests verify the return format works correctly and
|
|
|
|
|
|
that downstream code can route based on the 'target' key.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def _make_pre_llm_plugin(self, plugins_dir, name, return_expr):
|
|
|
|
|
|
"""Create a plugin that returns a specific value from pre_llm_call."""
|
|
|
|
|
|
_make_plugin_dir(
|
|
|
|
|
|
plugins_dir, name,
|
|
|
|
|
|
register_body=(
|
|
|
|
|
|
f'ctx.register_hook("pre_llm_call", lambda **kw: {return_expr})'
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_context_dict_returned(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plugin returning a context dict is collected by invoke_hook."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "basic_plugin",
|
|
|
|
|
|
'{"context": "basic context"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook(
|
|
|
|
|
|
"pre_llm_call", session_id="s1", user_message="hi",
|
|
|
|
|
|
conversation_history=[], is_first_turn=True, model="test",
|
|
|
|
|
|
)
|
|
|
|
|
|
assert len(results) == 1
|
|
|
|
|
|
assert results[0]["context"] == "basic context"
|
|
|
|
|
|
assert "target" not in results[0]
|
|
|
|
|
|
|
|
|
|
|
|
def test_plain_string_return(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Plain string returns are collected as-is (routing treats them as user_message)."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "str_plugin",
|
|
|
|
|
|
'"plain string context"',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook(
|
|
|
|
|
|
"pre_llm_call", session_id="s1", user_message="hi",
|
|
|
|
|
|
conversation_history=[], is_first_turn=True, model="test",
|
|
|
|
|
|
)
|
|
|
|
|
|
assert len(results) == 1
|
|
|
|
|
|
assert results[0] == "plain string context"
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_plugins_context_collected(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Multiple plugins returning context are all collected."""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "aaa_memory",
|
|
|
|
|
|
'{"context": "memory context"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "bbb_guardrail",
|
|
|
|
|
|
'{"context": "guardrail text"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook(
|
|
|
|
|
|
"pre_llm_call", session_id="s1", user_message="hi",
|
|
|
|
|
|
conversation_history=[], is_first_turn=True, model="test",
|
|
|
|
|
|
)
|
|
|
|
|
|
assert len(results) == 2
|
|
|
|
|
|
contexts = [r["context"] for r in results]
|
|
|
|
|
|
assert "memory context" in contexts
|
|
|
|
|
|
assert "guardrail text" in contexts
|
|
|
|
|
|
|
|
|
|
|
|
def test_routing_logic_all_to_user_message(self, tmp_path, monkeypatch):
|
|
|
|
|
|
"""Simulate the routing logic from run_agent.py.
|
|
|
|
|
|
|
|
|
|
|
|
All plugin context — dicts and plain strings — ends up in a single
|
|
|
|
|
|
user message context string. There is no system_prompt target.
|
|
|
|
|
|
"""
|
|
|
|
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "aaa_mem",
|
|
|
|
|
|
'{"context": "memory A"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "bbb_guard",
|
|
|
|
|
|
'{"context": "rule B"}',
|
|
|
|
|
|
)
|
|
|
|
|
|
self._make_pre_llm_plugin(
|
|
|
|
|
|
plugins_dir, "ccc_plain",
|
|
|
|
|
|
'"plain text C"',
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
|
|
|
|
|
|
|
|
mgr = PluginManager()
|
|
|
|
|
|
mgr.discover_and_load()
|
|
|
|
|
|
|
|
|
|
|
|
results = mgr.invoke_hook(
|
|
|
|
|
|
"pre_llm_call", session_id="s1", user_message="hi",
|
|
|
|
|
|
conversation_history=[], is_first_turn=True, model="test",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Replicate run_agent.py routing logic — everything goes to user msg
|
|
|
|
|
|
_ctx_parts = []
|
|
|
|
|
|
for r in results:
|
|
|
|
|
|
if isinstance(r, dict) and r.get("context"):
|
|
|
|
|
|
_ctx_parts.append(str(r["context"]))
|
|
|
|
|
|
elif isinstance(r, str) and r.strip():
|
|
|
|
|
|
_ctx_parts.append(r)
|
|
|
|
|
|
|
|
|
|
|
|
assert _ctx_parts == ["memory A", "rule B", "plain text C"]
|
|
|
|
|
|
_plugin_user_context = "\n\n".join(_ctx_parts)
|
|
|
|
|
|
assert "memory A" in _plugin_user_context
|
|
|
|
|
|
assert "rule B" in _plugin_user_context
|
|
|
|
|
|
assert "plain text C" in _plugin_user_context
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-22 05:58:26 -07:00
|
|
|
|
# NOTE: TestPluginCommands removed – register_command() was never implemented
|
|
|
|
|
|
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
|
|
|
|
|
|
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
|
|
|
|
|
|
# integration — all of which are unimplemented features.
|