Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,10 +512,23 @@ def __init__(self) -> None:
# 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] = []
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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


Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"30841158+n-WN@users.noreply.github.com": "n-WN",
"tsuijinglei@gmail.com": "hiddenpuppy",
"jerome@clawwork.ai": "HiddenPuppy",
"wysie@users.noreply.github.com": "Wysie",
"leoyuan0099@gmail.com": "keyuyuan",
"bxzt2006@163.com": "Only-Code-A",
"i@troy-y.org": "TroyMitchell911",
Expand Down
99 changes: 99 additions & 0 deletions tests/tools/test_image_generation_plugin_dispatch.py
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions tools/image_generation_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading