mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge 56ed019a1a into fd10463069
This commit is contained in:
commit
3b3d44f48c
3 changed files with 111 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue