diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 11f18f071..3dd7af823 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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", "")), diff --git a/run_agent.py b/run_agent.py index ef4019163..ec5e86d78 100644 --- a/run_agent.py +++ b/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 --provider openrouter") if ( self.api_mode == "anthropic_messages" and status_code == 401 diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 9433ecdca..04d056771 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 ────────────────────────────────────────────────────────