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.
fix/verification-admin-route-recovery
Teknium 2026-04-22 05:54:11 -07:00 committed by GitHub
parent 5fb143169b
commit 3e652f75b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 112 additions and 0 deletions

View File

@ -734,6 +734,30 @@ class PluginManager:
)
kind = "standalone"
# Auto-coerce user-installed memory providers to kind="exclusive"
# so they're routed to plugins/memory discovery instead of being
# loaded by the general PluginManager (which has no
# register_memory_provider on PluginContext). Mirrors the
# heuristic in plugins/memory/__init__.py:_is_memory_provider_dir.
# Bundled memory providers are already skipped via skip_names.
if kind == "standalone" and "kind" not in data:
init_file = plugin_dir / "__init__.py"
if init_file.exists():
try:
source_text = init_file.read_text(errors="replace")[:8192]
if (
"register_memory_provider" in source_text
or "MemoryProvider" in source_text
):
kind = "exclusive"
logger.debug(
"Plugin %s: detected memory provider, "
"treating as kind='exclusive'",
key,
)
except Exception:
pass
return PluginManifest(
name=name,
version=str(data.get("version", "")),

View File

@ -10044,6 +10044,27 @@ class AIAgent:
if self._try_refresh_nous_client_credentials(force=True):
print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
continue
# Credential refresh didn't help — show diagnostic info.
# Most common causes: Portal OAuth expired/revoked,
# account out of credits, or agent key blocked.
from hermes_constants import display_hermes_home as _dhh_fn
_dhh = _dhh_fn()
_body_text = ""
try:
_body = getattr(api_error, "body", None) or getattr(api_error, "response", None)
if _body is not None:
_body_text = str(_body)[:200]
except Exception:
pass
print(f"{self.log_prefix}🔐 Nous 401 — Portal authentication failed.")
if _body_text:
print(f"{self.log_prefix} Response: {_body_text}")
print(f"{self.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
print(f"{self.log_prefix} Troubleshooting:")
print(f"{self.log_prefix} • Re-authenticate: hermes login --provider nous")
print(f"{self.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
print(f"{self.log_prefix} • Verify stored credentials: {_dhh}/auth.json")
print(f"{self.log_prefix} • Switch providers temporarily: /model <model> --provider openrouter")
if (
self.api_mode == "anthropic_messages"
and status_code == 401

View File

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