From de60bf40c6ee4c889a9db35ea0d5d20fab962b35 Mon Sep 17 00:00:00 2001 From: Sol Aitken Date: Tue, 2 Jun 2026 11:25:06 +0000 Subject: [PATCH] fix(memory): register parent packages for user-installed provider imports User-installed memory providers load under the synthetic _hermes_user_memory. package, but the loader never registered that parent namespace in sys.modules (it only registers "plugins" and "plugins.memory" for bundled providers). As a result any external provider using a relative import failed to load: from . import config ModuleNotFoundError: No module named '_hermes_user_memory' The same gap in discover_plugin_cli_commands() meant an external provider's cli.py with a relative import could never be discovered, so the documented "hermes " CLI integration did not work for standalone plugins. Register the synthetic parent namespace before loading user-installed providers, mirror it for cli.py discovery (including the per-provider parent package, without executing the plugin's __init__.py), and make _load_provider_from_dir() reuse only modules actually loaded from disk so a parent shell registered by CLI discovery is never mistaken for the loaded provider. Regressions cover: a flat provider with a sibling relative import, a provider with its implementation in a nested subpackage (including a namespace intermediate directory), cli.py discovery with a relative import, and provider load after CLI discovery ran first. Co-Authored-By: Claude Opus 4.8 --- plugins/memory/__init__.py | 53 ++++++++++- tests/agent/test_memory_provider.py | 141 ++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index 2398f2ebd..3f92b5cbb 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -22,6 +22,7 @@ Usage: from __future__ import annotations import importlib +import importlib.machinery import importlib.util import logging import sys @@ -33,6 +34,28 @@ logger = logging.getLogger(__name__) _MEMORY_PLUGINS_DIR = Path(__file__).parent +# Synthetic parent package for user-installed providers, so they don't +# collide with bundled providers in sys.modules. +_USER_NAMESPACE = "_hermes_user_memory" + + +def _register_synthetic_package(name: str, search_locations: List[str]) -> None: + """Register an empty package shell in sys.modules. + + User-installed providers import as ``_hermes_user_memory.``, a + dotted name whose parents exist nowhere on disk. Unless those parents + are present in ``sys.modules``, any relative import inside the plugin + (``from . import config``) fails with + ``ModuleNotFoundError: No module named '_hermes_user_memory'`` — the + same reason the loader already registers ``plugins`` and + ``plugins.memory`` for bundled providers. + """ + if name in sys.modules: + return + spec = importlib.machinery.ModuleSpec(name, None, is_package=True) + spec.submodule_search_locations = search_locations + sys.modules[name] = importlib.util.module_from_spec(spec) + # --------------------------------------------------------------------------- # Directory helpers @@ -193,15 +216,18 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["MemoryProvider"]: # Use a separate namespace for user-installed plugins so they don't # collide with bundled providers in sys.modules. _is_bundled = _MEMORY_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _MEMORY_PLUGINS_DIR - module_name = f"plugins.memory.{name}" if _is_bundled else f"_hermes_user_memory.{name}" + module_name = f"plugins.memory.{name}" if _is_bundled else f"{_USER_NAMESPACE}.{name}" init_file = provider_dir / "__init__.py" if not init_file.exists(): return None - # Check if already loaded - if module_name in sys.modules: - mod = sys.modules[module_name] + # Check if already loaded. A synthetic package shell registered by + # discover_plugin_cli_commands() for relative-import support has no + # __file__; only reuse modules that were actually loaded from disk. + cached = sys.modules.get(module_name) + if cached is not None and getattr(cached, "__file__", None): + mod = cached else: # Handle relative imports within the plugin # First ensure the parent packages are registered @@ -224,6 +250,11 @@ def _load_provider_from_dir(provider_dir: Path) -> Optional["MemoryProvider"]: except Exception: pass + # User-installed plugins need their synthetic parent registered the + # same way, or relative imports inside the plugin cannot resolve. + if not _is_bundled: + _register_synthetic_package(_USER_NAMESPACE, []) + # Now load the provider module spec = importlib.util.spec_from_file_location( module_name, str(init_file), @@ -355,12 +386,24 @@ def discover_plugin_cli_commands() -> List[dict]: return results _is_bundled = _MEMORY_PLUGINS_DIR in plugin_dir.parents or plugin_dir.parent == _MEMORY_PLUGINS_DIR - module_name = f"plugins.memory.{active_provider}.cli" if _is_bundled else f"_hermes_user_memory.{active_provider}.cli" + module_name = f"plugins.memory.{active_provider}.cli" if _is_bundled else f"{_USER_NAMESPACE}.{active_provider}.cli" try: # Import the CLI module (lightweight — no SDK needed) if module_name in sys.modules: cli_mod = sys.modules[module_name] else: + if not _is_bundled: + # cli.py imports as _hermes_user_memory..cli, usually + # before the provider itself is loaded. Register its parent + # packages so relative imports inside cli.py + # ("from . import config") resolve without executing the + # plugin's __init__.py. The package shell has no __file__, + # so _load_provider_from_dir() will still load the real + # module later instead of reusing the shell. + _register_synthetic_package(_USER_NAMESPACE, []) + _register_synthetic_package( + f"{_USER_NAMESPACE}.{active_provider}", [str(plugin_dir)] + ) spec = importlib.util.spec_from_file_location( module_name, str(cli_file) ) diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index c516e408f..bb84c4253 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -533,6 +533,147 @@ class TestUserInstalledProviderDiscovery: names = [n for n, _, _ in providers] assert "notmemory" not in names + def test_load_user_plugin_with_relative_import(self, tmp_path, monkeypatch): + """User plugins may import sibling modules with relative imports. + + Regression: _load_provider_from_dir() imports user plugins under the + synthetic ``_hermes_user_memory.`` package but never registered + that parent namespace in sys.modules, so any relative import inside + the plugin raised + ``ModuleNotFoundError: No module named '_hermes_user_memory'``. + """ + from plugins.memory import load_memory_provider + plugin_dir = tmp_path / "plugins" / "relimport" + plugin_dir.mkdir(parents=True) + (plugin_dir / "helper.py").write_text("PROVIDER_NAME = 'relimport'\n") + (plugin_dir / "__init__.py").write_text( + "from agent.memory_provider import MemoryProvider\n" + "from . import helper\n" + "class MyProvider(MemoryProvider):\n" + " @property\n" + " def name(self): return helper.PROVIDER_NAME\n" + " def is_available(self): return True\n" + " def initialize(self, **kw): pass\n" + " def sync_turn(self, *a, **kw): pass\n" + " def get_tool_schemas(self): return []\n" + " def handle_tool_call(self, *a, **kw): return '{}'\n" + ) + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + p = load_memory_provider("relimport") + assert p is not None + assert p.name == "relimport" + + def test_load_user_plugin_with_nested_subpackage(self, tmp_path, monkeypatch): + """User plugins may keep their implementation in a nested subpackage. + + Plugin repos that target several runtimes commonly expose a thin root + ``__init__.py`` re-exporting from a deeper package, and the + intermediate directory may be a namespace package (no __init__.py). + Both must resolve through the synthetic parent namespace. + """ + from plugins.memory import load_memory_provider + plugin_dir = tmp_path / "plugins" / "nestedimpl" + impl_dir = plugin_dir / "adapters" / "hermes" # adapters/ has no __init__.py + impl_dir.mkdir(parents=True) + (impl_dir / "__init__.py").write_text( + "from agent.memory_provider import MemoryProvider\n" + "class MyProvider(MemoryProvider):\n" + " @property\n" + " def name(self): return 'nestedimpl'\n" + " def is_available(self): return True\n" + " def initialize(self, **kw): pass\n" + " def sync_turn(self, *a, **kw): pass\n" + " def get_tool_schemas(self): return []\n" + " def handle_tool_call(self, *a, **kw): return '{}'\n" + ) + (plugin_dir / "__init__.py").write_text( + "from .adapters.hermes import MyProvider\n" + "def register(ctx):\n" + " ctx.register_memory_provider(MyProvider())\n" + ) + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + p = load_memory_provider("nestedimpl") + assert p is not None + assert p.name == "nestedimpl" + + +class TestUserInstalledProviderCli: + """CLI commands of user-installed providers must be discoverable. + + Mirror of the relative-import regression above: + discover_plugin_cli_commands() imports the active provider's cli.py as + ``_hermes_user_memory..cli`` without registering the parent + packages, so a cli.py with a relative import could never load. + """ + + def _make_plugin_with_cli(self, tmp_path, name): + plugin_dir = tmp_path / "plugins" / name + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text( + "from agent.memory_provider import MemoryProvider\n" + "from . import config\n" + "class MyProvider(MemoryProvider):\n" + " @property\n" + f" def name(self): return {name!r}\n" + " def is_available(self): return True\n" + " def initialize(self, **kw): pass\n" + " def sync_turn(self, *a, **kw): pass\n" + " def get_tool_schemas(self): return []\n" + " def handle_tool_call(self, *a, **kw): return '{}'\n" + "def register(ctx):\n" + " ctx.register_memory_provider(MyProvider())\n" + ) + (plugin_dir / "config.py").write_text("STATUS = 'ok'\n") + (plugin_dir / "cli.py").write_text( + "from . import config\n" + "def register_cli(subparser):\n" + " subparser.add_argument('--status', action='store_true')\n" + ) + return plugin_dir + + def _activate(self, tmp_path, monkeypatch, name): + monkeypatch.setattr( + "plugins.memory._get_user_plugins_dir", + lambda: tmp_path / "plugins", + ) + monkeypatch.setattr( + "plugins.memory._get_active_memory_provider", + lambda: name, + ) + + def test_cli_discovered_for_user_plugin_with_relative_import( + self, tmp_path, monkeypatch + ): + """discover_plugin_cli_commands() loads a user provider's cli.py.""" + from plugins.memory import discover_plugin_cli_commands + self._make_plugin_with_cli(tmp_path, "extcli") + self._activate(tmp_path, monkeypatch, "extcli") + commands = discover_plugin_cli_commands() + assert len(commands) == 1 + assert commands[0]["name"] == "extcli" + assert callable(commands[0]["setup_fn"]) + + def test_provider_load_after_cli_discovery(self, tmp_path, monkeypatch): + """The provider still loads after CLI discovery ran first. + + CLI discovery registers a synthetic parent package shell for the + relative imports in cli.py; _load_provider_from_dir() must load the + real plugin module instead of reusing that shell. + """ + from plugins.memory import discover_plugin_cli_commands, load_memory_provider + self._make_plugin_with_cli(tmp_path, "extcliload") + self._activate(tmp_path, monkeypatch, "extcliload") + assert len(discover_plugin_cli_commands()) == 1 + p = load_memory_provider("extcliload") + assert p is not None + assert p.name == "extcliload" + # --------------------------------------------------------------------------- # Sequential dispatch routing tests