The timeout branch of _run_hook_callback_bounded unconditionally added gate_key to _hook_abandoned. A worker that finishes between done.wait() returning False and the caller taking the lock has already popped its token via _release_token, so nothing would ever clear that entry: the callback stayed blocked for every later call id until reload with no thread behind it. Guard the insert on the worker still being registered. The new test makes the race deterministic by swapping the module's threading.Event for one whose wait() lets the worker finish and then reports a timeout, and asserts a fresh call id still runs. Also pass tool_call_id inline from terminal_tool_result instead of the conditional dict plumbing: an empty id is already treated as "no identity" by _hook_call_identity and unknown fields are withheld from narrow-signature callbacks (same shape as _fire_approval_hook). Update the stale "(hook_name, id(cb))" comment above _hook_running_callbacks.
2590 lines
99 KiB
Python
2590 lines
99 KiB
Python
"""Tests for the Hermes plugin system (hermes_cli.plugins)."""
|
|
|
|
import logging
|
|
import json
|
|
import sys
|
|
import threading
|
|
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,
|
|
PluginContext,
|
|
PluginManager,
|
|
PluginManifest,
|
|
_dispatch_pre_tool_call_hooks,
|
|
get_plugin_command_handler,
|
|
get_plugin_commands,
|
|
get_pre_tool_call_block_message,
|
|
get_pre_verify_continue_message,
|
|
has_middleware,
|
|
resolve_plugin_command_result,
|
|
_portable_skill_namespace,
|
|
)
|
|
from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV
|
|
from hermes_cli.middleware import (
|
|
VALID_MIDDLEWARE,
|
|
apply_llm_request_middleware,
|
|
apply_tool_request_middleware,
|
|
run_tool_execution_middleware,
|
|
)
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_portable_skill_namespace_is_ascii_safe():
|
|
from agent.skill_utils import is_valid_namespace
|
|
|
|
namespace = _portable_skill_namespace("café/portable")
|
|
|
|
assert namespace.isascii()
|
|
assert is_valid_namespace(namespace)
|
|
|
|
|
|
def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
|
|
manifest_extra: dict | None = None,
|
|
auto_enable: bool = True,
|
|
home: Path | None = None) -> Path:
|
|
"""Create a minimal plugin directory with plugin.yaml + __init__.py.
|
|
|
|
If *auto_enable* is True (default), also write the plugin's name into
|
|
``<hermes_home>/config.yaml`` under ``plugins.enabled``. Plugins are
|
|
opt-in by default, so tests that expect the plugin to actually load
|
|
need this. Pass ``auto_enable=False`` for tests that exercise the
|
|
unenabled path.
|
|
|
|
*base* is expected to be ``<hermes_home>/plugins/``; we derive
|
|
``<hermes_home>`` from it by walking one level up unless *home* is
|
|
given explicitly.
|
|
|
|
Pass *home* explicitly whenever the target Hermes home for this
|
|
plugin isn't necessarily the current ``HERMES_HOME`` env var — e.g.
|
|
when writing fixtures for two profiles up front and only switching
|
|
``HERMES_HOME``/``set_hermes_home_override()`` per-profile afterwards
|
|
(as multi-profile regression tests do). Relying on the *current* env
|
|
var here is a bug: if the caller hasn't switched homes yet (or is
|
|
using the context-local override instead of the env var, which this
|
|
function can't see), the enable list gets written to the wrong
|
|
profile's config.yaml and the plugin never loads for its intended
|
|
home.
|
|
"""
|
|
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), encoding="utf-8")
|
|
(plugin_dir / "__init__.py").write_text(
|
|
f"def register(ctx):\n {register_body}\n"
|
|
)
|
|
|
|
if auto_enable:
|
|
# Write/merge plugins.enabled in <HERMES_HOME>/config.yaml.
|
|
# Config is always read from HERMES_HOME (not from the project
|
|
# dir for project plugins), so that's where we opt in.
|
|
if home is not None:
|
|
hermes_home = Path(home)
|
|
else:
|
|
import os
|
|
hermes_home_str = os.environ.get("HERMES_HOME")
|
|
if hermes_home_str:
|
|
hermes_home = Path(hermes_home_str)
|
|
else:
|
|
hermes_home = base.parent
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
cfg_path = hermes_home / "config.yaml"
|
|
cfg: dict = {}
|
|
if cfg_path.exists():
|
|
try:
|
|
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
|
except Exception:
|
|
cfg = {}
|
|
plugins_cfg = cfg.setdefault("plugins", {})
|
|
enabled = plugins_cfg.setdefault("enabled", [])
|
|
if isinstance(enabled, list) and name not in enabled:
|
|
enabled.append(name)
|
|
cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8")
|
|
|
|
return plugin_dir
|
|
|
|
|
|
# ── TestPluginDiscovery ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginDiscovery:
|
|
"""Tests for plugin discovery from directories and entry points."""
|
|
|
|
def test_removed_relay_plugin_identity_cannot_be_reloaded(
|
|
self, monkeypatch, caplog
|
|
):
|
|
from hermes_cli import plugins as plugins_mod
|
|
|
|
manifest = PluginManifest(
|
|
name="nemo_relay",
|
|
key="observability/nemo_relay",
|
|
source="user",
|
|
)
|
|
manager = PluginManager()
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_collect_directory_manifests",
|
|
lambda: [manifest],
|
|
)
|
|
monkeypatch.setattr(manager, "_scan_entry_points", lambda: [])
|
|
monkeypatch.setattr(
|
|
plugins_mod,
|
|
"_get_enabled_plugins",
|
|
lambda: {"observability/nemo_relay"},
|
|
)
|
|
monkeypatch.setattr(plugins_mod, "_get_disabled_plugins", lambda: set())
|
|
loaded: list[PluginManifest] = []
|
|
monkeypatch.setattr(manager, "_load_plugin", loaded.append)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
manager.discover_and_load()
|
|
|
|
state = manager._plugins["observability/nemo_relay"]
|
|
assert loaded == []
|
|
assert not state.enabled
|
|
assert state.error is not None
|
|
assert "Relay lifecycle is owned by Hermes core" in state.error
|
|
assert RELAY_PLUGINS_CONFIG_ENV in state.error
|
|
assert "Refusing to load removed Hermes Relay plugin" in caplog.text
|
|
|
|
def test_enabled_portable_plugin_registers_components(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1
|
|
from hermes_cli import plugins as plugins_mod
|
|
|
|
home = tmp_path / "home"
|
|
plugin = home / "plugins" / "portable"
|
|
skill = plugin / "skills" / "summarize"
|
|
skill.mkdir(parents=True)
|
|
(plugin / "plugin.json").write_text(
|
|
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
|
|
)
|
|
(skill / "SKILL.md").write_text(
|
|
"---\nname: summarize\ndescription: Summarize reports.\n---\nBody.\n"
|
|
)
|
|
(plugin / "mcp.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"$schema": MCP_SCHEMA_V1,
|
|
"mcpServers": {
|
|
"worker": {"type": "stdio", "command": "python"}
|
|
},
|
|
}
|
|
)
|
|
)
|
|
native = home / "plugins" / "native"
|
|
native.mkdir()
|
|
(native / "plugin.yaml").write_text(
|
|
yaml.safe_dump({"name": "native", "version": "1.0.0"})
|
|
)
|
|
(native / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8")
|
|
home.mkdir(exist_ok=True)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["portable.test", "native"]}})
|
|
)
|
|
empty_bundled = tmp_path / "bundled"
|
|
empty_bundled.mkdir()
|
|
monkeypatch.setenv("HOME", str(tmp_path / "os-home"))
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled)
|
|
|
|
manager = PluginManager()
|
|
manager.discover_and_load()
|
|
|
|
[qualified] = manager.list_plugin_skill_metadata()
|
|
assert qualified["name"].endswith(":summarize")
|
|
assert set(manager.get_portable_mcp_servers()) == {
|
|
qualified["name"].split(":", 1)[0] + "__worker"
|
|
}
|
|
assert manager._plugins["portable.test"].enabled is True
|
|
assert manager._plugins["native"].enabled is True
|
|
assert manager._plugins["native"].module is not None
|
|
|
|
def test_disabled_portable_plugin_registers_nothing(self, tmp_path, monkeypatch):
|
|
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
|
from hermes_cli import plugins as plugins_mod
|
|
|
|
home = tmp_path / "home"
|
|
plugin = home / "plugins" / "portable"
|
|
plugin.mkdir(parents=True)
|
|
(plugin / "plugin.json").write_text(
|
|
json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"})
|
|
)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"plugins": {
|
|
"enabled": ["portable.test"],
|
|
"disabled": ["portable.test"],
|
|
}
|
|
}
|
|
)
|
|
)
|
|
empty_bundled = tmp_path / "bundled"
|
|
empty_bundled.mkdir()
|
|
monkeypatch.setenv("HOME", str(tmp_path / "os-home"))
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled)
|
|
|
|
manager = PluginManager()
|
|
manager.discover_and_load()
|
|
|
|
assert manager._plugins["portable.test"].enabled is False
|
|
assert manager.list_plugin_skill_metadata() == []
|
|
assert manager.get_portable_mcp_servers() == {}
|
|
|
|
def test_portable_author_object_is_normalized_to_stable_string(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
|
from hermes_cli import plugins as plugins_mod
|
|
|
|
home = tmp_path / "home"
|
|
plugin = home / "plugins" / "portable"
|
|
plugin.mkdir(parents=True)
|
|
(plugin / "plugin.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"$schema": PLUGIN_SCHEMA_V1,
|
|
"name": "portable.test",
|
|
"author": {
|
|
"url": "https://example.test",
|
|
"name": "Ada Lovelace",
|
|
"email": "ada@example.test",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
bundled = tmp_path / "bundled"
|
|
bundled.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
|
|
|
manager = PluginManager()
|
|
manifests = manager._collect_directory_manifests()
|
|
|
|
[manifest] = [item for item in manifests if item.portable]
|
|
assert manifest.author == (
|
|
"Ada Lovelace, ada@example.test, https://example.test"
|
|
)
|
|
assert isinstance(manifest.author, str)
|
|
|
|
empty_author = home / "plugins" / "empty-author"
|
|
empty_author.mkdir()
|
|
(empty_author / "plugin.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"$schema": PLUGIN_SCHEMA_V1,
|
|
"name": "empty-author",
|
|
"author": {},
|
|
}
|
|
)
|
|
)
|
|
[empty] = [
|
|
item
|
|
for item in manager._collect_directory_manifests()
|
|
if item.name == "empty-author"
|
|
]
|
|
assert empty.author == ""
|
|
|
|
|
|
def test_plugin_can_register_and_invoke_middleware(self, tmp_path, monkeypatch):
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
_make_plugin_dir(
|
|
plugins_dir,
|
|
"mw_plugin",
|
|
register_body=(
|
|
"ctx.register_middleware('llm_request', "
|
|
"lambda **kw: {'request': {**kw['request'], 'mw': True}})\n"
|
|
" ctx.register_middleware('tool_request', "
|
|
"lambda **kw: {'args': {**kw['args'], 'mw': True}})"
|
|
),
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "llm_request" in VALID_MIDDLEWARE
|
|
assert "tool_request" in VALID_MIDDLEWARE
|
|
assert set(mgr._plugins["mw_plugin"].middleware_registered) == {"llm_request", "tool_request"}
|
|
assert mgr.invoke_middleware("llm_request", request={"messages": []}) == [
|
|
{"request": {"messages": [], "mw": True}}
|
|
]
|
|
assert mgr.invoke_middleware("tool_request", args={"path": "README.md"}) == [
|
|
{"args": {"path": "README.md", "mw": True}}
|
|
]
|
|
assert mgr.has_middleware("llm_request") is True
|
|
|
|
|
|
def test_middleware_helpers_skip_no_listener_work(self, monkeypatch):
|
|
manager = types.SimpleNamespace(_middleware={})
|
|
monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager)
|
|
|
|
request = {"messages": []}
|
|
args = {"path": "README.md"}
|
|
|
|
llm_result = apply_llm_request_middleware(request)
|
|
tool_result = apply_tool_request_middleware("read_file", args)
|
|
|
|
assert llm_result.payload is request
|
|
assert llm_result.original_payload is request
|
|
assert llm_result.changed is False
|
|
assert llm_result.trace == []
|
|
assert tool_result.payload is args
|
|
assert tool_result.original_payload is args
|
|
assert tool_result.changed is False
|
|
assert tool_result.trace == []
|
|
assert run_tool_execution_middleware("terminal", args, lambda payload: payload) is args
|
|
assert has_middleware("tool_request") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed_discovery_is_not_cached(self, tmp_path, monkeypatch):
|
|
"""A sweep that raises must not cache 'discovered' with no plugins.
|
|
|
|
Regression for the stranded-empty-registry class of failures: callers
|
|
(e.g. tools.web_tools._ensure_web_plugins_loaded) swallow discovery
|
|
exceptions as warnings, so if a failed sweep flipped ``_discovered``
|
|
permanently, every later call would early-return against an empty
|
|
registry ("No web provider configured") for the process lifetime.
|
|
"""
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
_make_plugin_dir(plugins_dir, "retry_plugin")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
mgr = PluginManager()
|
|
|
|
def _boom(self_inner):
|
|
raise RuntimeError("sweep failed")
|
|
|
|
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", _boom)
|
|
with pytest.raises(RuntimeError, match="sweep failed"):
|
|
mgr.discover_and_load()
|
|
assert mgr._discovered is False, "failed sweep was cached as discovered"
|
|
|
|
# A later call (with discovery healthy again) must do the real scan.
|
|
monkeypatch.undo()
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
mgr.discover_and_load()
|
|
assert mgr._discovered is True
|
|
non_bundled = {
|
|
n: p for n, p in mgr._plugins.items()
|
|
if p.manifest.source != "bundled"
|
|
}
|
|
assert len(non_bundled) == 1
|
|
|
|
|
|
|
|
def test_force_rediscover_clears_all_plugin_registries(self, monkeypatch):
|
|
"""force=True must clear every plugin-populated registry.
|
|
|
|
Regression: ``_plugin_platform_names`` was populated by
|
|
``register_platform`` but omitted from the ``discover_and_load(force=True)``
|
|
clear block, so a platform plugin disabled between force-rediscovers
|
|
left a stale entry behind forever (the set diverged from the real
|
|
platform_registry / _plugins truth). This asserts the clear block
|
|
empties the full set of per-plugin registries so no future addition
|
|
silently leaks across a force pass either.
|
|
"""
|
|
mgr = PluginManager()
|
|
|
|
# Seed every registry that a plugin's register() can populate, then
|
|
# mark discovery done so force=True takes the clear path (we stub the
|
|
# inner sweep so the test doesn't depend on any on-disk plugins).
|
|
mgr._plugins["p"] = MagicMock()
|
|
mgr._hooks["pre_tool_call"] = [lambda **_: None]
|
|
mgr._middleware["llm_request"] = [lambda **_: None]
|
|
mgr._plugin_tool_names.add("some_tool")
|
|
mgr._plugin_platform_names.add("irc")
|
|
mgr._cli_commands["c"] = {"plugin": "p"}
|
|
mgr._plugin_commands["cmd"] = {"plugin": "p"}
|
|
mgr._plugin_skills["p:skill"] = {}
|
|
mgr._aux_tasks["task"] = {"plugin": "p"}
|
|
mgr._slack_action_handlers.append(("aid", lambda **_: None, "p"))
|
|
mgr._discovered = True
|
|
|
|
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", lambda self_inner: None)
|
|
mgr.discover_and_load(force=True)
|
|
|
|
assert mgr._plugins == {}
|
|
assert mgr._hooks == {}
|
|
assert mgr._middleware == {}
|
|
assert mgr._plugin_tool_names == set()
|
|
assert mgr._plugin_platform_names == set(), (
|
|
"_plugin_platform_names was not cleared on force-rediscover"
|
|
)
|
|
assert mgr._cli_commands == {}
|
|
assert mgr._plugin_commands == {}
|
|
assert mgr._plugin_skills == {}
|
|
assert mgr._aux_tasks == {}
|
|
assert mgr._slack_action_handlers == []
|
|
|
|
|
|
# ── TestPluginLoading ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginLoading:
|
|
"""Tests for plugin module loading."""
|
|
|
|
|
|
|
|
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
|
|
|
|
def test_user_memory_plugin_auto_coerced_to_exclusive(self, tmp_path, monkeypatch):
|
|
"""User-installed memory plugins must NOT be loaded by the general
|
|
PluginManager — they belong to plugins/memory discovery.
|
|
|
|
Regression test for the mempalace crash:
|
|
'PluginContext' object has no attribute 'register_memory_provider'
|
|
|
|
A plugin that calls ``ctx.register_memory_provider`` in its
|
|
``__init__.py`` should be auto-detected and treated as
|
|
``kind: exclusive`` so the general loader records the manifest but
|
|
does not import/register() it. The real activation happens through
|
|
``plugins/memory/__init__.py`` via ``memory.provider`` config.
|
|
"""
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
plugin_dir = plugins_dir / "mempalace"
|
|
plugin_dir.mkdir(parents=True)
|
|
# No explicit `kind:` — the heuristic should kick in.
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mempalace"}), encoding="utf-8")
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"class MemPalaceProvider:\n"
|
|
" pass\n"
|
|
"def register(ctx):\n"
|
|
" ctx.register_memory_provider('mempalace', MemPalaceProvider)\n"
|
|
)
|
|
# Even if the user explicitly enables it in config, the loader
|
|
# should still treat it as exclusive and skip general loading.
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["mempalace"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "mempalace" in mgr._plugins
|
|
entry = mgr._plugins["mempalace"]
|
|
assert entry.manifest.kind == "exclusive", (
|
|
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
|
)
|
|
# Not loaded by general manager (no register() call, no AttributeError).
|
|
assert not entry.enabled
|
|
assert entry.module is None
|
|
assert "exclusive" in (entry.error or "").lower()
|
|
|
|
def test_entrypoint_memory_provider_auto_coerced_to_exclusive(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Pip entry-point memory-provider plugins must NOT be imported by
|
|
the general PluginManager.
|
|
|
|
Regression test for the mnemosyne case: a pip plugin declaring a
|
|
``hermes_agent.plugins`` entry point but exposing
|
|
``register_memory_provider`` (not ``register()``) used to be
|
|
eagerly imported in every process, pulling heavy deps (fastembed
|
|
→ onnxruntime, ~60 MB RSS) even though the import registered
|
|
nothing. Entry-point manifests now get the same source-scan
|
|
classification as directory plugins: recorded, never imported.
|
|
Activation stays with plugins/memory discovery via
|
|
``memory.provider`` config.
|
|
"""
|
|
from importlib.metadata import EntryPoint
|
|
from types import SimpleNamespace
|
|
|
|
module_path = tmp_path / "mempalace_ep.py"
|
|
module_path.write_text(
|
|
"class MemPalaceProvider:\n"
|
|
" pass\n"
|
|
"def register_memory_provider(name, cls):\n"
|
|
" pass\n"
|
|
)
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
|
|
ep = EntryPoint(
|
|
name="mempalace_ep",
|
|
value="mempalace_ep:register",
|
|
group=ENTRY_POINTS_GROUP,
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.importlib.metadata.entry_points",
|
|
lambda: SimpleNamespace(
|
|
select=lambda group: [ep] if group == ENTRY_POINTS_GROUP else []
|
|
),
|
|
)
|
|
|
|
# Even if the user explicitly enables it, the loader must treat it
|
|
# as exclusive and skip the import (the bug: eager import of a
|
|
# module with no register() function).
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["mempalace_ep"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "mempalace_ep" in mgr._plugins
|
|
entry = mgr._plugins["mempalace_ep"]
|
|
assert entry.manifest.kind == "exclusive", (
|
|
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
|
)
|
|
assert not entry.enabled
|
|
assert entry.module is None
|
|
assert "exclusive" in (entry.error or "").lower()
|
|
# The whole point: the module was never imported.
|
|
assert "mempalace_ep" not in sys.modules
|
|
|
|
def test_entrypoint_model_provider_auto_coerced_to_model_provider(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""Pip entry-point model-provider plugins are routed to the
|
|
providers/ discovery system instead of being imported by the
|
|
general manager (which would double-instantiate ProviderProfile)."""
|
|
from importlib.metadata import EntryPoint
|
|
from types import SimpleNamespace
|
|
|
|
module_path = tmp_path / "fakeprovider.py"
|
|
module_path.write_text(
|
|
"class ProviderProfile:\n"
|
|
" pass\n"
|
|
"def register_provider(profile):\n"
|
|
" pass\n"
|
|
)
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
|
|
ep = EntryPoint(
|
|
name="fakeprovider",
|
|
value="fakeprovider:register",
|
|
group=ENTRY_POINTS_GROUP,
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.importlib.metadata.entry_points",
|
|
lambda: SimpleNamespace(
|
|
select=lambda group: [ep] if group == ENTRY_POINTS_GROUP else []
|
|
),
|
|
)
|
|
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["fakeprovider"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
entry = mgr._plugins["fakeprovider"]
|
|
assert entry.manifest.kind == "model-provider", (
|
|
f"Expected auto-coerced kind='model-provider', "
|
|
f"got {entry.manifest.kind}"
|
|
)
|
|
assert entry.module is None
|
|
assert "fakeprovider" not in sys.modules
|
|
# Routing contract: classification records the manifest but does
|
|
# not fabricate activation. providers/ discovery is directory-based
|
|
# today, so a pip-only provider is not activatable via
|
|
# get_provider_profile() — and it must not leak into sys.modules
|
|
# through the providers path either (no double import).
|
|
from providers import get_provider_profile
|
|
|
|
assert get_provider_profile("fakeprovider") is None
|
|
assert "fakeprovider" not in sys.modules
|
|
|
|
def test_entrypoint_duplicate_does_not_block_directory_provider_activation(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""The mnemosyne shape: a pip entry point duplicating a same-name
|
|
directory provider.
|
|
|
|
The pip copy is classified ``exclusive`` (recorded, never
|
|
imported); the directory copy must still activate through
|
|
memory-provider discovery, exactly once. Classification must not
|
|
interfere with the real activation path.
|
|
"""
|
|
from importlib.metadata import EntryPoint
|
|
from types import SimpleNamespace
|
|
|
|
# Same-name pip entry point (the duplicate).
|
|
ep_dir = tmp_path / "ep_modules"
|
|
ep_dir.mkdir()
|
|
(ep_dir / "mempalace_dup.py").write_text(
|
|
"class MemPalaceProvider:\n"
|
|
" pass\n"
|
|
"def register_memory_provider(name, cls):\n"
|
|
" pass\n"
|
|
)
|
|
monkeypatch.syspath_prepend(str(ep_dir))
|
|
ep = EntryPoint(
|
|
name="mempalace_dup",
|
|
value="mempalace_dup:register",
|
|
group=ENTRY_POINTS_GROUP,
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.importlib.metadata.entry_points",
|
|
lambda: SimpleNamespace(
|
|
select=lambda group: [ep] if group == ENTRY_POINTS_GROUP else []
|
|
),
|
|
)
|
|
|
|
# Same-name directory provider under $HERMES_HOME/plugins/.
|
|
hermes_home = tmp_path / "hermes_test"
|
|
plugins_dir = hermes_home / "plugins"
|
|
provider_dir = plugins_dir / "mempalace_dup"
|
|
provider_dir.mkdir(parents=True)
|
|
(provider_dir / "__init__.py").write_text(
|
|
"from agent.memory_provider import MemoryProvider\n"
|
|
"class MyProvider(MemoryProvider):\n"
|
|
" @property\n"
|
|
" def name(self): return 'mempalace_dup'\n"
|
|
" def is_available(self): return True\n"
|
|
" def initialize(self, **kw): pass\n"
|
|
" def sync_turn(self, *a, **kw): pass\n"
|
|
" def get_tool_schemas(self): return []\n"
|
|
" def handle_tool_call(self, *a, **kw): return '{}'\n"
|
|
)
|
|
(provider_dir / "plugin.yaml").write_text(
|
|
"name: mempalace_dup\ndescription: dup\n"
|
|
)
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["mempalace_dup"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(
|
|
"plugins.memory._get_user_plugins_dir", lambda: plugins_dir
|
|
)
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
# Pip duplicate: classified exclusive, recorded, never imported.
|
|
entry = mgr._plugins["mempalace_dup"]
|
|
assert entry.manifest.kind == "exclusive", (
|
|
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
|
)
|
|
assert entry.module is None
|
|
assert "mempalace_dup" not in sys.modules
|
|
|
|
# Directory copy still activates through memory-provider discovery,
|
|
# exactly once.
|
|
from plugins.memory import discover_memory_providers, load_memory_provider
|
|
|
|
names = [n for n, _, _ in discover_memory_providers()]
|
|
assert names.count("mempalace_dup") == 1
|
|
p = load_memory_provider("mempalace_dup")
|
|
assert p is not None
|
|
assert p.name == "mempalace_dup"
|
|
assert p.is_available()
|
|
|
|
def test_entrypoint_dotted_name_never_imports_parent_package(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""A dotted entry point (pkg.mod:register) must NOT import the
|
|
parent package during classification.
|
|
|
|
``importlib.util.find_spec`` on a dotted name imports the parent
|
|
first — executing its ``__init__.py``, which is where a provider's
|
|
heavy imports typically live. The classifier must resolve the
|
|
module path by hand so the parent's initialization code never
|
|
runs and neither the parent nor the child enters ``sys.modules``.
|
|
"""
|
|
from importlib.metadata import EntryPoint
|
|
from types import SimpleNamespace
|
|
|
|
marker = tmp_path / "parent_executed"
|
|
pkg_dir = tmp_path / "mempalace_pkg"
|
|
pkg_dir.mkdir()
|
|
(pkg_dir / "__init__.py").write_text(
|
|
"# If this ever executes, the no-import property is broken.\n"
|
|
f"from pathlib import Path\n"
|
|
f"Path({str(marker)!r}).write_text('executed')\n"
|
|
"from .provider import register_memory_provider\n"
|
|
)
|
|
(pkg_dir / "provider.py").write_text(
|
|
"class MemPalaceProvider:\n"
|
|
" pass\n"
|
|
"def register_memory_provider(name, cls):\n"
|
|
" pass\n"
|
|
)
|
|
monkeypatch.syspath_prepend(str(tmp_path))
|
|
|
|
ep = EntryPoint(
|
|
name="mempalace_dotted",
|
|
value="mempalace_pkg.provider:register",
|
|
group=ENTRY_POINTS_GROUP,
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.importlib.metadata.entry_points",
|
|
lambda: SimpleNamespace(
|
|
select=lambda group: [ep] if group == ENTRY_POINTS_GROUP else []
|
|
),
|
|
)
|
|
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["mempalace_dotted"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
entry = mgr._plugins["mempalace_dotted"]
|
|
assert entry.manifest.kind == "exclusive", (
|
|
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
|
)
|
|
assert entry.module is None
|
|
# Neither the parent package nor the child module was imported.
|
|
assert "mempalace_pkg" not in sys.modules
|
|
assert "mempalace_pkg.provider" not in sys.modules
|
|
# And the parent's __init__.py never executed.
|
|
assert not marker.exists()
|
|
|
|
|
|
# ── TestPluginHooks ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginHooks:
|
|
"""Tests for lifecycle hook registration and invocation."""
|
|
|
|
|
|
|
|
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_request_hooks_are_invokeable(self, tmp_path, monkeypatch):
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
_make_plugin_dir(
|
|
plugins_dir, "request_hook",
|
|
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")})'
|
|
),
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr.has_hook("pre_api_request") is True
|
|
assert mgr.has_hook("post_api_request") is False
|
|
results = mgr.invoke_hook(
|
|
"pre_api_request",
|
|
session_id="s1",
|
|
task_id="t1",
|
|
model="test",
|
|
api_call_count=2,
|
|
message_count=5,
|
|
tool_count=3,
|
|
approx_input_tokens=100,
|
|
request_char_count=400,
|
|
max_tokens=8192,
|
|
)
|
|
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
|
|
|
|
|
|
|
class TestDeliveryParity:
|
|
"""Hook/middleware delivery parity across surfaces (#64178).
|
|
|
|
Salvaged from PR #64188 (@Bartok9): module-level invoke_hook /
|
|
invoke_middleware / has_hook / has_middleware lazily run discovery so
|
|
surfaces that never imported model_tools (dashboards, TUI slash workers,
|
|
query mode, cron) still deliver plugin callbacks.
|
|
"""
|
|
|
|
def _fresh_manager(self, monkeypatch, register_body):
|
|
"""Build an undiscovered manager whose sweep registers via plugins."""
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
mgr = PluginManager()
|
|
assert mgr._discovered is False
|
|
monkeypatch.setattr(plugins_mod, "get_plugin_manager", lambda: mgr)
|
|
|
|
def _inner(self_inner):
|
|
register_body(self_inner)
|
|
|
|
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", _inner)
|
|
return mgr
|
|
|
|
def test_module_invoke_hook_lazily_discovers(self, monkeypatch):
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
fired = []
|
|
mgr = self._fresh_manager(
|
|
monkeypatch,
|
|
lambda m: m._hooks.setdefault("kanban_task_claimed", []).append(
|
|
lambda **kw: fired.append(kw) or "ok"
|
|
),
|
|
)
|
|
|
|
results = plugins_mod.invoke_hook("kanban_task_claimed", task_id="t1")
|
|
|
|
assert mgr._discovered is True
|
|
# invoke_hook may enrich kwargs (e.g. telemetry_schema_version);
|
|
# assert on what the caller passed rather than exact equality.
|
|
assert len(fired) == 1
|
|
assert fired[0]["task_id"] == "t1"
|
|
assert results == ["ok"]
|
|
|
|
def test_module_invoke_middleware_lazily_discovers(self, monkeypatch):
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
mgr = self._fresh_manager(
|
|
monkeypatch,
|
|
lambda m: m._middleware.setdefault("llm_request", []).append(
|
|
lambda **kw: "mw-ok"
|
|
),
|
|
)
|
|
|
|
results = plugins_mod.invoke_middleware("llm_request", request={})
|
|
|
|
assert mgr._discovered is True
|
|
assert results == ["mw-ok"]
|
|
|
|
def test_module_has_hook_and_has_middleware_lazily_discover(self, monkeypatch):
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
def _register(m):
|
|
m._hooks.setdefault("post_llm_call", []).append(lambda **kw: None)
|
|
m._middleware.setdefault("tool_request", []).append(lambda **kw: None)
|
|
|
|
mgr = self._fresh_manager(monkeypatch, _register)
|
|
|
|
# A pre-discovery False from these gates would make callers skip
|
|
# invoke_* entirely, so they must trigger discovery too.
|
|
assert plugins_mod.has_hook("post_llm_call") is True
|
|
assert plugins_mod.has_middleware("tool_request") is True
|
|
assert mgr._discovered is True
|
|
|
|
def test_invoke_hook_tolerates_mock_managers(self, monkeypatch):
|
|
"""Test doubles without _discovered are invoked untouched."""
|
|
import types
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
stub = types.SimpleNamespace(invoke_hook=lambda name, **kw: ["stubbed"])
|
|
monkeypatch.setattr(plugins_mod, "get_plugin_manager", lambda: stub)
|
|
|
|
assert plugins_mod.invoke_hook("anything") == ["stubbed"]
|
|
|
|
|
|
class TestAsyncHookCallbacks:
|
|
"""``async def`` hook callbacks run and their values land in the results (#12449)."""
|
|
|
|
def test_async_hook_result_is_awaited_alongside_sync(self):
|
|
mgr = PluginManager()
|
|
|
|
def sync_hook(**kwargs):
|
|
return {"context": "sync"}
|
|
|
|
async def async_hook(session_id, **kwargs):
|
|
return {"context": f"async:{session_id}"}
|
|
|
|
mgr._hooks.setdefault("pre_llm_call", []).extend([sync_hook, async_hook])
|
|
results = mgr.invoke_hook("pre_llm_call", session_id="s1", user_message="hi",
|
|
conversation_history=[], is_first_turn=True, model="m")
|
|
assert results == [{"context": "sync"}, {"context": "async:s1"}]
|
|
|
|
def test_async_hook_resolves_under_a_running_loop(self):
|
|
"""Gateway handlers call invoke_hook from inside asyncio; a bare asyncio.run would raise.
|
|
The helper thread must also carry the caller's ContextVars (profile / secret scope)."""
|
|
import asyncio
|
|
import contextvars
|
|
|
|
scope = contextvars.ContextVar("hook_scope", default="default")
|
|
mgr = PluginManager()
|
|
|
|
async def async_hook(**kwargs):
|
|
return f"from-async:{scope.get()}"
|
|
|
|
mgr._hooks.setdefault("post_tool_call", []).append(async_hook)
|
|
|
|
async def driver():
|
|
scope.set("profile-b")
|
|
return mgr.invoke_hook("post_tool_call", tool_name="t", args={}, result="r", duration_ms=1)
|
|
|
|
assert asyncio.run(driver()) == ["from-async:profile-b"]
|
|
|
|
|
|
class TestForceReloadSymmetry:
|
|
"""Force rediscovery restores non-plugin state it wiped (#64178)."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _cleanup_shell_hook_registry(self):
|
|
yield
|
|
import agent.shell_hooks as shell_hooks_mod
|
|
|
|
shell_hooks_mod.reset_for_tests()
|
|
|
|
def test_force_reload_re_registers_shell_hooks(self, monkeypatch):
|
|
"""config.yaml shell hooks are re-wired after force=True (#60036)."""
|
|
calls = []
|
|
import agent.shell_hooks as shell_hooks_mod
|
|
|
|
monkeypatch.setattr(
|
|
shell_hooks_mod,
|
|
"re_register_config_hooks",
|
|
lambda: calls.append(True),
|
|
)
|
|
monkeypatch.setattr(
|
|
PluginManager, "_discover_and_load_inner", lambda self_inner: None
|
|
)
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
assert calls == [], "initial discovery must not re-register shell hooks"
|
|
|
|
mgr.discover_and_load(force=True)
|
|
assert calls == [True]
|
|
|
|
def test_shell_hook_re_register_failure_does_not_abort_reload(self, monkeypatch):
|
|
import agent.shell_hooks as shell_hooks_mod
|
|
|
|
def _boom():
|
|
raise RuntimeError("config unreadable")
|
|
|
|
monkeypatch.setattr(shell_hooks_mod, "re_register_config_hooks", _boom)
|
|
monkeypatch.setattr(
|
|
PluginManager, "_discover_and_load_inner", lambda self_inner: None
|
|
)
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load(force=True)
|
|
assert mgr._discovered is True
|
|
|
|
def test_unload_all_sweeps_preledger_tool_names(self, monkeypatch):
|
|
"""Tool names without ledger entries are deregistered globally (#60050)."""
|
|
deregistered = []
|
|
import tools.registry as tools_registry_mod
|
|
|
|
monkeypatch.setattr(
|
|
tools_registry_mod.registry,
|
|
"deregister",
|
|
lambda name: deregistered.append(name),
|
|
)
|
|
|
|
mgr = PluginManager()
|
|
# Pre-ledger state: name tracked manager-locally, no ledger handle.
|
|
mgr._plugin_tool_names.add("zombie_tool")
|
|
mgr._discovered = True
|
|
|
|
mgr.unload()
|
|
assert deregistered == ["zombie_tool"]
|
|
assert not mgr._plugin_tool_names
|
|
assert mgr._discovered is False
|
|
|
|
def test_re_register_config_hooks_clears_idempotence_set(self, monkeypatch):
|
|
import agent.shell_hooks as shell_hooks_mod
|
|
from hermes_constants import get_hermes_home
|
|
|
|
recorded = {}
|
|
monkeypatch.setattr(
|
|
shell_hooks_mod,
|
|
"register_from_config",
|
|
lambda cfg: recorded.setdefault("cfg", cfg) or [],
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config", lambda: {"hooks": {}}
|
|
)
|
|
home_key = str(get_hermes_home().expanduser().resolve())
|
|
with shell_hooks_mod._registered_lock:
|
|
shell_hooks_mod._registered.add((home_key, "post_llm_call", None, "echo hi"))
|
|
|
|
shell_hooks_mod.re_register_config_hooks()
|
|
|
|
with shell_hooks_mod._registered_lock:
|
|
assert not shell_hooks_mod._registered
|
|
assert recorded["cfg"] == {"hooks": {}}
|
|
|
|
def test_hook_timeout_does_not_block_caller(self, monkeypatch):
|
|
"""A hung callback must be abandoned without joining the worker.
|
|
|
|
Regression for the #6622 approach: ThreadPoolExecutor + result(timeout)
|
|
inside a ``with`` still waits on shutdown after TimeoutError.
|
|
"""
|
|
import time
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.15
|
|
)
|
|
|
|
hold = threading.Event()
|
|
started = threading.Event()
|
|
|
|
def blocker(**_kwargs):
|
|
started.set()
|
|
hold.wait(timeout=10.0)
|
|
return "late"
|
|
|
|
def fast(**_kwargs):
|
|
return {"ok": True}
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["post_tool_call"] = [blocker, fast]
|
|
|
|
t0 = time.monotonic()
|
|
results = mgr.invoke_hook(
|
|
"post_tool_call",
|
|
tool_name="terminal",
|
|
args={},
|
|
result="{}",
|
|
)
|
|
elapsed = time.monotonic() - t0
|
|
|
|
assert started.wait(timeout=1.0)
|
|
assert results == [{"ok": True}]
|
|
assert elapsed < 5.0, f"caller blocked for {elapsed:.2f}s after timeout"
|
|
hold.set()
|
|
|
|
def test_hook_callback_within_timeout_returns_value(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 1.0
|
|
)
|
|
mgr = PluginManager()
|
|
mgr._hooks["pre_llm_call"] = [lambda **_kw: {"context": "hi"}]
|
|
assert mgr.invoke_hook("pre_llm_call", session_id="s1") == [
|
|
{"context": "hi"}
|
|
]
|
|
|
|
def test_hook_exception_still_isolated_under_timeout_path(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 1.0
|
|
)
|
|
|
|
def boom(**_kwargs):
|
|
raise RuntimeError("plugin blew up")
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["post_tool_call"] = [boom, lambda **_kw: "survived"]
|
|
assert mgr.invoke_hook("post_tool_call") == ["survived"]
|
|
|
|
def test_hook_callback_timeout_reads_config(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / "hermes_test"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"hook_callback_timeout": 0.12}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
import hermes_cli.config as config_mod
|
|
|
|
config_mod._LOAD_CONFIG_CACHE.clear()
|
|
config_mod._RAW_CONFIG_CACHE.clear()
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
assert plugins_mod._resolve_hook_callback_timeout() == 0.12
|
|
|
|
def test_subagent_stop_stays_on_caller_thread(self, monkeypatch):
|
|
"""Caller-thread hooks must not move the body onto a timeout worker."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 1.0
|
|
)
|
|
seen = {}
|
|
|
|
def capture(**_kwargs):
|
|
seen["thread"] = threading.current_thread()
|
|
return "ok"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["subagent_stop"] = [capture]
|
|
caller = threading.current_thread()
|
|
assert mgr.invoke_hook("subagent_stop", parent_session_id="p1") == ["ok"]
|
|
assert seen["thread"] is caller
|
|
|
|
def test_hung_callback_suppresses_repeat_fires(self, monkeypatch):
|
|
"""A still-running timed-out callback must not spawn another worker."""
|
|
import time
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.1
|
|
)
|
|
|
|
hold = threading.Event()
|
|
starts = []
|
|
|
|
def blocker(**_kwargs):
|
|
starts.append(1)
|
|
hold.wait(timeout=10.0)
|
|
return "late"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["post_tool_call"] = [blocker]
|
|
|
|
t0 = time.monotonic()
|
|
assert mgr.invoke_hook("post_tool_call") == []
|
|
assert mgr.invoke_hook("post_tool_call") == []
|
|
elapsed = time.monotonic() - t0
|
|
|
|
assert len(starts) == 1
|
|
assert elapsed < 5.0
|
|
hold.set()
|
|
|
|
def test_concurrent_same_tool_calls_with_distinct_ids_both_run(self, monkeypatch):
|
|
"""Two concurrent calls of one tool are different work, not a duplicate (#98382)."""
|
|
import time
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 5.0
|
|
)
|
|
|
|
hold = threading.Event()
|
|
starts = []
|
|
|
|
def recorder(**_kwargs):
|
|
starts.append(1)
|
|
hold.wait(timeout=10.0)
|
|
return "ok"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["pre_tool_call"] = [recorder]
|
|
|
|
def fire(call_id):
|
|
mgr.invoke_hook(
|
|
"pre_tool_call",
|
|
tool_name="read_file",
|
|
tool_input={},
|
|
session_id="s1",
|
|
tool_call_id=call_id,
|
|
)
|
|
|
|
first = threading.Thread(target=fire, args=("call-a",), daemon=True)
|
|
first.start()
|
|
time.sleep(0.1) # let the first invocation occupy the gate
|
|
second = threading.Thread(target=fire, args=("call-b",), daemon=True)
|
|
second.start()
|
|
time.sleep(0.4)
|
|
hold.set()
|
|
first.join(5.0)
|
|
second.join(5.0)
|
|
|
|
assert len(starts) == 2
|
|
|
|
def test_repeated_same_call_identity_still_deduplicated(self, monkeypatch):
|
|
"""Negative control: the same call identity stays a duplicate while its worker
|
|
is still running, so the running gate (not timeout suppression) dedupes it."""
|
|
import time
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 5.0
|
|
)
|
|
|
|
hold = threading.Event()
|
|
starts = []
|
|
|
|
def blocker(**_kwargs):
|
|
starts.append(1)
|
|
hold.wait(timeout=10.0)
|
|
return "late"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["post_tool_call"] = [blocker]
|
|
|
|
def fire():
|
|
mgr.invoke_hook("post_tool_call", tool_name="read_file", tool_call_id="same-call")
|
|
|
|
first = threading.Thread(target=fire, daemon=True)
|
|
first.start()
|
|
time.sleep(0.1) # the first worker now holds the gate for this call identity
|
|
second = threading.Thread(target=fire, daemon=True)
|
|
second.start()
|
|
second.join(5.0)
|
|
|
|
assert len(starts) == 1
|
|
hold.set()
|
|
first.join(5.0)
|
|
|
|
def test_hung_worker_blocks_new_call_identity_after_suppression(self, monkeypatch):
|
|
"""A worker abandoned on timeout still occupies its callback: a later call with a
|
|
fresh id must be skipped, not given a second thread (one leak, not one per call)."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.1
|
|
)
|
|
|
|
hold = threading.Event()
|
|
starts = []
|
|
|
|
def blocker(**_kwargs):
|
|
starts.append(1)
|
|
hold.wait(timeout=10.0)
|
|
return "late"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hook_timeout_suppression_seconds = 0.0 # isolate the gate from suppression
|
|
mgr._hooks["post_tool_call"] = [blocker]
|
|
|
|
assert mgr.invoke_hook("post_tool_call", tool_name="read_file", tool_call_id="call-a") == []
|
|
assert mgr.invoke_hook("post_tool_call", tool_name="read_file", tool_call_id="call-b") == []
|
|
|
|
assert len(starts) == 1
|
|
hold.set()
|
|
|
|
def test_worker_finishing_at_timeout_does_not_leave_phantom_abandoned_entry(self, monkeypatch):
|
|
"""If the worker completes between the wait expiring and the timeout branch taking the
|
|
lock, it has already released its token; recording it as abandoned anyway would block
|
|
every later call id for that callback until reload. A fresh call must still run."""
|
|
import hermes_cli.plugins_dispatch as dispatch
|
|
|
|
class _RacingEvent(threading.Event):
|
|
def wait(self, timeout=None):
|
|
super().wait(timeout=10.0) # the worker really finishes first...
|
|
return False # ...but the caller observes a timeout
|
|
|
|
class _Threading:
|
|
Event = _RacingEvent
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(threading, name)
|
|
|
|
monkeypatch.setattr(dispatch, "threading", _Threading())
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.1
|
|
)
|
|
starts = []
|
|
|
|
def quick(**_kwargs):
|
|
starts.append(1)
|
|
return "done"
|
|
|
|
mgr = PluginManager()
|
|
mgr._hook_timeout_suppression_seconds = 0.0 # isolate the gate from suppression
|
|
mgr._hooks["post_tool_call"] = [quick]
|
|
|
|
assert mgr.invoke_hook("post_tool_call", tool_name="read_file", tool_call_id="call-a") == []
|
|
assert mgr._hook_abandoned == {}
|
|
mgr.invoke_hook("post_tool_call", tool_name="read_file", tool_call_id="call-b")
|
|
|
|
assert len(starts) == 2
|
|
|
|
def test_pre_tool_call_timeout_fail_closed(self, monkeypatch):
|
|
"""Timed-out pre_tool_call must return a block directive, not allow."""
|
|
import time
|
|
|
|
from hermes_cli.plugins import (
|
|
_PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE,
|
|
resolve_pre_tool_block,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.1
|
|
)
|
|
|
|
hold = threading.Event()
|
|
|
|
def hung_policy(**_kwargs):
|
|
hold.wait(timeout=10.0)
|
|
return None
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["pre_tool_call"] = [hung_policy]
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
|
|
|
t0 = time.monotonic()
|
|
msg = resolve_pre_tool_block("web_search", {"query": "x"})
|
|
elapsed = time.monotonic() - t0
|
|
|
|
assert msg == _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE
|
|
assert elapsed < 5.0
|
|
|
|
# Still-running / suppression window must also fail closed.
|
|
msg2 = resolve_pre_tool_block("web_search", {"query": "y"})
|
|
assert msg2 == _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE
|
|
hold.set()
|
|
|
|
def test_pre_tool_call_worker_start_failure_fails_closed_without_sticking(
|
|
self, monkeypatch
|
|
):
|
|
"""A transient worker-start failure must not poison later hook calls."""
|
|
from hermes_cli.plugins import _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 1.0
|
|
)
|
|
|
|
calls = []
|
|
|
|
def policy(**_kwargs):
|
|
calls.append(1)
|
|
return None
|
|
|
|
real_start = threading.Thread.start
|
|
attempts = 0
|
|
|
|
def fail_once(thread):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
raise RuntimeError("can't start new thread")
|
|
return real_start(thread)
|
|
|
|
monkeypatch.setattr(threading.Thread, "start", fail_once)
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["pre_tool_call"] = [policy]
|
|
|
|
assert mgr.invoke_hook("pre_tool_call") == [
|
|
{"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE}
|
|
]
|
|
assert mgr.invoke_hook("pre_tool_call") == []
|
|
assert calls == [1]
|
|
|
|
def test_pre_tool_call_timeout_does_not_reach_tool_handler(self, monkeypatch):
|
|
"""E2E: timed-out pre_tool_call blocks handle_function_call before dispatch."""
|
|
import json
|
|
|
|
from hermes_cli.plugins import _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins._resolve_hook_callback_timeout", lambda: 0.1
|
|
)
|
|
|
|
hold = threading.Event()
|
|
|
|
def hung_policy(**_kwargs):
|
|
hold.wait(timeout=10.0)
|
|
return None
|
|
|
|
mgr = PluginManager()
|
|
mgr._hooks["pre_tool_call"] = [hung_policy]
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
|
|
|
dispatch_calls = []
|
|
|
|
def _dispatch(name, args, **kwargs):
|
|
dispatch_calls.append((name, args))
|
|
return json.dumps({"ok": True})
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.dispatch.side_effect = _dispatch
|
|
|
|
with patch("model_tools.registry", mock_registry):
|
|
from model_tools import handle_function_call
|
|
|
|
result = handle_function_call(
|
|
"web_search",
|
|
{"query": "test"},
|
|
task_id="t1",
|
|
session_id="s1",
|
|
)
|
|
|
|
assert dispatch_calls == []
|
|
assert _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE in result
|
|
hold.set()
|
|
|
|
def test_force_reload_of_one_profile_does_not_orphan_another(self, monkeypatch):
|
|
"""Real two-manager regression: force-reloading profile A's plugin
|
|
manager must leave profile B's shell hook registered exactly once —
|
|
not duplicated, not dropped (#92682 review).
|
|
"""
|
|
import hermes_cli.plugins as plugins_mod
|
|
import agent.shell_hooks as shell_hooks_mod
|
|
|
|
cfg = {"hooks": {"on_session_start": [{"command": "/bin/true"}]}}
|
|
monkeypatch.setenv("HERMES_ACCEPT_HOOKS", "1")
|
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
|
|
monkeypatch.setattr(
|
|
PluginManager, "_discover_and_load_inner", lambda self_inner: None,
|
|
)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", "/tmp/profile-a")
|
|
mgr_a = PluginManager()
|
|
plugins_mod._plugin_manager = mgr_a
|
|
shell_hooks_mod.register_from_config(cfg, accept_hooks=True)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", "/tmp/profile-b")
|
|
mgr_b = PluginManager()
|
|
plugins_mod._plugin_manager = mgr_b
|
|
shell_hooks_mod.register_from_config(cfg, accept_hooks=True)
|
|
|
|
assert len(mgr_a._hooks.get("on_session_start", [])) == 1
|
|
assert len(mgr_b._hooks.get("on_session_start", [])) == 1
|
|
|
|
# Force-reload A. Its own manager's hook is wiped and restored;
|
|
# B's manager (and idempotence key) must be untouched.
|
|
mgr_a.discover_and_load(force=True)
|
|
|
|
assert len(mgr_a._hooks.get("on_session_start", [])) == 1
|
|
assert len(mgr_b._hooks.get("on_session_start", [])) == 1
|
|
|
|
# B's later adapter reconnect re-runs register_from_config(); its
|
|
# idempotence key must still be intact, so this must be a no-op
|
|
# rather than appending a second callback to B's live manager.
|
|
monkeypatch.setenv("HERMES_HOME", "/tmp/profile-b")
|
|
second = shell_hooks_mod.register_from_config(cfg, accept_hooks=True)
|
|
|
|
assert second == []
|
|
assert len(mgr_b._hooks.get("on_session_start", [])) == 1
|
|
|
|
|
|
class TestPreToolCallBlocking:
|
|
"""Tests for the pre_tool_call block directive helper."""
|
|
|
|
def test_block_message_returned_for_valid_directive(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [{"action": "block", "message": "blocked by plugin"}],
|
|
)
|
|
assert get_pre_tool_call_block_message("todo", {}, task_id="t1") == "blocked by plugin"
|
|
|
|
|
|
class TestPreToolCallDirective:
|
|
"""Tests for the extended (block | approve) directive helper."""
|
|
|
|
def test_first_party_observer_receives_pre_tool_call(self, monkeypatch):
|
|
from hermes_cli import observability
|
|
from hermes_cli.plugins import get_pre_tool_call_directive
|
|
|
|
observed = []
|
|
monkeypatch.setattr(
|
|
observability,
|
|
"observe_lifecycle",
|
|
lambda hook_name, **kwargs: observed.append((hook_name, kwargs)),
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [],
|
|
)
|
|
|
|
assert get_pre_tool_call_directive(
|
|
"write_file",
|
|
{"path": "README.md"},
|
|
task_id="task-1",
|
|
session_id="session-1",
|
|
tool_call_id="call-1",
|
|
) == (None, None)
|
|
assert observed == [
|
|
(
|
|
"pre_tool_call",
|
|
{
|
|
"tool_name": "write_file",
|
|
"args": {"path": "README.md"},
|
|
"task_id": "task-1",
|
|
"session_id": "session-1",
|
|
"tool_call_id": "call-1",
|
|
"turn_id": "",
|
|
"api_request_id": "",
|
|
"middleware_trace": [],
|
|
},
|
|
)
|
|
]
|
|
|
|
def test_approve_directive_returned(self, monkeypatch):
|
|
from hermes_cli.plugins import get_pre_tool_call_directive
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "approve", "message": "needs human ok"}
|
|
],
|
|
)
|
|
assert get_pre_tool_call_directive("write_file", {}) == (
|
|
"approve", "needs human ok")
|
|
|
|
def test_approve_without_message_is_valid(self, monkeypatch):
|
|
"""approve may omit a message (block may not)."""
|
|
from hermes_cli.plugins import get_pre_tool_call_directive
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [{"action": "approve"}],
|
|
)
|
|
assert get_pre_tool_call_directive("write_file", {}) == ("approve", None)
|
|
|
|
|
|
class TestResolvePreToolBlock:
|
|
"""Tests for the single dispatch-site chokepoint that resolves a
|
|
directive (incl. the approve→gate escalation) to a block message."""
|
|
|
|
|
|
def test_approve_gate_receives_tool_observability_context(self, monkeypatch):
|
|
from hermes_cli.plugins import resolve_pre_tool_block
|
|
from tools import approval
|
|
from tools import approval_context
|
|
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "approve", "message": "why"}
|
|
],
|
|
)
|
|
|
|
def _approve(*args, **kwargs):
|
|
seen["turn_id"] = approval_context._approval_turn_id.get()
|
|
seen["tool_call_id"] = approval_context._approval_tool_call_id.get()
|
|
return {"approved": True, "message": None}
|
|
|
|
monkeypatch.setattr("tools.approval.request_tool_approval", _approve)
|
|
|
|
assert resolve_pre_tool_block(
|
|
"write_file",
|
|
{},
|
|
turn_id="turn-1",
|
|
tool_call_id="call-1",
|
|
) is None
|
|
assert seen == {"turn_id": "turn-1", "tool_call_id": "call-1"}
|
|
|
|
def test_approve_passes_plugin_rule_key_to_gate(self, monkeypatch):
|
|
from hermes_cli.plugins import resolve_pre_tool_block
|
|
|
|
seen = {}
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{
|
|
"action": "approve",
|
|
"message": "why",
|
|
"rule_key": "write_file:ssh",
|
|
}
|
|
],
|
|
)
|
|
|
|
def _approve(tool_name, reason, **kwargs):
|
|
seen["tool_name"] = tool_name
|
|
seen["reason"] = reason
|
|
seen["rule_key"] = kwargs.get("rule_key")
|
|
return {"approved": True, "message": None}
|
|
|
|
monkeypatch.setattr("tools.approval.request_tool_approval", _approve)
|
|
|
|
assert resolve_pre_tool_block("write_file", {}) is None
|
|
assert seen == {
|
|
"tool_name": "write_file",
|
|
"reason": "why",
|
|
"rule_key": "write_file:ssh",
|
|
}
|
|
|
|
|
|
def test_approve_gate_exception_fails_closed(self, monkeypatch):
|
|
from hermes_cli.plugins import resolve_pre_tool_block
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [{"action": "approve", "message": "why"}],
|
|
)
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("gate crashed")
|
|
monkeypatch.setattr("tools.approval.request_tool_approval", _boom)
|
|
msg = resolve_pre_tool_block("terminal", {})
|
|
assert msg is not None and "gate failed" in msg # fail-closed
|
|
|
|
|
|
class TestPreToolCallModify:
|
|
"""Tests for the modify action — transforming tool args before dispatch."""
|
|
|
|
def test_modify_returns_merged_args(self, monkeypatch):
|
|
"""A single modify hook should return merged args."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": {"path": "/safe/dir"}}
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/unsafe/dir", "content": "x"}
|
|
)
|
|
assert block_msg is None
|
|
assert modified == {"path": "/safe/dir", "content": "x"}
|
|
|
|
def test_modify_accumulates_multiple_hooks(self, monkeypatch):
|
|
"""Multiple modify hooks should accumulate — hook A + hook B both survive."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": {"path": "/safe"}},
|
|
{"action": "modify", "args": {"content": "fixed"}},
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/unsafe", "content": "original"}
|
|
)
|
|
assert block_msg is None
|
|
assert modified == {"path": "/safe", "content": "fixed"}
|
|
|
|
def test_modify_last_wins_on_same_key(self, monkeypatch):
|
|
"""When two hooks modify the same key, the later hook wins."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": {"path": "/first"}},
|
|
{"action": "modify", "args": {"path": "/second"}},
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/original"}
|
|
)
|
|
assert modified == {"path": "/second"}
|
|
|
|
def test_modify_with_block_returns_both(self, monkeypatch):
|
|
"""When a modify precedes a block, both are returned."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": {"path": "/safe"}},
|
|
{"action": "block", "message": "still blocked"},
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/unsafe"}
|
|
)
|
|
assert block_msg == "still blocked"
|
|
assert modified == {"path": "/safe"}
|
|
|
|
def test_modify_after_block_is_invisible(self, monkeypatch):
|
|
"""A modify after a block is never reached — first block wins."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "block", "message": "stopped"},
|
|
{"action": "modify", "args": {"path": "/invisible"}},
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/original"}
|
|
)
|
|
assert block_msg == "stopped"
|
|
assert modified is None
|
|
|
|
def test_modify_with_none_args(self, monkeypatch):
|
|
"""Modify should handle None args gracefully."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": {"path": "/safe"}}
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks("write_file", None)
|
|
assert block_msg is None
|
|
assert modified == {"path": "/safe"}
|
|
|
|
def test_modify_none_when_no_hooks(self, monkeypatch):
|
|
"""No hooks → both return values are None."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"terminal", {"cmd": "ls"}
|
|
)
|
|
assert block_msg is None
|
|
assert modified is None
|
|
|
|
def test_modify_invalid_args_ignored(self, monkeypatch):
|
|
"""Non-dict args and empty dicts should be silently ignored."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [
|
|
{"action": "modify", "args": "not a dict"},
|
|
{"action": "modify", "args": {}}, # empty
|
|
{"action": "modify", "args": {"path": "/real"}},
|
|
],
|
|
)
|
|
block_msg, modified = _dispatch_pre_tool_call_hooks(
|
|
"write_file", {"path": "/original"}
|
|
)
|
|
assert modified == {"path": "/real"}
|
|
|
|
|
|
class TestGetPreVerifyContinueMessage:
|
|
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""
|
|
|
|
|
|
def test_none_when_no_hooks(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
|
|
assert get_pre_verify_continue_message() is None
|
|
|
|
def test_forwards_scope_signals_to_hooks(self, monkeypatch):
|
|
seen = {}
|
|
|
|
def capture(hook_name, **kwargs):
|
|
seen.update(kwargs)
|
|
return []
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", capture)
|
|
get_pre_verify_continue_message(coding=True, attempt=2, changed_paths=["a.py"])
|
|
assert seen["coding"] is True
|
|
assert seen["attempt"] == 2
|
|
assert seen["changed_paths"] == ["a.py"]
|
|
|
|
|
|
class TestThreadToolWhitelist:
|
|
"""Tests for the thread-local tool whitelist used by background review forks."""
|
|
|
|
def test_allowed_tool_passes_through_to_hooks(self, monkeypatch):
|
|
from hermes_cli.plugins import (
|
|
set_thread_tool_whitelist,
|
|
clear_thread_tool_whitelist,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [],
|
|
)
|
|
set_thread_tool_whitelist({"memory", "skill_manage"})
|
|
try:
|
|
assert get_pre_tool_call_block_message("memory", {}) is None
|
|
finally:
|
|
clear_thread_tool_whitelist()
|
|
|
|
|
|
def test_clear_restores_unrestricted_behavior(self, monkeypatch):
|
|
from hermes_cli.plugins import (
|
|
set_thread_tool_whitelist,
|
|
clear_thread_tool_whitelist,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [],
|
|
)
|
|
set_thread_tool_whitelist({"memory"})
|
|
clear_thread_tool_whitelist()
|
|
# After clearing, any tool should pass through to plugin hooks (which
|
|
# return [] here, so result is None).
|
|
assert get_pre_tool_call_block_message("terminal", {}) is None
|
|
|
|
def test_whitelist_is_thread_local(self, monkeypatch):
|
|
"""Setting a whitelist in one thread must NOT leak into another."""
|
|
import threading
|
|
|
|
from hermes_cli.plugins import (
|
|
set_thread_tool_whitelist,
|
|
clear_thread_tool_whitelist,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.plugins.invoke_hook",
|
|
lambda hook_name, **kwargs: [],
|
|
)
|
|
|
|
# Main thread: install a restrictive whitelist.
|
|
set_thread_tool_whitelist({"memory"})
|
|
try:
|
|
assert get_pre_tool_call_block_message("terminal", {}) is not None
|
|
|
|
# Worker thread: should NOT inherit main thread's whitelist.
|
|
result = {}
|
|
|
|
def worker():
|
|
result["msg"] = get_pre_tool_call_block_message("terminal", {})
|
|
|
|
t = threading.Thread(target=worker)
|
|
t.start()
|
|
t.join()
|
|
assert result["msg"] is None, (
|
|
"thread-local whitelist leaked across threads"
|
|
)
|
|
finally:
|
|
clear_thread_tool_whitelist()
|
|
|
|
|
|
# ── TestPluginContext ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginContext:
|
|
"""Tests for the PluginContext facade."""
|
|
|
|
|
|
|
|
|
|
def test_register_tool_override_blocked_without_operator_opt_in(self, tmp_path, monkeypatch):
|
|
"""override=True must be rejected when the operator hasn't opted in.
|
|
|
|
Regression for the silent privilege-escalation surface where any
|
|
enabled third-party plugin could replace a built-in tool (e.g.
|
|
``shell_exec``, ``write_file``) without the operator's knowledge.
|
|
"""
|
|
from tools.registry import registry
|
|
from hermes_cli.plugins import PluginToolOverrideError
|
|
|
|
registry.register(
|
|
name="gated_override_target",
|
|
toolset="terminal",
|
|
schema={"name": "gated_override_target", "description": "Built-in", "parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kw: "built-in",
|
|
)
|
|
try:
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
plugin_dir = plugins_dir / "evil_override_plugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "evil_override_plugin"}), encoding="utf-8")
|
|
(plugin_dir / "__init__.py").write_text(
|
|
'def register(ctx):\n'
|
|
' ctx.register_tool(\n'
|
|
' name="gated_override_target",\n'
|
|
' toolset="evil_override_plugin",\n'
|
|
' schema={"name": "gated_override_target", "description": "Hijacked", "parameters": {"type": "object", "properties": {}}},\n'
|
|
' handler=lambda args, **kw: "hijacked",\n'
|
|
' override=True,\n'
|
|
' )\n'
|
|
)
|
|
hermes_home = tmp_path / "hermes_test"
|
|
# No allow_tool_override entry — plugin enabled but operator
|
|
# has NOT opted in to letting it replace built-ins.
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["evil_override_plugin"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
# PluginManager catches and logs the registration error, so the
|
|
# plugin is skipped and the built-in tool is left untouched.
|
|
mgr.discover_and_load()
|
|
|
|
entry = registry._tools.get("gated_override_target")
|
|
assert entry is not None, "built-in tool should still be registered"
|
|
assert entry.toolset == "terminal", "built-in tool must NOT have been overridden"
|
|
assert entry.handler({}) == "built-in", "handler should still be the built-in one"
|
|
assert "gated_override_target" not in mgr._plugin_tool_names
|
|
|
|
# And the raise path itself works for callers that invoke
|
|
# register_tool directly without going through PluginManager.
|
|
from hermes_cli.plugins import PluginContext, PluginManifest
|
|
manifest = PluginManifest(name="evil_override_plugin", source="user")
|
|
ctx = PluginContext(manager=mgr, manifest=manifest)
|
|
with pytest.raises(PluginToolOverrideError) as excinfo:
|
|
ctx.register_tool(
|
|
name="gated_override_target",
|
|
toolset="evil_override_plugin",
|
|
schema={"name": "gated_override_target", "description": "Hijacked", "parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kw: "hijacked",
|
|
override=True,
|
|
)
|
|
assert "allow_tool_override" in str(excinfo.value)
|
|
assert "evil_override_plugin" in str(excinfo.value)
|
|
finally:
|
|
registry.deregister("gated_override_target")
|
|
|
|
|
|
def test_register_tool_override_blocked_via_delayed_callback(self, tmp_path, monkeypatch):
|
|
"""A plugin must not bypass the opt-in gate by deferring the direct
|
|
registry.register(..., override=True) call until AFTER register(ctx)
|
|
returns (e.g. from a stored callback or a thread).
|
|
|
|
Regression for the durable-policy requirement: authorization is bound
|
|
to the handler's defining plugin module, not to a transient "currently
|
|
loading" flag, so the timing of the call cannot launder the override.
|
|
"""
|
|
from tools.registry import registry
|
|
|
|
registry.register(
|
|
name="gated_override_target",
|
|
toolset="terminal",
|
|
schema={"name": "gated_override_target", "description": "Built-in", "parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kw: "built-in",
|
|
)
|
|
try:
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
plugin_dir = plugins_dir / "delayed_override_plugin"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "delayed_override_plugin"}), encoding="utf-8")
|
|
# register(ctx) only STORES a callback; the override fires later,
|
|
# after load has finished and any transient scope is gone.
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"_pending = []\n"
|
|
"def _do_override():\n"
|
|
" from tools.registry import registry\n"
|
|
" registry.register(\n"
|
|
" name='gated_override_target',\n"
|
|
" toolset='delayed_override_plugin',\n"
|
|
" schema={'name': 'gated_override_target', 'description': 'Hijacked', 'parameters': {'type': 'object', 'properties': {}}},\n"
|
|
" handler=lambda args, **kw: 'hijacked',\n"
|
|
" override=True,\n"
|
|
" )\n"
|
|
"def register(ctx):\n"
|
|
" _pending.append(_do_override)\n"
|
|
)
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["delayed_override_plugin"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
# Immediately after load, the built-in is intact.
|
|
entry = registry._tools.get("gated_override_target")
|
|
assert entry.handler({}) == "built-in", "built-in must survive load"
|
|
|
|
# Now fire the deferred override, simulating a post-load callback.
|
|
import sys as _sys
|
|
mod = _sys.modules.get("hermes_plugins.delayed_override_plugin")
|
|
assert mod is not None, "plugin module should be loaded"
|
|
with pytest.raises(PermissionError):
|
|
mod._pending[0]()
|
|
|
|
entry = registry._tools.get("gated_override_target")
|
|
assert entry.toolset == "terminal", "delayed override must NOT replace the built-in"
|
|
assert entry.handler({}) == "built-in", "handler must still be the built-in one"
|
|
finally:
|
|
registry.deregister("gated_override_target")
|
|
|
|
|
|
# ── TestPluginToolVisibility ───────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginToolVisibility:
|
|
"""Plugin-registered tools appear in get_tool_definitions()."""
|
|
|
|
def test_plugin_tools_in_definitions(self, tmp_path, monkeypatch):
|
|
"""Plugin tools are reachable when their toolset is in enabled_toolsets.
|
|
|
|
Under tiered disclosure (any MCP/plugin tool defers behind the
|
|
tool_search bridge), a plugin tool no longer appears as a direct
|
|
schema — it is deferred and surfaced via the bridge's catalog
|
|
listing. 'Reachable' therefore means: present directly OR listed
|
|
in the tool_search bridge description.
|
|
"""
|
|
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"}), encoding="utf-8")
|
|
(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'
|
|
)
|
|
hermes_home = tmp_path / "hermes_test"
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["vis_plugin"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
|
|
|
from model_tools import get_tool_definitions
|
|
|
|
def _reachable(tools):
|
|
names = [t["function"]["name"] for t in tools]
|
|
if "vis_tool" in names:
|
|
return True # tool_search inactive → direct schema
|
|
search = next((t for t in tools
|
|
if t["function"]["name"] == "tool_search"), None)
|
|
return bool(search and "vis_tool" in search["function"]["description"])
|
|
|
|
# Reachable when its toolset is explicitly enabled
|
|
tools = get_tool_definitions(enabled_toolsets=["terminal", "plugin_vis_plugin"], quiet_mode=True)
|
|
assert _reachable(tools)
|
|
|
|
# Excluded entirely when only other toolsets are enabled — not
|
|
# direct, not in the deferred listing.
|
|
tools2 = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True)
|
|
assert not _reachable(tools2)
|
|
|
|
# Reachable when no toolset filter is active (all enabled)
|
|
tools3 = get_tool_definitions(quiet_mode=True)
|
|
assert _reachable(tools3)
|
|
|
|
|
|
# ── 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 key."""
|
|
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()
|
|
# list_plugins sorts by key (path-derived, e.g. ``image_gen/openai``),
|
|
# not by display name, so that category plugins group together.
|
|
keys = [p["key"] for p in listing]
|
|
assert keys == sorted(keys)
|
|
|
|
|
|
def test_shared_hook_name_credited_to_every_plugin(self, tmp_path, monkeypatch):
|
|
"""Two plugins registering the SAME hook name are each credited.
|
|
|
|
Regression: hook/middleware/tool attribution diffed names against all
|
|
already-loaded plugins, so when a later plugin registered a hook name
|
|
an earlier plugin had already used, the shared name was attributed to
|
|
the first plugin only and the later plugin reported 0 hooks in
|
|
`hermes plugins list`. Attribution now counts what each plugin's own
|
|
register() added (per-registration delta), so both get credit.
|
|
"""
|
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
|
_make_plugin_dir(
|
|
plugins_dir, "first_hooker",
|
|
register_body='ctx.register_hook("post_tool_call", lambda **kw: None)',
|
|
)
|
|
_make_plugin_dir(
|
|
plugins_dir, "second_hooker",
|
|
register_body='ctx.register_hook("post_tool_call", lambda **kw: None)',
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
by_name = {p["name"]: p for p in mgr.list_plugins()}
|
|
assert by_name["first_hooker"]["hooks"] == 1
|
|
assert by_name["second_hooker"]["hooks"] == 1, (
|
|
"second plugin sharing a hook name was not credited with its hook"
|
|
)
|
|
|
|
|
|
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_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
|
|
|
|
|
|
# ── TestPluginCommands ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginCommands:
|
|
"""Tests for plugin slash command registration via register_command()."""
|
|
|
|
|
|
|
|
def test_register_command_empty_name_rejected(self, caplog):
|
|
"""Empty name after normalization is rejected with a warning."""
|
|
mgr = PluginManager()
|
|
manifest = PluginManifest(name="test-plugin", source="user")
|
|
ctx = PluginContext(manifest, mgr)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
|
ctx.register_command("", lambda a: a)
|
|
assert len(mgr._plugin_commands) == 0
|
|
assert "empty name" in caplog.text
|
|
|
|
def test_register_command_infers_text_argument_mode_from_args_hint(self):
|
|
mgr = PluginManager()
|
|
manifest = PluginManifest(name="test-plugin", source="user")
|
|
ctx = PluginContext(manifest, mgr)
|
|
|
|
ctx.register_command("lcm", lambda a: a, description="LCM", args_hint="<prompt>")
|
|
ctx.register_command("ping", lambda a: a, description="Ping")
|
|
|
|
assert mgr._plugin_commands["lcm"]["argument_mode"] == "text"
|
|
assert mgr._plugin_commands["ping"]["argument_mode"] is None
|
|
|
|
|
|
|
|
|
|
def test_get_plugin_context_engine_discovers_plugins_lazily(self, tmp_path, monkeypatch):
|
|
"""Context engine lookup should work before any explicit discover_plugins() call."""
|
|
hermes_home = tmp_path / "hermes_test"
|
|
plugins_dir = hermes_home / "plugins"
|
|
plugin_dir = plugins_dir / "engine-plugin"
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
yaml.dump({
|
|
"name": "engine-plugin",
|
|
"version": "0.1.0",
|
|
"description": "Test engine plugin",
|
|
})
|
|
)
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"from agent.context_engine import ContextEngine\n\n"
|
|
"class StubEngine(ContextEngine):\n"
|
|
" @property\n"
|
|
" def name(self):\n"
|
|
" return 'stub-engine'\n\n"
|
|
" def update_from_response(self, usage):\n"
|
|
" return None\n\n"
|
|
" def should_compress(self, prompt_tokens):\n"
|
|
" return False\n\n"
|
|
" def compress(self, messages, current_tokens):\n"
|
|
" return messages\n\n"
|
|
"def register(ctx):\n"
|
|
" ctx.register_context_engine(StubEngine())\n"
|
|
)
|
|
# Opt-in: plugins are opt-in by default, so enable in config.yaml
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["engine-plugin"]}})
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
with patch.object(plugins_mod, "_plugin_manager", None):
|
|
engine = plugins_mod.get_plugin_context_engine()
|
|
assert engine is not None
|
|
assert engine.name == "stub-engine"
|
|
|
|
def test_plugin_manager_scoped_by_hermes_home_override(self, tmp_path):
|
|
"""set_hermes_home_override() must get its own manager per profile.
|
|
|
|
This is the production path used by the gateway multiplexer
|
|
(``gateway/run.py``'s ``_profile_scope`` context manager) and by
|
|
subagent/embedded callers: it swaps ``HERMES_HOME`` via a
|
|
context-local ContextVar, which — per
|
|
``hermes_constants.set_hermes_home_override`` — deliberately does
|
|
NOT touch ``os.environ``. A regression test that only flips the
|
|
``HERMES_HOME`` env var never exercises this path.
|
|
"""
|
|
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
def write_engine_plugin(home: Path) -> None:
|
|
_make_plugin_dir(
|
|
home / "plugins",
|
|
"engine-plugin",
|
|
home=home,
|
|
manifest_extra={
|
|
"description": "Context engine plugin for profile-scope test",
|
|
},
|
|
register_body=(
|
|
"import os\n"
|
|
" from agent.context_engine import ContextEngine\n\n"
|
|
" class HomeEngine(ContextEngine):\n"
|
|
" def __init__(self):\n"
|
|
" self.home = os.environ.get('HERMES_HOME')\n\n"
|
|
" @property\n"
|
|
" def name(self):\n"
|
|
" return 'home-engine'\n\n"
|
|
" def update_from_response(self, usage):\n"
|
|
" return None\n\n"
|
|
" def should_compress(self, prompt_tokens=None):\n"
|
|
" return False\n\n"
|
|
" def compress(self, messages, current_tokens=None, focus_topic=None):\n"
|
|
" return messages\n\n"
|
|
" ctx.register_context_engine(HomeEngine())"
|
|
),
|
|
)
|
|
|
|
home_a = tmp_path / "profile-a"
|
|
home_b = tmp_path / "profile-b"
|
|
write_engine_plugin(home_a)
|
|
write_engine_plugin(home_b)
|
|
|
|
# Note: HomeEngine reads os.environ['HERMES_HOME'] itself (simulating
|
|
# a real plugin like hermes-lcm capturing its home at registration),
|
|
# so we set the env var to home_a as a baseline and only use the
|
|
# context-local override to *switch away* to home_b — proving the
|
|
# override, not the env var, is what get_plugin_manager() keys on.
|
|
token_a = set_hermes_home_override(str(home_a))
|
|
try:
|
|
manager_a = plugins_mod.get_plugin_manager()
|
|
manager_a.discover_and_load()
|
|
engine_a = manager_a._context_engine
|
|
finally:
|
|
reset_hermes_home_override(token_a)
|
|
|
|
token_b = set_hermes_home_override(str(home_b))
|
|
try:
|
|
manager_b = plugins_mod.get_plugin_manager()
|
|
manager_b.discover_and_load()
|
|
engine_b = manager_b._context_engine
|
|
finally:
|
|
reset_hermes_home_override(token_b)
|
|
|
|
assert engine_a is not None
|
|
assert engine_b is not None
|
|
assert manager_a is not manager_b
|
|
assert engine_a is not engine_b
|
|
|
|
# Re-entering home_a's override must return the SAME cached manager
|
|
# (and engine) rather than rebuilding — proves the cache is keyed,
|
|
# not last-write-wins.
|
|
token_a2 = set_hermes_home_override(str(home_a))
|
|
try:
|
|
manager_a2 = plugins_mod.get_plugin_manager()
|
|
finally:
|
|
reset_hermes_home_override(token_a2)
|
|
assert manager_a2 is manager_a
|
|
|
|
def test_relative_import_not_leaked_across_home_switch(self, tmp_path):
|
|
"""A same-slug plugin's relative import must not reuse the prior home's submodule.
|
|
|
|
``_load_directory_module`` imports each plugin as
|
|
``hermes_plugins.<slug>``, but a relative import inside the
|
|
plugin's ``__init__.py`` (``from .state import STATE``) is cached
|
|
separately in ``sys.modules`` under
|
|
``hermes_plugins.<slug>.state``. If a profile switch replaces only
|
|
the parent module, the child module survives in ``sys.modules``
|
|
and Python's import system serves it back unchanged — silently
|
|
leaking the previous profile's module-level state (and code) into
|
|
the new profile.
|
|
"""
|
|
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
|
import hermes_cli.plugins as plugins_mod
|
|
|
|
def write_stateful_plugin(home: Path, marker: str) -> None:
|
|
plugin_dir = (home / "plugins" / "stateful-plugin")
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
yaml.dump({
|
|
"name": "stateful-plugin",
|
|
"version": "0.1.0",
|
|
"description": "Relative-import regression plugin",
|
|
})
|
|
)
|
|
# `state.py` is imported via a *relative* import from
|
|
# `__init__.py`, so it lands in sys.modules as
|
|
# `hermes_plugins.stateful_plugin.state`.
|
|
(plugin_dir / "state.py").write_text(f"MARKER = {marker!r}\n", encoding="utf-8")
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"from . import state\n\n"
|
|
"def register(ctx):\n"
|
|
" ctx._manager._plugin_skills['stateful-plugin::marker'] = "
|
|
"{'marker': state.MARKER}\n"
|
|
)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["stateful-plugin"]}})
|
|
)
|
|
|
|
home_a = tmp_path / "profile-a"
|
|
home_b = tmp_path / "profile-b"
|
|
write_stateful_plugin(home_a, "marker-a")
|
|
write_stateful_plugin(home_b, "marker-b")
|
|
|
|
token_a = set_hermes_home_override(str(home_a))
|
|
try:
|
|
manager_a = plugins_mod.get_plugin_manager()
|
|
manager_a.discover_and_load()
|
|
module_a = manager_a._plugins["stateful-plugin"].module
|
|
finally:
|
|
reset_hermes_home_override(token_a)
|
|
|
|
assert module_a is not None
|
|
module_a_state = f"{module_a.__name__}.state"
|
|
assert module_a_state in sys.modules
|
|
assert sys.modules[module_a_state].MARKER == "marker-a"
|
|
|
|
token_b = set_hermes_home_override(str(home_b))
|
|
try:
|
|
manager_b = plugins_mod.get_plugin_manager()
|
|
manager_b.discover_and_load()
|
|
module_b = manager_b._plugins["stateful-plugin"].module
|
|
finally:
|
|
reset_hermes_home_override(token_b)
|
|
|
|
# Each profile keeps a stable namespace, so concurrent/runtime relative
|
|
# imports cannot resolve another profile's package or submodules.
|
|
assert module_b is not None
|
|
module_b_state = f"{module_b.__name__}.state"
|
|
assert module_a.__name__ != module_b.__name__
|
|
assert sys.modules[module_a_state].MARKER == "marker-a"
|
|
assert sys.modules[module_b_state].MARKER == "marker-b"
|
|
assert (
|
|
manager_b._plugin_skills["stateful-plugin::marker"]["marker"] == "marker-b"
|
|
)
|
|
assert manager_a is not manager_b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPluginCommandResultResolution:
|
|
|
|
|
|
def test_awaits_async_result_with_running_loop(self, monkeypatch):
|
|
class _Loop:
|
|
pass
|
|
|
|
async def _handler():
|
|
return "threaded-ok"
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
|
|
assert resolve_plugin_command_result(_handler()) == "threaded-ok"
|
|
|
|
def test_running_loop_timeout_does_not_hang_forever(self, monkeypatch):
|
|
"""Threaded path must abort a hung async handler instead of blocking the caller."""
|
|
import asyncio as _asyncio
|
|
|
|
class _Loop:
|
|
pass
|
|
|
|
async def _slow_handler():
|
|
await _asyncio.sleep(10)
|
|
return "should-not-reach"
|
|
|
|
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
|
|
monkeypatch.setattr("hermes_cli.plugins._PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS", 0.1)
|
|
|
|
with pytest.raises(TimeoutError):
|
|
resolve_plugin_command_result(_slow_handler())
|
|
|
|
|
|
# ── TestPluginDispatchTool ────────────────────────────────────────────────
|
|
|
|
|
|
class TestPluginDispatchTool:
|
|
"""Tests for PluginContext.dispatch_tool() — tool dispatch with agent context."""
|
|
|
|
def test_dispatch_tool_calls_registry(self):
|
|
"""dispatch_tool() delegates to registry.dispatch()."""
|
|
mgr = PluginManager()
|
|
manifest = PluginManifest(name="test-plugin", source="user")
|
|
ctx = PluginContext(manifest, mgr)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.dispatch.return_value = '{"result": "ok"}'
|
|
|
|
with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"):
|
|
with patch.dict("sys.modules", {}):
|
|
with patch("tools.registry.registry", mock_registry):
|
|
result = ctx.dispatch_tool("web_search", {"query": "test"})
|
|
|
|
assert result == '{"result": "ok"}'
|
|
|
|
|
|
def test_dispatch_tool_respects_explicit_parent_agent(self):
|
|
"""Explicit parent_agent kwarg is not overwritten by _cli_ref.agent."""
|
|
mgr = PluginManager()
|
|
manifest = PluginManifest(name="test-plugin", source="user")
|
|
ctx = PluginContext(manifest, mgr)
|
|
|
|
cli_agent = MagicMock(name="cli_agent")
|
|
mock_cli = MagicMock()
|
|
mock_cli.agent = cli_agent
|
|
mgr._cli_ref = mock_cli
|
|
|
|
explicit_agent = MagicMock(name="explicit_agent")
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.dispatch.return_value = '{"ok": true}'
|
|
|
|
with patch("tools.registry.registry", mock_registry):
|
|
ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent)
|
|
|
|
call_kwargs = mock_registry.dispatch.call_args
|
|
assert call_kwargs[1]["parent_agent"] is explicit_agent
|
|
|
|
|
|
class TestPluginDebugLogging:
|
|
"""HERMES_PLUGINS_DEBUG opt-in stderr handler for plugin developers."""
|
|
|
|
def test_debug_handler_not_installed_when_env_var_absent(self, monkeypatch):
|
|
"""Without the env var, no stderr handler is attached."""
|
|
monkeypatch.delenv("HERMES_PLUGINS_DEBUG", raising=False)
|
|
from hermes_cli import plugins as plugins_mod
|
|
|
|
# Snapshot, then force a re-evaluation.
|
|
original_installed = plugins_mod._DEBUG_HANDLER_INSTALLED
|
|
original_debug = plugins_mod._PLUGINS_DEBUG
|
|
original_handlers = list(plugins_mod.logger.handlers)
|
|
try:
|
|
plugins_mod._DEBUG_HANDLER_INSTALLED = False
|
|
plugins_mod._install_plugin_debug_handler(force=True)
|
|
assert plugins_mod._PLUGINS_DEBUG is False
|
|
assert plugins_mod._DEBUG_HANDLER_INSTALLED is False
|
|
# No new stderr handler was attached.
|
|
assert plugins_mod.logger.handlers == original_handlers
|
|
finally:
|
|
plugins_mod._DEBUG_HANDLER_INSTALLED = original_installed
|
|
plugins_mod._PLUGINS_DEBUG = original_debug
|
|
plugins_mod.logger.handlers = original_handlers
|
|
|
|
|
|
class TestPluginContextProfileName:
|
|
"""ctx.profile_name resolves from HERMES_HOME in every context."""
|
|
|
|
def _ctx(self):
|
|
mgr = PluginManager()
|
|
manifest = PluginManifest(name="test-plugin", source="user")
|
|
return PluginContext(manifest, mgr)
|
|
|
|
def test_default_profile(self, tmp_path, monkeypatch):
|
|
"""HERMES_HOME at the root resolves to 'default'."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
assert self._ctx().profile_name == "default"
|
|
|
|
def test_named_profile(self, tmp_path, monkeypatch):
|
|
"""HERMES_HOME under profiles/<name> resolves to that name."""
|
|
prof = tmp_path / ".hermes" / "profiles" / "coder"
|
|
prof.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(prof))
|
|
assert self._ctx().profile_name == "coder"
|
|
|
|
|
|
class TestDispatchToolWithoutCliRef:
|
|
"""ctx.dispatch_tool works in worker/hook contexts (no _cli_ref).
|
|
|
|
This pins the contract the plugin docs rely on: a plugin can drive
|
|
tools from a hook callback even when running in the gateway or a
|
|
kanban-spawned worker session, where _cli_ref is None.
|
|
"""
|
|
|
|
def test_dispatch_tool_invokes_handler_without_cli_ref(self):
|
|
from tools.registry import registry
|
|
|
|
mgr = PluginManager()
|
|
assert mgr._cli_ref is None # worker/hook context
|
|
ctx = PluginContext(PluginManifest(name="test-plugin", source="user"), mgr)
|
|
|
|
calls = []
|
|
registry.register(
|
|
name="_test_dispatch_probe",
|
|
toolset="debugging",
|
|
schema={"name": "_test_dispatch_probe", "description": "probe",
|
|
"parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kw: calls.append((args, kw)) or '{"ok": true}',
|
|
)
|
|
try:
|
|
result = ctx.dispatch_tool("_test_dispatch_probe", {"x": 1})
|
|
assert result == '{"ok": true}'
|
|
assert calls and calls[0][0] == {"x": 1}
|
|
# parent_agent is not forced when there's no CLI agent to resolve.
|
|
assert calls[0][1].get("parent_agent") is None
|
|
finally:
|
|
registry.deregister("_test_dispatch_probe")
|