fix(memory): entry-point + project-dir discovery parity for out-of-tree providers (salvage of #18842, #40644, #76567) - #80493
Conversation
…ting 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.
find_spec() on a dotted module name imports the parent package first, executing its __init__.py — which is exactly where a provider's heavy imports typically live (fastembed -> onnxruntime and friends). The previous classifier only preserved the no-import property for top-level entry points. _resolve_module_source() now resolves only the top-level name with find_spec() (import-free for top-level names) and walks the remaining dotted segments through submodule_search_locations by hand, mirroring PathFinder's file conventions (part.py module / part/__init__.py package). Namespace packages, zipped modules, extension modules, and anything else unexpected fall back to standalone (the safe default). .pyc origins map back to source via source_from_cache. Regression: a dotted entry point whose parent __init__.py writes an execution marker and imports the child — asserts the parent never executed and neither module enters sys.modules during classification. Fails against the previous implementation (marker written), passes now.
…ation Documents and tests the routing contract the sweeper review asked about: classification records the manifest but does not activate anything. - model-provider test now exercises providers.get_provider_profile() against the pip-only name (None today — providers discovery is directory-based) and asserts the module never leaks into sys.modules via that path. - new test for the mnemosyne shape: a pip entry point duplicating a same-name directory provider. The pip copy is classified exclusive and never imported; the directory copy still activates through plugins.memory discovery, exactly once. - _classify_entrypoint_kind docstring now states the activation contract explicitly: pip-only providers were equally unactivatable pre-change (both destination systems are directory-only; the hermes_agent.memory_providers entry-point group has no consumers), so classification only removes the wasted import. Entry-point activation is tracked upstream (NousResearch#40644 for memory); this change is its prerequisite, preventing double import once it lands.
…ee providers Builds on the three salvaged commits: adds the sources and integration points they leave out, so a pip-installed memory provider is not a second-class citizen next to a directory install. Discovery - Project-local providers (./.hermes/plugins/<name>/), gated on HERMES_ENABLE_PROJECT_PLUGINS exactly as PluginManager gates its own project scan. Completes the four sources CONTRIBUTING.md and AGENTS.md already promised; memory was the only discovery system missing two of them. - find_provider_dir() now resolves a package entry point to its directory. This is load-bearing: config_schema.py (the dashboard panel) and cli.py (the `hermes <provider>` subcommands) are read from disk rather than imported, so without a directory a pip-installed provider silently lost both. - list_memory_provider_names() includes entry-point providers, so they appear in the dashboard's memory.provider dropdown. Resolution stays import-free. hermes_cli.plugins.resolve_module_origin() is extracted from _resolve_module_source() (added by the salvaged NousResearch#76567) and shared, so discovery walks a module's file layout instead of importing it. find_provider_dir() is called from the dashboard and from argparse setup, long before the operator has chosen a provider — importing every installed candidate would execute third-party code on the strength of a package being present. A test asserts the resolution leaves no side effects and no sys.modules entry. Registration - PluginContext gains register_memory_provider(). Memory was the only provider category without one; context engine, image gen, video gen, web search, browser, TTS, transcription, secret source, dashboard auth and platform all have one. - _ProviderCollector delegates unknown register_* calls to a real PluginContext instead of carrying three hand-written no-ops. It silently dropped register_tool/register_hook, and had no register_auxiliary_task at all — despite PluginContext.register_auxiliary_task documenting a memory provider (hindsight's pre-retain dedup) as its worked example. It can no longer drift behind PluginContext. - A raise after register_memory_provider() no longer costs the provider. The loader caught it into a debug log, discarded the registered instance, and fell through to "instantiate any MemoryProvider subclass" — returning a different, unconfigured provider. A silent downgrade that looked like success, and the exact outcome of calling register_auxiliary_task. Activation is unchanged: still gated on memory.provider naming the plugin, and covered by a test so the real PluginContext cannot start requiring plugins.enabled — that would break every existing user-installed provider. Verified end to end against a real third-party provider (kainappsinc/elephant) installed by pip alone, with no directory copy: it appears in the dropdown, resolves its directory, loads with its tools, and renders its dashboard panel. Closes NousResearch#40101.
720d7c3 to
7af6e84
Compare
|
Rebased onto current Full suites re-run on the rebased branch: 574 passed, 1 skipped across One triage note: this is labelled P3 ("cosmetic, nice to have"), but it's a |
Sibling test pinned the old zero-arg constructor; salvaged #80493 gave _ProviderCollector a required provider name (used for skill registration and PluginContext delegation).
Sibling test pinned the old zero-arg constructor; salvaged #80493 gave _ProviderCollector a required provider name (used for skill registration and PluginContext delegation).
|
Merged via PR #85527 — all five commits cherry-picked onto current main with authorship preserved in git history (your parity commit, @m1k3s0's two classification commits + activation-contract tests, and @smarzola's packaged-skills commit). One follow-up on top from us: a sibling test ( Thanks for doing this as a proper salvage rather than a fourth competing implementation — the authorship table in your PR body made the credit trail trivial to preserve. |
Closes #40101.
Problem
plugins/memory/is closed to new providers, so every new memory backend now ships out of tree. But the out-of-tree path is measurably weaker than the in-tree one, and the docs already promise otherwise.plugins/memory/__init__.py:_iter_provider_dirs()walks directories only — bundled and$HERMES_HOME/plugins/. The generalPluginManager(hermes_cli/plugins.py:1371-1391) scans four sources including pip entry points.CONTRIBUTING.md:78andAGENTS.mdboth state memory discovery "picks them up from user/project plugin directories and pip entry points". It does not.The user-visible symptom is #40101: a correctly-registered pip-installed provider reports
Plugin: NOT installed. The workaround in the wild is a bespoke second install step — note that Memori, documented inmemory-providers.md, shipspip install hermes-memoriplushermes-memori installfor exactly this reason.This is a salvage, not a fourth attempt
Three PRs already address parts of this and none have merged. Rather than add another competing implementation, this rebases them onto current
mainwith authorship preserved and adds what none of them cover — the same approach as #79239.hermes_agent.memory_providers,ctx.register_skill()forwarding, tests, developer-guide docsimportlib.metadataand the agent runtime) and reverted by the author. Its Mnemosyne docs belong in a docs PR.#18842 and #76567 are complementary rather than competing: one adds memory entry-point discovery, the other stops the general PluginManager from eagerly importing such packages in every hermes process.
What this adds on top
Project-local providers (
./.hermes/plugins/<name>/), gated onHERMES_ENABLE_PROJECT_PLUGINSexactly asPluginManagergates its own project scan. Completes the four sources.find_provider_dir()resolves package entry points. This is load-bearing rather than cosmetic:config_schema.py(the dashboard config panel) andcli.py(thehermes <provider>subcommands) are read from disk, not imported —plugins/memory/config_schema.py:14-17does this deliberately so the web server never pulls in the agent runtime. Without a directory, a pip-installed provider silently loses both.list_memory_provider_names()includes entry-point providers, so they appear in the dashboard'smemory.providerdropdown.Resolution stays import-free.
resolve_module_origin()is extracted from #76567's_resolve_module_source()and shared, so discovery walks a module's file layout instead of importing it.find_provider_dir()is called from the dashboard and from argparse setup — long before the operator has chosen a provider — so importing every installed candidate would execute third-party code on the strength of a package merely being present. A test asserts resolution leaves no side effect and nosys.modulesentry.PluginContext.register_memory_provider(). Memory was the only provider category without one — context engine, image gen, video gen, web search, browser, TTS, transcription, secret source, dashboard auth and platform all have one._ProviderCollectordelegates unknownregister_*calls to a realPluginContextinstead of carrying three hand-written no-ops. It silently droppedregister_tool/register_hook, and had noregister_auxiliary_taskat all — despitePluginContext.register_auxiliary_task's own docstring using a memory provider (hindsight's pre-retain dedup) as its worked example. It can no longer drift behindPluginContext.A raise after
register_memory_provider()no longer costs the provider. The loader caught it into alogger.debug, discarded the registered instance, and fell through to "instantiate anyMemoryProvidersubclass" — returning a different, unconfigured provider. A silent downgrade that looked like success, and the exact outcome of callingregister_auxiliary_task.Precedence
bundled > user > project > entrypoint — deliberately the reverse of the general PluginManager's later-wins order, and documented in the module docstring. A memory provider is activated by name, so letting a directory dropped into a working tree shadow a shipped provider would silently redirect the agent's memory. Existing behaviour is unchanged; the new sources sit below it.
Safety
Activation is still gated on
memory.providernaming the plugin. Discovery enumerates; it does not load. Using a realPluginContextmust not start also requiring the plugin inplugins.enabled— that would break every existing user-installed provider — and there is a test pinning it.Testing
406 passedacrosstests/plugins/memory/,tests/agent/test_memory_provider.py,tests/hermes_cli/test_plugins.py,tests/test_plugin_skills.py,tests/hermes_cli/test_web_server.py. Six failures intest_hindsight_provider.pyare a missing optional dependency (hindsight_client_api) and fail identically onmain.New coverage in
tests/plugins/memory/test_discovery_sources.py: project-dir discovery on and off, entry-point discovery, precedence, import-free resolution, bare-module entry points, the secondary-registration fix, and theplugins.enabledinvariant.Verified end to end against a real third-party provider (kainappsinc/elephant) installed by pip alone with no directory copy — it appears in the dropdown, resolves its directory, loads with its 34 tools, and renders its dashboard config panel.
Follow-ups, deliberately not bundled
hermes_cli/config_defaults.py:3792hardcodes env-var metadata per bundled provider; external plugins cannot join. Largely served already byget_config_schema()/config_schema.py, and a much larger refactor.hermes_cli/main.py:9210still hardcodes"honcho"in_SUBCOMMANDS. Cosmetic — it only affects unquoted multi-word session names — but it is the same class of thing PR fix(honcho): plugin drift overhaul + feat(plugins): CLI registration system #5295 removed.