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."""
|
|
|
|
|
|
|
|
|
|
|
|
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 == []
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|