mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(plugins+nous): auto-coerce memory plugins; actionable Nous 401 diagnostic (#14005)
* fix(plugins): auto-coerce user-installed memory plugins to kind=exclusive
User-installed memory provider plugins at $HERMES_HOME/plugins/<name>/
were being dispatched to the general PluginManager, which has no
register_memory_provider method on PluginContext. Every startup logged:
Failed to load plugin 'mempalace': 'PluginContext' object has no
attribute 'register_memory_provider'
Bundled memory providers were already skipped via skip_names={memory,
context_engine} in discover_and_load, but user-installed ones weren't.
Fix: _parse_manifest now scans the plugin's __init__.py source for
'register_memory_provider' or 'MemoryProvider' (same heuristic as
plugins/memory/__init__.py:_is_memory_provider_dir) and auto-coerces
kind to 'exclusive' when the manifest didn't declare one explicitly.
This routes the plugin to plugins/memory discovery instead of the
general loader.
The escape hatch: if a manifest explicitly declares kind: standalone,
the heuristic doesn't override it.
Reported by Uncle HODL on Discord.
* fix(nous): actionable CLI message when Nous 401 refresh fails
Mirrors the Anthropic 401 diagnostic pattern. When Nous returns 401
and the credential refresh (_try_refresh_nous_client_credentials)
also fails, the user used to see only the raw APIError. Now prints:
🔐 Nous 401 — Portal authentication failed.
Response: <truncated body>
Most likely: Portal OAuth expired, account out of credits, or
agent key revoked.
Troubleshooting:
• Re-authenticate: hermes login --provider nous
• Check credits / billing: https://portal.nousresearch.com
• Verify stored credentials: $HERMES_HOME/auth.json
• Switch providers temporarily: /model <model> --provider openrouter
Addresses the common 'my hermes model hangs' pattern where the user's
Portal OAuth expired and the CLI gave no hint about the next step.
This commit is contained in:
parent
5fb143169b
commit
3e652f75b2
3 changed files with 112 additions and 0 deletions
|
|
@ -250,6 +250,73 @@ class TestPluginLoading:
|
|||
|
||||
assert "hermes_plugins.ns_plugin" in sys.modules
|
||||
|
||||
def test_user_memory_plugin_auto_coerced_to_exclusive(self, tmp_path, monkeypatch):
|
||||
"""User-installed memory plugins must NOT be loaded by the general
|
||||
PluginManager — they belong to plugins/memory discovery.
|
||||
|
||||
Regression test for the mempalace crash:
|
||||
'PluginContext' object has no attribute 'register_memory_provider'
|
||||
|
||||
A plugin that calls ``ctx.register_memory_provider`` in its
|
||||
``__init__.py`` should be auto-detected and treated as
|
||||
``kind: exclusive`` so the general loader records the manifest but
|
||||
does not import/register() it. The real activation happens through
|
||||
``plugins/memory/__init__.py`` via ``memory.provider`` config.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "mempalace"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
# No explicit `kind:` — the heuristic should kick in.
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mempalace"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"class MemPalaceProvider:\n"
|
||||
" pass\n"
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_memory_provider('mempalace', MemPalaceProvider)\n"
|
||||
)
|
||||
# Even if the user explicitly enables it in config, the loader
|
||||
# should still treat it as exclusive and skip general loading.
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["mempalace"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "mempalace" in mgr._plugins
|
||||
entry = mgr._plugins["mempalace"]
|
||||
assert entry.manifest.kind == "exclusive", (
|
||||
f"Expected auto-coerced kind='exclusive', got {entry.manifest.kind}"
|
||||
)
|
||||
# Not loaded by general manager (no register() call, no AttributeError).
|
||||
assert not entry.enabled
|
||||
assert entry.module is None
|
||||
assert "exclusive" in (entry.error or "").lower()
|
||||
|
||||
def test_explicit_standalone_kind_not_coerced(self, tmp_path, monkeypatch):
|
||||
"""If a plugin explicitly declares ``kind: standalone`` in its
|
||||
manifest, the memory-provider heuristic must NOT override it —
|
||||
even if the source happens to mention ``MemoryProvider``.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "not_memory"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
yaml.dump({"name": "not_memory", "kind": "standalone"})
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"# This plugin inspects MemoryProvider docs but isn't one.\n"
|
||||
"def register(ctx):\n pass\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert mgr._plugins["not_memory"].manifest.kind == "standalone"
|
||||
|
||||
|
||||
# ── TestPluginHooks ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue