fix(plugins): classify wrapper memory providers as exclusive

This commit is contained in:
Allan Ditzel 2026-04-22 01:53:50 -04:00
parent b5d89fd4da
commit 56ed019a1a
2 changed files with 93 additions and 10 deletions

View file

@ -136,6 +136,46 @@ def _get_enabled_plugins() -> Optional[set]:
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
def _looks_like_exclusive_plugin(plugin_dir: Path) -> bool:
"""Heuristic: does *plugin_dir* implement an exclusive provider contract?
Today this is primarily used to keep memory provider plugins discovered under
``$HERMES_HOME/plugins`` out of the general plugin loader. Those plugins are
selected via ``memory.provider`` and register via
``ctx.register_memory_provider(...)`` rather than generic tool/hook APIs.
We scan a small set of Python files under the plugin root so wrapper-style
packages that re-export their real implementation from ``src/`` still get
classified correctly.
"""
py_files = []
init_file = plugin_dir / "__init__.py"
if init_file.exists():
py_files.append(init_file)
try:
for path in sorted(plugin_dir.rglob("*.py")):
if path == init_file:
continue
py_files.append(path)
if len(py_files) >= 12:
break
except Exception:
pass
scanned = 0
for path in py_files:
try:
source = path.read_text(errors="replace")
except Exception:
continue
scanned += len(source)
if "register_memory_provider" in source or "MemoryProvider" in source:
return True
if scanned >= 32768:
break
return False
@dataclass
class PluginManifest:
"""Parsed representation of a plugin.yaml manifest."""
@ -723,16 +763,24 @@ class PluginManager:
name = data.get("name", plugin_dir.name)
key = f"{prefix}/{plugin_dir.name}" if prefix else name
raw_kind = data.get("kind", "standalone")
if not isinstance(raw_kind, str):
raw_kind = "standalone"
kind = raw_kind.strip().lower()
if kind not in _VALID_PLUGIN_KINDS:
logger.warning(
"Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'",
key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)),
)
kind = "standalone"
raw_kind = data.get("kind")
if raw_kind is None:
kind = "exclusive" if _looks_like_exclusive_plugin(plugin_dir) else "standalone"
if kind == "exclusive":
logger.debug(
"Auto-classifying plugin %s as exclusive based on provider-style register() contract",
key,
)
else:
if not isinstance(raw_kind, str):
raw_kind = "standalone"
kind = raw_kind.strip().lower()
if kind not in _VALID_PLUGIN_KINDS:
logger.warning(
"Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'",
key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)),
)
kind = "standalone"
return PluginManifest(
name=name,

View file

@ -100,6 +100,41 @@ class TestPluginDiscovery:
assert "hello_plugin" in mgr._plugins
assert mgr._plugins["hello_plugin"].enabled
def test_user_memory_provider_plugins_are_classified_exclusive(self, tmp_path, monkeypatch):
"""Wrapper-style user memory providers should bypass the general loader."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "memory_plugin"
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "plugin.yaml").write_text(
yaml.safe_dump({
"name": "memory_plugin",
"version": "0.1.0",
"description": "Wrapper-style memory provider plugin",
})
)
(plugin_dir / "__init__.py").write_text(
'from .src.memory_plugin_impl import register\n'
)
impl_dir = plugin_dir / "src" / "memory_plugin_impl"
impl_dir.mkdir(parents=True, exist_ok=True)
(impl_dir / "__init__.py").write_text(
'def register(ctx):\n ctx.register_memory_provider(object())\n'
)
hermes_home = tmp_path / "hermes_test"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["memory_plugin"]}})
)
mgr = PluginManager()
mgr.discover_and_load()
assert "memory_plugin" in mgr._plugins
loaded = mgr._plugins["memory_plugin"]
assert loaded.manifest.kind == "exclusive"
assert not loaded.enabled
assert "exclusive plugin" in (loaded.error or "")
def test_discover_project_plugins(self, tmp_path, monkeypatch):
"""Plugins in ./.hermes/plugins/ are discovered."""
project_dir = tmp_path / "project"