diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 7eb9a400c..40d48993a 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -144,6 +144,46 @@ def _get_enabled_plugins() -> Optional[set]: _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"} +def _looks_like_exclusive_plugin(plugin_dir: Path) -> bool: + """Heuristic: does *plugin_dir* implement an exclusive provider contract? + + Today this is primarily used to keep memory provider plugins discovered under + ``$HERMES_HOME/plugins`` out of the general plugin loader. Those plugins are + selected via ``memory.provider`` and register via + ``ctx.register_memory_provider(...)`` rather than generic tool/hook APIs. + + We scan a small set of Python files under the plugin root so wrapper-style + packages that re-export their real implementation from ``src/`` still get + classified correctly. + """ + py_files = [] + init_file = plugin_dir / "__init__.py" + if init_file.exists(): + py_files.append(init_file) + try: + for path in sorted(plugin_dir.rglob("*.py")): + if path == init_file: + continue + py_files.append(path) + if len(py_files) >= 12: + break + except Exception: + pass + + scanned = 0 + for path in py_files: + try: + source = path.read_text(errors="replace") + except Exception: + continue + scanned += len(source) + if "register_memory_provider" in source or "MemoryProvider" in source: + return True + if scanned >= 32768: + break + return False + + @dataclass class PluginManifest: """Parsed representation of a plugin.yaml manifest.""" @@ -753,16 +793,24 @@ class PluginManager: name = data.get("name", plugin_dir.name) key = f"{prefix}/{plugin_dir.name}" if prefix else name - raw_kind = data.get("kind", "standalone") - if not isinstance(raw_kind, str): - raw_kind = "standalone" - kind = raw_kind.strip().lower() - if kind not in _VALID_PLUGIN_KINDS: - logger.warning( - "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", - key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), - ) - kind = "standalone" + raw_kind = data.get("kind") + if raw_kind is None: + kind = "exclusive" if _looks_like_exclusive_plugin(plugin_dir) else "standalone" + if kind == "exclusive": + logger.debug( + "Auto-classifying plugin %s as exclusive based on provider-style register() contract", + key, + ) + else: + if not isinstance(raw_kind, str): + raw_kind = "standalone" + kind = raw_kind.strip().lower() + if kind not in _VALID_PLUGIN_KINDS: + logger.warning( + "Plugin %s: unknown kind '%s' (valid: %s); treating as 'standalone'", + key, raw_kind, ", ".join(sorted(_VALID_PLUGIN_KINDS)), + ) + kind = "standalone" # Auto-coerce user-installed memory providers to kind="exclusive" # so they're routed to plugins/memory discovery instead of being diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index e91bb6e41..ba8593848 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -51,6 +51,24 @@ class TestGatewayPidState: assert status.get_running_pid() is None assert not pid_path.exists() + def test_get_running_pid_removes_stale_json_pid_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + pid_path = tmp_path / "gateway.pid" + pid_path.write_text(json.dumps({ + "pid": 99999, + "kind": "hermes-gateway", + "argv": ["python", "-m", "hermes_cli.main", "gateway"], + "start_time": None, + })) + + def _raise_missing(pid, sig): + raise ProcessLookupError + + monkeypatch.setattr(status.os, "kill", _raise_missing) + + assert status.get_running_pid() is None + assert not pid_path.exists() + def test_get_running_pid_accepts_gateway_metadata_when_cmdline_unavailable(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) pid_path = tmp_path / "gateway.pid" diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 157f967e5..3d31908e0 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -100,6 +100,41 @@ class TestPluginDiscovery: assert "hello_plugin" in mgr._plugins assert mgr._plugins["hello_plugin"].enabled + def test_user_memory_provider_plugins_are_classified_exclusive(self, tmp_path, monkeypatch): + """Wrapper-style user memory providers should bypass the general loader.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + plugin_dir = plugins_dir / "memory_plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text( + yaml.safe_dump({ + "name": "memory_plugin", + "version": "0.1.0", + "description": "Wrapper-style memory provider plugin", + }) + ) + (plugin_dir / "__init__.py").write_text( + 'from .src.memory_plugin_impl import register\n' + ) + impl_dir = plugin_dir / "src" / "memory_plugin_impl" + impl_dir.mkdir(parents=True, exist_ok=True) + (impl_dir / "__init__.py").write_text( + 'def register(ctx):\n ctx.register_memory_provider(object())\n' + ) + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["memory_plugin"]}}) + ) + + mgr = PluginManager() + mgr.discover_and_load() + + assert "memory_plugin" in mgr._plugins + loaded = mgr._plugins["memory_plugin"] + assert loaded.manifest.kind == "exclusive" + assert not loaded.enabled + assert "exclusive plugin" in (loaded.error or "") + def test_discover_project_plugins(self, tmp_path, monkeypatch): """Plugins in ./.hermes/plugins/ are discovered.""" project_dir = tmp_path / "project"