From 826e9d18af3f924c8f41cc7b8ac954a925f62e15 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Sun, 2 Aug 2026 00:04:09 -0400 Subject: [PATCH] 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. --- hermes_cli/plugins.py | 82 ++++++++++++++++------- tests/hermes_cli/test_plugins.py | 110 +++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 00f0f25e7b125..7b65b0c2b283a 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index f79a357cccee1..a35b2c029ba83 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 ────────────────────────────────────────────────────────