From be99feff1f42ffe47bd215753fa9c9c40bc5e4a9 Mon Sep 17 00:00:00 2001 From: Wysie Date: Thu, 23 Apr 2026 15:20:58 +0800 Subject: [PATCH] fix(image-gen): force-refresh plugin providers in long-lived sessions --- hermes_cli/plugins.py | 38 +++++-- .../test_image_generation_plugin_dispatch.py | 99 +++++++++++++++++++ tools/image_generation_tool.py | 10 ++ 3 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 tests/tools/test_image_generation_plugin_dispatch.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2dc1b50ea..28cb3b1b5 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -512,10 +512,23 @@ class PluginManager: # Public # ----------------------------------------------------------------------- - def discover_and_load(self) -> None: - """Scan all plugin sources and load each plugin found.""" - if self._discovered: + def discover_and_load(self, force: bool = False) -> None: + """Scan all plugin sources and load each plugin found. + + When ``force`` is true, clear cached discovery state first so config + changes or newly-added bundled backends become visible in long-lived + sessions without requiring a full agent restart. + """ + if self._discovered and not force: return + if force: + self._plugins.clear() + self._hooks.clear() + self._plugin_tool_names.clear() + self._cli_commands.clear() + self._plugin_commands.clear() + self._plugin_skills.clear() + self._context_engine = None self._discovered = True manifests: List[PluginManifest] = [] @@ -1029,9 +1042,13 @@ def get_plugin_manager() -> PluginManager: return _plugin_manager -def discover_plugins() -> None: - """Discover and load all plugins (idempotent).""" - get_plugin_manager().discover_and_load() +def discover_plugins(force: bool = False) -> None: + """Discover and load all plugins. + + Default behavior is idempotent. Pass ``force=True`` to rescan plugin + manifests and reload state in the current process. + """ + get_plugin_manager().discover_and_load(force=force) def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: @@ -1082,10 +1099,13 @@ def get_pre_tool_call_block_message( return None -def _ensure_plugins_discovered() -> PluginManager: - """Return the global manager after running idempotent plugin discovery.""" +def _ensure_plugins_discovered(force: bool = False) -> PluginManager: + """Return the global manager after ensuring plugin discovery has run. + + Pass ``force=True`` to rescan in the current process. + """ manager = get_plugin_manager() - manager.discover_and_load() + manager.discover_and_load(force=force) return manager diff --git a/tests/tools/test_image_generation_plugin_dispatch.py b/tests/tools/test_image_generation_plugin_dispatch.py new file mode 100644 index 000000000..fa8ca9d95 --- /dev/null +++ b/tests/tools/test_image_generation_plugin_dispatch.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import pytest + +from agent import image_gen_registry +from agent.image_gen_provider import ImageGenProvider + + +@pytest.fixture(autouse=True) +def _reset_registry(): + image_gen_registry._reset_for_tests() + yield + image_gen_registry._reset_for_tests() + + +class _FakeCodexProvider(ImageGenProvider): + @property + def name(self) -> str: + return "codex" + + def generate(self, prompt, aspect_ratio="landscape", **kwargs): + return { + "success": True, + "image": "/tmp/codex-test.png", + "model": "gpt-5.2-codex", + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "provider": "codex", + } + + +class TestPluginDispatch: + def test_dispatch_routes_to_codex_provider(self, monkeypatch, tmp_path): + from tools import image_generation_tool + from agent import image_gen_registry as registry_module + from hermes_cli import plugins as plugins_module + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text("image_gen:\n provider: codex\n") + image_gen_registry.register_provider(_FakeCodexProvider()) + + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "codex") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda: None) + monkeypatch.setattr(registry_module, "get_provider", lambda name: _FakeCodexProvider() if name == "codex" else None) + + dispatched = image_generation_tool._dispatch_to_plugin_provider("draw cat", "square") + payload = json.loads(dispatched) + + assert payload["success"] is True + assert payload["provider"] == "codex" + assert payload["image"] == "/tmp/codex-test.png" + assert payload["aspect_ratio"] == "square" + + def test_dispatch_reports_missing_registered_provider(self, monkeypatch, tmp_path): + from tools import image_generation_tool + from hermes_cli import plugins as plugins_module + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text("image_gen:\n provider: missing-codex\n") + + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "missing-codex") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda: None) + + dispatched = image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape") + payload = json.loads(dispatched) + + assert payload["success"] is False + assert payload["error_type"] == "provider_not_registered" + assert "image_gen.provider='missing-codex'" in payload["error"] + + def test_dispatch_force_refreshes_plugins_when_provider_initially_missing(self, monkeypatch, tmp_path): + from tools import image_generation_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as registry_module + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text("image_gen:\n provider: codex\n") + + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "codex") + + calls = [] + provider_state = {"provider": None} + + def fake_ensure_plugins_discovered(force=False): + calls.append(force) + if force: + provider_state["provider"] = _FakeCodexProvider() + + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", fake_ensure_plugins_discovered) + monkeypatch.setattr(registry_module, "get_provider", lambda name: provider_state["provider"]) + + dispatched = image_generation_tool._dispatch_to_plugin_provider("draw hammy", "portrait") + payload = json.loads(dispatched) + + assert calls == [False, True] + assert payload["success"] is True + assert payload["provider"] == "codex" + assert payload["aspect_ratio"] == "portrait" diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 9631e74ee..ac3744978 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -927,6 +927,16 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): logger.debug("image_gen plugin dispatch skipped: %s", exc) return None + if provider is None: + try: + # Long-lived sessions may have discovered plugins before a bundled + # backend was patched in or before config changed. Retry once with + # a forced refresh before surfacing a missing-provider error. + _ensure_plugins_discovered(force=True) + provider = get_provider(configured) + except Exception as exc: + logger.debug("image_gen plugin force-refresh skipped: %s", exc) + if provider is None: return json.dumps({ "success": False,