mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
Rewrite all import statements, patch() targets, sys.modules keys, importlib.import_module() strings, and subprocess -m references to use hermes_agent.* paths. Strip sys.path.insert hacks from production code (rely on editable install). Update COMPONENT_PREFIXES for logger filtering. Fix 3 hardcoded getLogger() calls to use __name__. Update transport and tool registry discovery paths. Update plugin module path strings. Add legacy process-name patterns for gateway PID detection. Add main() to skills_sync for console_script entry point. Fix _get_bundled_dir() path traversal after move. Part of #14182, #14183
357 lines
13 KiB
Python
357 lines
13 KiB
Python
"""Tests for PR1 pluggable image gen: scanner recursion, kinds, path keys.
|
|
|
|
Covers ``_scan_directory`` recursion into category namespaces
|
|
(``plugins/image_gen/openai/``), ``kind`` parsing, path-derived registry
|
|
keys, and the new gate logic (bundled backends auto-load; user backends
|
|
still opt-in; exclusive kind skipped; unknown kinds → standalone warning).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from hermes_agent.cli.plugins import PluginManager, PluginManifest
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _write_plugin(
|
|
root: Path,
|
|
segments: list[str],
|
|
*,
|
|
manifest_extra: Dict[str, Any] | None = None,
|
|
register_body: str = "pass",
|
|
) -> Path:
|
|
"""Create a plugin dir at ``root/<segments...>/`` with plugin.yaml + __init__.py.
|
|
|
|
``segments`` lets tests build both flat (``["my-plugin"]``) and
|
|
category-namespaced (``["image_gen", "openai"]``) layouts.
|
|
"""
|
|
plugin_dir = root
|
|
for seg in segments:
|
|
plugin_dir = plugin_dir / seg
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
manifest = {
|
|
"name": segments[-1],
|
|
"version": "0.1.0",
|
|
"description": f"Test plugin {'/'.join(segments)}",
|
|
}
|
|
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
|
|
|
|
|
|
def _enable(hermes_home: Path, name: str) -> None:
|
|
"""Append ``name`` to ``plugins.enabled`` in ``<hermes_home>/config.yaml``."""
|
|
cfg_path = hermes_home / "config.yaml"
|
|
cfg: dict = {}
|
|
if cfg_path.exists():
|
|
try:
|
|
cfg = yaml.safe_load(cfg_path.read_text()) 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))
|
|
|
|
|
|
# ── Scanner recursion ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCategoryNamespaceRecursion:
|
|
def test_category_namespace_discovered(self, tmp_path, monkeypatch):
|
|
"""``<root>/image_gen/openai/plugin.yaml`` is discovered with key
|
|
``image_gen/openai`` when the ``image_gen`` parent has no manifest."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(user_plugins, ["image_gen", "openai"])
|
|
_enable(hermes_home, "image_gen/openai")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "image_gen/openai" in mgr._plugins
|
|
loaded = mgr._plugins["image_gen/openai"]
|
|
assert loaded.manifest.key == "image_gen/openai"
|
|
assert loaded.manifest.name == "openai"
|
|
assert loaded.enabled is True
|
|
|
|
def test_flat_plugin_key_matches_name(self, tmp_path, monkeypatch):
|
|
"""Flat plugins keep their bare name as the key (back-compat)."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(user_plugins, ["my-plugin"])
|
|
_enable(hermes_home, "my-plugin")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "my-plugin" in mgr._plugins
|
|
assert mgr._plugins["my-plugin"].manifest.key == "my-plugin"
|
|
|
|
def test_depth_cap_two(self, tmp_path, monkeypatch):
|
|
"""Plugins nested three levels deep are not discovered.
|
|
|
|
``<root>/a/b/c/plugin.yaml`` should NOT be picked up — cap is
|
|
two segments.
|
|
"""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(user_plugins, ["a", "b", "c"])
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
non_bundled = [
|
|
k for k, p in mgr._plugins.items()
|
|
if p.manifest.source != "bundled"
|
|
]
|
|
assert non_bundled == []
|
|
|
|
def test_category_dir_with_manifest_is_leaf(self, tmp_path, monkeypatch):
|
|
"""If ``image_gen/plugin.yaml`` exists, ``image_gen`` itself IS the
|
|
plugin and its children are ignored."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
# parent has a manifest → stop recursing
|
|
_write_plugin(user_plugins, ["image_gen"])
|
|
# child also has a manifest — should NOT be found because we stop
|
|
# at the parent.
|
|
_write_plugin(user_plugins, ["image_gen", "openai"])
|
|
_enable(hermes_home, "image_gen")
|
|
_enable(hermes_home, "image_gen/openai")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
# The bundled plugins/image_gen/openai/ exists in the repo — filter
|
|
# it out so we're only asserting on the user-dir layout.
|
|
user_plugins_in_registry = {
|
|
k for k, p in mgr._plugins.items() if p.manifest.source != "bundled"
|
|
}
|
|
assert "image_gen" in user_plugins_in_registry
|
|
assert "image_gen/openai" not in user_plugins_in_registry
|
|
|
|
|
|
# ── Kind parsing ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestKindField:
|
|
def test_default_kind_is_standalone(self, tmp_path, monkeypatch):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(hermes_home / "plugins", ["p1"])
|
|
_enable(hermes_home, "p1")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["p1"].manifest.kind == "standalone"
|
|
|
|
@pytest.mark.parametrize("kind", ["backend", "exclusive", "standalone"])
|
|
def test_valid_kinds_parsed(self, kind, tmp_path, monkeypatch):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["p1"],
|
|
manifest_extra={"kind": kind},
|
|
)
|
|
# Not all kinds auto-load, but manifest should parse.
|
|
_enable(hermes_home, "p1")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "p1" in mgr._plugins
|
|
assert mgr._plugins["p1"].manifest.kind == kind
|
|
|
|
def test_unknown_kind_falls_back_to_standalone(self, tmp_path, monkeypatch, caplog):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["p1"],
|
|
manifest_extra={"kind": "bogus"},
|
|
)
|
|
_enable(hermes_home, "p1")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["p1"].manifest.kind == "standalone"
|
|
assert any(
|
|
"unknown kind" in rec.getMessage() for rec in caplog.records
|
|
)
|
|
|
|
|
|
# ── Gate logic ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBackendGate:
|
|
def test_user_backend_still_gated_by_enabled(self, tmp_path, monkeypatch):
|
|
"""User-installed ``kind: backend`` plugins still require opt-in —
|
|
they're not trusted by default."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(
|
|
user_plugins,
|
|
["image_gen", "fancy"],
|
|
manifest_extra={"kind": "backend"},
|
|
)
|
|
# Do NOT opt in.
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
loaded = mgr._plugins["image_gen/fancy"]
|
|
assert loaded.enabled is False
|
|
assert "not enabled" in (loaded.error or "")
|
|
|
|
def test_user_backend_loads_when_enabled(self, tmp_path, monkeypatch):
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
user_plugins = hermes_home / "plugins"
|
|
|
|
_write_plugin(
|
|
user_plugins,
|
|
["image_gen", "fancy"],
|
|
manifest_extra={"kind": "backend"},
|
|
)
|
|
_enable(hermes_home, "image_gen/fancy")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["image_gen/fancy"].enabled is True
|
|
|
|
def test_exclusive_kind_skipped(self, tmp_path, monkeypatch):
|
|
"""``kind: exclusive`` plugins are recorded but not loaded — the
|
|
category's own discovery system handles them (memory today)."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["some-backend"],
|
|
manifest_extra={"kind": "exclusive"},
|
|
)
|
|
_enable(hermes_home, "some-backend")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
loaded = mgr._plugins["some-backend"]
|
|
assert loaded.enabled is False
|
|
assert "exclusive" in (loaded.error or "")
|
|
|
|
|
|
# ── Bundled backend auto-load (integration with real bundled plugin) ────────
|
|
|
|
|
|
class TestBundledBackendAutoLoad:
|
|
def test_bundled_image_gen_openai_autoloads(self, tmp_path, monkeypatch):
|
|
"""The bundled ``plugins/image_gen/openai/`` plugin loads without
|
|
any opt-in — it's ``kind: backend`` and shipped in-repo."""
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert "image_gen/openai" in mgr._plugins
|
|
loaded = mgr._plugins["image_gen/openai"]
|
|
assert loaded.manifest.source == "bundled"
|
|
assert loaded.manifest.kind == "backend"
|
|
assert loaded.enabled is True, f"error: {loaded.error}"
|
|
|
|
|
|
# ── PluginContext.register_image_gen_provider ───────────────────────────────
|
|
|
|
|
|
class TestRegisterImageGenProvider:
|
|
def test_accepts_valid_provider(self, tmp_path, monkeypatch):
|
|
from hermes_agent.agent import image_gen_registry
|
|
from hermes_agent.agent.image_gen.provider import ImageGenProvider
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
class FakeProvider(ImageGenProvider):
|
|
@property
|
|
def name(self) -> str:
|
|
return "fake-test"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kw):
|
|
return {"success": True, "image": "test://fake"}
|
|
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
plugin_dir = _write_plugin(
|
|
hermes_home / "plugins",
|
|
["my-img-plugin"],
|
|
register_body=(
|
|
"from agent.image_gen_provider import ImageGenProvider\n"
|
|
" class P(ImageGenProvider):\n"
|
|
" @property\n"
|
|
" def name(self): return 'fake-ctx'\n"
|
|
" def generate(self, prompt, aspect_ratio='landscape', **kw):\n"
|
|
" return {'success': True, 'image': 'x://y'}\n"
|
|
" ctx.register_image_gen_provider(P())"
|
|
),
|
|
)
|
|
_enable(hermes_home, "my-img-plugin")
|
|
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
assert mgr._plugins["my-img-plugin"].enabled is True
|
|
assert image_gen_registry.get_provider("fake-ctx") is not None
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
def test_rejects_non_provider(self, tmp_path, monkeypatch, caplog):
|
|
from hermes_agent.agent import image_gen_registry
|
|
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
import os
|
|
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
|
|
_write_plugin(
|
|
hermes_home / "plugins",
|
|
["bad-img-plugin"],
|
|
register_body="ctx.register_image_gen_provider('not a provider')",
|
|
)
|
|
_enable(hermes_home, "bad-img-plugin")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
mgr = PluginManager()
|
|
mgr.discover_and_load()
|
|
|
|
# Plugin loaded (register returned normally) but nothing was
|
|
# registered in the provider registry.
|
|
assert mgr._plugins["bad-img-plugin"].enabled is True
|
|
assert image_gen_registry.get_provider("not a provider") is None
|
|
|
|
image_gen_registry._reset_for_tests()
|