mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Fix variable name breakage (run_agent, hermes_constants, etc.) where import rewriter changed 'import X' to 'import hermes_agent.Y' but test code still referenced 'X' as a variable name. Fix package-vs-module confusion (cli.auth, cli.models, cli.ui) where single files became directories. Fix hardcoded file paths in tests pointing to old locations. Fix tool registry to discover tools in subpackage directories. Fix stale import in hermes_agent/tools/__init__.py. 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.image_gen import registry as 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.image_gen import registry as 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()
|