fix(plugins): classify pip entry-point provider plugins without importing

Entry-point (pip-installed) plugins exposing register_memory_provider()
or register_provider() + ProviderProfile were treated as plain
standalone plugins and eagerly imported by the general PluginManager,
even though memory and model providers have their own discovery
systems and the module has no register() for the general manager to
call. The import registered nothing and paid the module's full import
cost in every Hermes process (a pip memory provider pulls fastembed ->
onnxruntime, ~60 MB RSS).

Entry-point manifests now get the same source-scan classification as
directory plugins via a shared _detect_kind_from_source() helper: the
module is resolved with importlib.util.find_spec (no import) and its
first 8192 chars are scanned for provider markers. Memory providers ->
kind=exclusive, model providers -> kind=model-provider; both are
recorded for introspection and skipped by the general loader.
Unresolvable or non-Python modules stay standalone (default behavior
unchanged).

Tests: an enabled pip entry-point memory provider is never imported;
a pip entry-point model provider routes to providers/ discovery.
This commit is contained in:
Mike Smith 2026-08-02 00:04:09 -04:00 committed by Teknium
parent 364adc89af
commit 826e9d18af
2 changed files with 170 additions and 22 deletions

View File

@ -821,6 +821,25 @@ def resolve_plugin_load_order(
return ordered
def _detect_kind_from_source(source_text: str) -> Optional[str]:
"""Return the plugin kind implied by source markers, or ``None``.
Mirrors ``plugins/memory/__init__.py:_is_memory_provider_dir``: a
module that registers a memory provider (``register_memory_provider``
or ``MemoryProvider``) belongs to the memory-provider discovery
system (``exclusive``); a module that registers a model provider
(``register_provider`` + ``ProviderProfile``) belongs to the
providers discovery (``model-provider``). Applied to both directory
plugins and pip entry-point plugins so neither is eagerly imported
by the general PluginManager.
"""
if "register_memory_provider" in source_text or "MemoryProvider" in source_text:
return "exclusive"
if "register_provider" in source_text and "ProviderProfile" in source_text:
return "model-provider"
return None
@dataclass
class PluginManifest:
"""Parsed representation of a plugin.yaml manifest."""
@ -4060,29 +4079,16 @@ class PluginManager:
init_file = plugin_dir / "__init__.py"
if init_file.exists():
try:
source_text = init_file.read_text(errors="replace", encoding="utf-8")[:8192]
if (
"register_memory_provider" in source_text
or "MemoryProvider" in source_text
):
kind = "exclusive"
detected = _detect_kind_from_source(
init_file.read_text(
errors="replace", encoding="utf-8"
)[:8192]
)
if detected:
kind = detected
logger.debug(
"Plugin %s: detected memory provider, "
"treating as kind='exclusive'",
key,
)
elif (
"register_provider" in source_text
and "ProviderProfile" in source_text
):
# Model provider plugin (calls register_provider()
# from ``providers`` with a ProviderProfile). Route
# to providers/__init__.py discovery.
kind = "model-provider"
logger.debug(
"Plugin %s: detected model provider, "
"treating as kind='model-provider'",
key,
"Plugin %s: detected %s, treating as kind='%s'",
key, detected, detected,
)
except Exception:
pass
@ -4121,6 +4127,37 @@ class PluginManager:
# Entry-point scanning
# -----------------------------------------------------------------------
def _classify_entrypoint_kind(self, ep) -> str:
"""Classify a pip entry-point plugin by scanning its module source.
The ``kind`` semantics are the same for pip entry points as for
directory plugins: memory providers (``exclusive``) and model
providers (``model-provider``) have their own discovery systems,
so importing them here registers nothing and only pays the
module's import cost in every Hermes process (e.g. a pip
memory-provider plugin pulling in onnxruntime via fastembed
~60 MB RSS on startup).
The module is resolved with ``importlib.util.find_spec`` no
import for top-level module names (for dotted names only the
parent package may be imported). Only the first 8192 chars of
source are scanned, mirroring the directory-plugin heuristic.
Unresolvable or non-Python modules stay ``standalone``.
"""
try:
module_name = ep.value.split(":", 1)[0].strip()
if not module_name:
return "standalone"
spec = importlib.util.find_spec(module_name)
if spec is None or not spec.origin or not spec.origin.endswith(".py"):
return "standalone"
source_text = Path(spec.origin).read_text(
errors="replace", encoding="utf-8"
)[:8192]
return _detect_kind_from_source(source_text) or "standalone"
except Exception:
return "standalone"
def _scan_entry_points(self) -> List[PluginManifest]:
"""Check ``importlib.metadata`` for pip-installed plugins."""
manifests: List[PluginManifest] = []
@ -4141,6 +4178,7 @@ class PluginManager:
path=ep.value,
key=ep.name,
)
manifest.kind = self._classify_entrypoint_kind(ep)
manifests.append(manifest)
except Exception as exc:
logger.debug("Entry-point scan failed: %s", exc)

View File

@ -467,6 +467,116 @@ class TestPluginLoading:
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
# ── TestPluginHooks ────────────────────────────────────────────────────────