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 ────────────────────────────────────────────────────────