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
parent
5fb143169b
commit
3e652f75b2
|
|
@ -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", "")),
|
||||
|
|
|
|||
21
run_agent.py
21
run_agent.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…
Reference in New Issue