Compare commits
2 Commits
fix/953
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599904d945 | ||
|
|
df1bfe433a |
@@ -211,6 +211,43 @@ class PluginContext:
|
||||
}
|
||||
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
|
||||
|
||||
# -- memory provider registration ----------------------------------------
|
||||
|
||||
def register_memory_provider(self, provider) -> None:
|
||||
"""Register a memory provider supplied by this plugin.
|
||||
|
||||
The provider must be an instance of ``agent.memory_provider.MemoryProvider``.
|
||||
Only one plugin-registered memory provider is accepted; a second
|
||||
attempt is rejected with a warning.
|
||||
|
||||
The registered provider is retrievable via
|
||||
``get_plugin_memory_provider()`` and is picked up by ``run_agent.py``
|
||||
when ``memory.provider`` in *config.yaml* matches the provider's
|
||||
``name`` property.
|
||||
"""
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
if not isinstance(provider, MemoryProvider):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a memory provider that does not "
|
||||
"inherit from MemoryProvider. Ignoring.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
if self._manager._plugin_memory_provider is not None:
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a memory provider, but one is "
|
||||
"already registered by another plugin. Only one plugin-supplied "
|
||||
"memory provider is allowed at a time.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
self._manager._plugin_memory_provider = provider
|
||||
logger.info(
|
||||
"Plugin '%s' registered memory provider: %s",
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- context engine registration -----------------------------------------
|
||||
|
||||
def register_context_engine(self, engine) -> None:
|
||||
@@ -323,6 +360,7 @@ class PluginManager:
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._plugin_memory_provider = None # Set by a plugin via register_memory_provider()
|
||||
self._discovered: bool = False
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
@@ -699,6 +737,11 @@ def get_plugin_context_engine():
|
||||
return get_plugin_manager()._context_engine
|
||||
|
||||
|
||||
def get_plugin_memory_provider():
|
||||
"""Return the plugin-registered memory provider, or None."""
|
||||
return get_plugin_manager()._plugin_memory_provider
|
||||
|
||||
|
||||
def get_plugin_toolsets() -> List[tuple]:
|
||||
"""Return plugin toolsets as ``(key, label, description)`` tuples.
|
||||
|
||||
|
||||
12
run_agent.py
12
run_agent.py
@@ -1193,6 +1193,18 @@ class AIAgent:
|
||||
from plugins.memory import load_memory_provider as _load_mem
|
||||
self._memory_manager = _MemoryManager()
|
||||
_mp = _load_mem(_mem_provider_name)
|
||||
# Fall back to a user plugin that called register_memory_provider()
|
||||
if _mp is None:
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_memory_provider as _gpm
|
||||
_candidate = _gpm()
|
||||
if _candidate and _candidate.name == _mem_provider_name:
|
||||
_mp = _candidate
|
||||
except Exception as _gpm_err:
|
||||
logger.debug(
|
||||
"get_plugin_memory_provider() failed during fallback lookup: %s",
|
||||
_gpm_err,
|
||||
)
|
||||
if _mp and _mp.is_available():
|
||||
self._memory_manager.add_provider(_mp)
|
||||
if self._memory_manager.providers:
|
||||
|
||||
0
skills/creative/excalidraw/scripts/upload.py
Executable file → Normal file
0
skills/creative/excalidraw/scripts/upload.py
Executable file → Normal file
0
skills/leisure/find-nearby/scripts/find_nearby.py
Executable file → Normal file
0
skills/leisure/find-nearby/scripts/find_nearby.py
Executable file → Normal file
0
skills/media/youtube-content/scripts/fetch_transcript.py
Executable file → Normal file
0
skills/media/youtube-content/scripts/fetch_transcript.py
Executable file → Normal file
0
skills/productivity/google-workspace/scripts/google_api.py
Executable file → Normal file
0
skills/productivity/google-workspace/scripts/google_api.py
Executable file → Normal file
0
skills/productivity/google-workspace/scripts/setup.py
Executable file → Normal file
0
skills/productivity/google-workspace/scripts/setup.py
Executable file → Normal file
0
skills/productivity/ocr-and-documents/scripts/extract_marker.py
Executable file → Normal file
0
skills/productivity/ocr-and-documents/scripts/extract_marker.py
Executable file → Normal file
0
skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py
Executable file → Normal file
0
skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/auto_jailbreak.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/auto_jailbreak.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/godmode_race.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/godmode_race.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/parseltongue.py
Executable file → Normal file
0
skills/red-teaming/godmode/scripts/parseltongue.py
Executable file → Normal file
0
skills/research/arxiv/scripts/search_arxiv.py
Executable file → Normal file
0
skills/research/arxiv/scripts/search_arxiv.py
Executable file → Normal file
0
skills/research/polymarket/scripts/polymarket.py
Executable file → Normal file
0
skills/research/polymarket/scripts/polymarket.py
Executable file → Normal file
@@ -19,6 +19,7 @@ from hermes_cli.plugins import (
|
||||
PluginManifest,
|
||||
get_plugin_manager,
|
||||
get_pre_tool_call_block_message,
|
||||
get_plugin_memory_provider,
|
||||
discover_plugins,
|
||||
invoke_hook,
|
||||
)
|
||||
@@ -609,3 +610,105 @@ class TestPreLlmCallTargetRouting:
|
||||
# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands,
|
||||
# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS
|
||||
# integration — all of which are unimplemented features.
|
||||
|
||||
|
||||
# ── TestRegisterMemoryProvider ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterMemoryProvider:
|
||||
"""Regression tests for PluginContext.register_memory_provider() — issue #990.
|
||||
|
||||
The MemPalace plugin (and any user plugin following the developer guide)
|
||||
calls ``ctx.register_memory_provider(provider)`` inside ``register(ctx)``.
|
||||
Before the fix, PluginContext had no such method and the plugin failed to
|
||||
load with: 'PluginContext' object has no attribute 'register_memory_provider'.
|
||||
"""
|
||||
|
||||
def _make_memory_plugin(self, plugins_dir: "Path", name: str) -> None:
|
||||
"""Write a minimal user plugin that registers a stub MemoryProvider."""
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
plugin_dir = plugins_dir / name
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
f"name: {name}\nversion: 0.1.0\ndescription: Stub memory plugin\n"
|
||||
)
|
||||
# The register() body imports and calls register_memory_provider — this
|
||||
# is the exact pattern documented in memory-provider-plugin.md and used
|
||||
# by third-party plugins such as MemPalace.
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"from agent.memory_provider import MemoryProvider\n"
|
||||
"\n"
|
||||
"class _StubProvider(MemoryProvider):\n"
|
||||
" @property\n"
|
||||
f" def name(self): return '{name}'\n"
|
||||
" def is_available(self): return True\n"
|
||||
" def initialize(self, session_id, **kw): pass\n"
|
||||
" def get_tool_schemas(self): return []\n"
|
||||
"\n"
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_memory_provider(_StubProvider())\n"
|
||||
)
|
||||
|
||||
def test_register_memory_provider_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A user plugin calling register_memory_provider() loads without error."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_memory_plugin(plugins_dir, "mempalace")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert "mempalace" in mgr._plugins
|
||||
assert mgr._plugins["mempalace"].enabled, (
|
||||
mgr._plugins["mempalace"].error
|
||||
)
|
||||
|
||||
def test_plugin_memory_provider_stored(self, tmp_path, monkeypatch):
|
||||
"""The provider instance is accessible via get_plugin_memory_provider()."""
|
||||
import hermes_cli.plugins as plugins_mod
|
||||
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_memory_plugin(plugins_dir, "mempalace")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
# Swap the singleton so get_plugin_memory_provider() sees our manager
|
||||
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
|
||||
mgr.discover_and_load()
|
||||
|
||||
provider = get_plugin_memory_provider()
|
||||
assert provider is not None
|
||||
assert provider.name == "mempalace"
|
||||
|
||||
def test_second_registration_rejected(self, tmp_path, monkeypatch):
|
||||
"""Only one plugin-registered memory provider is accepted."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
self._make_memory_plugin(plugins_dir, "first_provider")
|
||||
self._make_memory_plugin(plugins_dir, "second_provider")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# The manager should hold exactly one provider
|
||||
assert mgr._plugin_memory_provider is not None
|
||||
assert mgr._plugin_memory_provider.name in {"first_provider", "second_provider"}
|
||||
|
||||
def test_non_provider_rejected(self, tmp_path, monkeypatch):
|
||||
"""Passing a non-MemoryProvider object logs a warning and is ignored."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "bad_provider"
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "plugin.yaml").write_text("name: bad_provider\n")
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_memory_provider('not-a-provider')\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Plugin still loads (warning only), but no provider is stored
|
||||
assert mgr._plugin_memory_provider is None
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Regression tests for bundled skill scripts and local shell execution.
|
||||
|
||||
Issue #953 verifies that bundled skill scripts run out of the box from the
|
||||
installed ~/.hermes/skills tree without manual chmod or PATH surgery.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SKILLS_ROOT = REPO_ROOT / "skills"
|
||||
|
||||
|
||||
def _bundled_shebang_scripts() -> list[Path]:
|
||||
scripts: list[Path] = []
|
||||
for path in SKILLS_ROOT.rglob("*"):
|
||||
if not path.is_file() or path.is_symlink() or "scripts" not in path.parts:
|
||||
continue
|
||||
first_line = path.read_bytes().splitlines()[:1]
|
||||
if first_line and first_line[0].startswith(b"#!"):
|
||||
scripts.append(path)
|
||||
return sorted(scripts)
|
||||
|
||||
|
||||
def test_bundled_skill_shebang_scripts_are_executable():
|
||||
missing = []
|
||||
for path in _bundled_shebang_scripts():
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
if mode & 0o111 == 0:
|
||||
missing.append(f"{path.relative_to(REPO_ROOT)} ({oct(mode)})")
|
||||
|
||||
assert not missing, (
|
||||
"Bundled shebang scripts must ship executable so synced skill copies run "
|
||||
"without manual chmod:\n" + "\n".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def test_local_environment_executes_installed_skill_script_without_manual_prep(tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
installed_skill = hermes_home / "skills" / "research" / "arxiv"
|
||||
installed_skill.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(SKILLS_ROOT / "research" / "arxiv", installed_skill)
|
||||
|
||||
script_path = installed_skill / "scripts" / "search_arxiv.py"
|
||||
env = LocalEnvironment(
|
||||
cwd=str(tmp_path),
|
||||
timeout=15,
|
||||
env={
|
||||
"HERMES_HOME": str(hermes_home),
|
||||
"PATH": "/custom/bin",
|
||||
},
|
||||
)
|
||||
|
||||
result = env.execute(f"{shlex.quote(str(script_path))} --help")
|
||||
|
||||
assert result["returncode"] == 0, result["output"]
|
||||
assert "Search arXiv and display results in a clean format." in result["output"]
|
||||
assert "python search_arxiv.py" in result["output"]
|
||||
Reference in New Issue
Block a user