fix(memory): register parent packages for user-installed provider imports
User-installed memory providers load under the synthetic
_hermes_user_memory.<name> 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 <plugin>" 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 <noreply@anthropic.com>
This commit is contained in:
parent
4ae3c988b5
commit
de60bf40c6
2 changed files with 189 additions and 5 deletions
|
|
@ -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.<name>`` 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.<name>.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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue