diff --git a/hermes_cli/plugins_discovery.py b/hermes_cli/plugins_discovery.py index 21d5850696..03c594423c 100644 --- a/hermes_cli/plugins_discovery.py +++ b/hermes_cli/plugins_discovery.py @@ -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) diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index e81fe50822..d0fadf5070 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -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" diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index d4e5e0ca65..c28a26abcc 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 ): diff --git a/tests/hermes_cli/test_single_query_plugin_cli_ref.py b/tests/hermes_cli/test_single_query_plugin_cli_ref.py new file mode 100644 index 0000000000..e3e74f8edc --- /dev/null +++ b/tests/hermes_cli/test_single_query_plugin_cli_ref.py @@ -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