fix: keep cron-provider plugins out of the general PluginManager
With `chronos` in plugins.enabled every start warned "Failed to load plugin 'chronos': 'PluginContext' object has no attribute 'register_cron_scheduler'": collect_directory_manifests skipped the bundled memory/context_engine/model-providers categories but not cron_providers, and a user-installed cron provider under ~/.hermes/plugins/<name> was never auto-coerced to kind=exclusive. Cron providers activate through plugins.cron_providers (cron.provider config), so the general manager must only record them, exactly like memory providers. Fixes #62951 credit: @wdmason #100362 (slim redo; #100396 is the consolidated duplicate)
This commit is contained in:
@@ -176,7 +176,8 @@ def collect_directory_manifests() -> List[PluginManifest]:
|
||||
# Excluded bundled top-level categories have their own discovery; platforms scan separately.
|
||||
repo_plugins = _origin.get_bundled_plugins_dir()
|
||||
logger.debug("Scanning bundled plugins: %s", repo_plugins)
|
||||
_scan("bundled (top-level)", repo_plugins, "bundled", {"memory", "context_engine", "platforms", "model-providers"})
|
||||
_scan("bundled (top-level)", repo_plugins, "bundled",
|
||||
{"memory", "context_engine", "platforms", "model-providers", "cron_providers"})
|
||||
_scan("bundled/platforms", repo_plugins / "platforms", "bundled")
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
logger.debug("Scanning user plugins: %s", user_dir)
|
||||
|
||||
@@ -249,10 +249,12 @@ def resolve_plugin_load_order(manifests: Mapping[str, "PluginManifest"]) -> List
|
||||
|
||||
|
||||
def _detect_kind_from_source(source_text: str) -> Optional[str]:
|
||||
"""Kind implied by source markers (mirrors plugins/memory ``_is_memory_provider_dir``): memory-provider
|
||||
markers -> ``exclusive``; ``register_provider`` + ``ProviderProfile`` -> ``model-provider``; else
|
||||
``None``. Keeps both kinds out of the general manager's eager import."""
|
||||
if "register_memory_provider" in source_text or "MemoryProvider" in source_text:
|
||||
"""Kind implied by source markers (mirrors plugins/memory ``_is_memory_provider_dir`` and
|
||||
plugins/cron_providers ``_is_cron_provider_dir``): memory- or cron-provider markers -> ``exclusive``;
|
||||
``register_provider`` + ``ProviderProfile`` -> ``model-provider``; else ``None``. Keeps these kinds out
|
||||
of the general manager's eager import (its PluginContext has no ``register_cron_scheduler``, #62951)."""
|
||||
if any(marker in source_text for marker in (
|
||||
"register_memory_provider", "MemoryProvider", "register_cron_scheduler", "CronScheduler")):
|
||||
return "exclusive"
|
||||
if "register_provider" in source_text and "ProviderProfile" in source_text:
|
||||
return "model-provider"
|
||||
|
||||
@@ -555,6 +555,47 @@ class TestPluginLoading:
|
||||
assert entry.module is None
|
||||
assert "exclusive" in (entry.error or "").lower()
|
||||
|
||||
def test_bundled_cron_provider_is_not_loaded_by_general_manager(self, tmp_path, monkeypatch):
|
||||
"""``plugins/cron_providers/`` has its own discovery (``plugins.cron_providers``); the general
|
||||
manager's PluginContext has no ``register_cron_scheduler``, so importing chronos from here
|
||||
warned ``Failed to load plugin 'chronos'`` on every start it was enabled (#62951)."""
|
||||
bundled = tmp_path / "bundled"
|
||||
chronos = bundled / "cron_providers" / "chronos"
|
||||
chronos.mkdir(parents=True)
|
||||
(chronos / "plugin.yaml").write_text(yaml.dump({"name": "chronos"}), encoding="utf-8")
|
||||
(chronos / "__init__.py").write_text(
|
||||
"def register(ctx):\n ctx.register_cron_scheduler(object())\n", encoding="utf-8")
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
hermes_home.mkdir(exist_ok=True)
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({"plugins": {"enabled": ["chronos"]}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
from hermes_cli import plugins as plugins_mod
|
||||
monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: bundled)
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert not any(key.endswith("chronos") for key in mgr._plugins)
|
||||
|
||||
def test_user_cron_plugin_auto_coerced_to_exclusive(self, tmp_path, monkeypatch):
|
||||
"""A user-installed cron provider (no ``kind:``) routes to ``plugins.cron_providers`` like a
|
||||
memory provider does, instead of being imported by the general manager (#62951)."""
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
plugin_dir = hermes_home / "plugins" / "mycron"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "mycron"}), encoding="utf-8")
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"def register(ctx):\n ctx.register_cron_scheduler(object())\n", encoding="utf-8")
|
||||
(hermes_home / "config.yaml").write_text(yaml.safe_dump({"plugins": {"enabled": ["mycron"]}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
entry = mgr._plugins["mycron"]
|
||||
assert entry.manifest.kind == "exclusive"
|
||||
assert entry.module is None and not entry.enabled
|
||||
|
||||
def test_entrypoint_memory_provider_auto_coerced_to_exclusive(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
|
||||
48
tests/hermes_cli/test_single_query_plugin_cli_ref.py
Normal file
48
tests/hermes_cli/test_single_query_plugin_cli_ref.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""``hermes chat -q``/``-Q`` runs give plugins the CLI reference like the interactive loop does.
|
||||
|
||||
Only ``HermesCLI.run()`` set ``PluginManager._cli_ref``, so a plugin tool dispatched from a one-shot
|
||||
turn saw ``_cli_ref is None`` and ``PluginContext.dispatch_tool`` injected no ``parent_agent`` (#67597).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import cli
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _one_shot_seams(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
|
||||
monkeypatch.setattr(cli, "_should_seed_interactive", lambda *a, **k: False)
|
||||
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, []))
|
||||
monkeypatch.setattr(cli, "_collect_kanban_task_images", lambda imgs: [])
|
||||
monkeypatch.setattr(cli, "_finalize_single_query", lambda c: None)
|
||||
monkeypatch.setattr(get_plugin_manager(), "_cli_ref", None)
|
||||
|
||||
|
||||
def _stub(**extra):
|
||||
return SimpleNamespace(
|
||||
_single_query_mode=False, _claim_active_session=lambda *a, **k: True,
|
||||
console=SimpleNamespace(print=lambda *a, **k: None), _show_security_advisories=lambda: None,
|
||||
chat=lambda *a, **k: "response", _print_exit_summary=lambda **k: None,
|
||||
_last_turn_result={"failed": False}, **extra,
|
||||
)
|
||||
|
||||
|
||||
def test_one_shot_turn_binds_the_cli_for_plugins():
|
||||
stub = _stub()
|
||||
with pytest.raises(SystemExit):
|
||||
cli._run_single_query_mode(stub, "do the thing", None, False, True)
|
||||
assert get_plugin_manager()._cli_ref is stub
|
||||
|
||||
|
||||
def test_quiet_turn_binds_the_cli_for_plugins():
|
||||
stub = _stub(_ensure_runtime_credentials=lambda: False, _credentials_rate_limited=False,
|
||||
session_id="s1", model="m")
|
||||
with pytest.raises(SystemExit):
|
||||
cli._run_single_query_mode(stub, "do the thing", None, True, True)
|
||||
assert get_plugin_manager()._cli_ref is stub
|
||||
Reference in New Issue
Block a user