From b82a598a31d7fcff40d0cd78db063ecc45c3bcbb Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 30 Aug 2026 17:14:49 +0800 Subject: [PATCH 1/2] fix(providers): skip platform and memory plugins in entry-point discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider discovery called ep.load() on every enabled hermes_agent.plugins entry point regardless of kind. Platform adapters are owned by the PluginManager (loaded lazily), and their documented entry modules do `from gateway.config import Platform` at the top — when discovery runs inside the gateway.config -> hermes_cli.config import chain, that import hits a half-initialized module and the platform silently disappears (#98438). Skip entry points that are provably not model providers, import-free: a `-platform` name (the manager's platform-id convention) or a memory-provider source signature (the classifier the PluginManager already trusts). Everything else keeps the historical load path, so a real provider is never dropped when its source cannot be classified. --- providers/__init__.py | 29 +++++ tests/providers/test_entry_point_discovery.py | 103 +++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/providers/__init__.py b/providers/__init__.py index 011e84afa7233..eed7fff06b6b9 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -214,6 +214,35 @@ def _discover_entry_point_providers() -> None: "entry-point provider %r skipped: not enabled in config", ep.name ) continue + # Import-free ownership precheck (#98438): this group is shared with + # the general PluginManager, which classifies entry points WITHOUT + # importing them and loads platform plugins lazily. Importing a + # platform adapter here breaks it — documented adapters do + # ``from gateway.config import Platform`` at module top, and when + # this scan runs inside the ``gateway.config -> hermes_cli.config`` + # import chain (provider env injection at the bottom of + # hermes_cli/config.py) ``gateway.config`` is still half-initialized, + # so the import fails and the platform silently disappears. Skip + # entry points that are provably NOT model providers: a + # ``-platform`` name (the manager's platform-id convention) or + # a memory-provider source signature. Anything else falls through to + # the load below — a real provider must never be dropped just + # because its source could not be classified. + try: + from hermes_cli.plugins import _classify_entrypoint_value_kind + + value = getattr(ep, "value", "") + if ep.name.endswith("-platform") or ( + value and _classify_entrypoint_value_kind(value) == "exclusive" + ): + logger.debug( + "entry-point %r skipped by provider scan: platform or " + "memory-provider plugin owned by the PluginManager", + ep.name, + ) + continue + except Exception: + pass # classification unavailable — keep the historical behavior try: loaded = ep.load() except Exception as exc: diff --git a/tests/providers/test_entry_point_discovery.py b/tests/providers/test_entry_point_discovery.py index 86965f47fb342..685f1b5aff952 100644 --- a/tests/providers/test_entry_point_discovery.py +++ b/tests/providers/test_entry_point_discovery.py @@ -51,9 +51,10 @@ def _restore_real_discovery(): class _FakeEP: - def __init__(self, name, loader): + def __init__(self, name, loader, value=""): self.name = name self.group = "hermes_agent.plugins" + self.value = value self._loader = loader def load(self): @@ -223,3 +224,103 @@ def register(): assert "impostor.test" not in (p.base_url or "") finally: _clear_provider_caches() + + +def test_platform_named_entry_point_not_loaded(monkeypatch): + """A ``-platform`` entry point must not be imported by this scan + (#98438). + + Platform adapters are owned by the PluginManager, which loads them + lazily; importing one here runs its ``from gateway.config import + Platform`` while ``gateway.config`` may still be half-initialized (this + scan can run inside the ``gateway.config -> hermes_cli.config`` import + chain), killing the platform with a circular-import error. + """ + loads = [] + + def _platform_side_effect(): + loads.append("stub-platform") # the import itself would already fail + return object() + + fake_eps = _FakeEntryPoints( + [_FakeEP("stub-platform", _platform_side_effect, value="stubplat")] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "stub-platform") + _clear_provider_caches() + try: + providers._discover_providers() + assert loads == [] # never imported + finally: + _clear_provider_caches() + + +def test_memory_provider_entry_point_not_loaded(monkeypatch, tmp_path): + """An enabled memory-provider entry point (``MemoryProvider`` source + signature) is not imported here — memory providers are owned by the + ``plugins/memory`` discovery system.""" + loads = [] + # A resolvable top-level module whose source carries the memory-provider + # markers the PluginManager's import-free classifier looks for. + (tmp_path / "stub_mem_plugin.py").write_text( + "from plugins.memory import register_memory_provider\n" + "class StubMemoryProvider(MemoryProvider):\n" + " pass\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + def _mem_side_effect(): + loads.append("stub-mem") + return object() + + fake_eps = _FakeEntryPoints( + [_FakeEP("stub-mem", _mem_side_effect, value="stub_mem_plugin")] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "stub-mem") + _clear_provider_caches() + try: + providers._discover_providers() + assert loads == [] # never imported + finally: + sys.modules.pop("stub_mem_plugin", None) + _clear_provider_caches() + + +def test_unresolvable_entry_point_still_loaded(monkeypatch): + """Fail-open: an entry point whose source cannot be classified is still + loaded, so a real provider is never dropped because classification + failed (unresolvable module, thin re-export package, ...).""" + from providers.base import ProviderProfile + + def _register_unresolvable(): + def register(): + providers.register_provider( + ProviderProfile(name="ep-unresolvable", base_url="https://c.test/v1") + ) + + return register + + fake_eps = _FakeEntryPoints( + [ + _FakeEP( + "ep-unresolvable", + _register_unresolvable, + value="no_such_module_zq", + ) + ] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "ep-unresolvable") + _clear_provider_caches() + try: + assert providers.get_provider_profile("ep-unresolvable") is not None + finally: + _clear_provider_caches() From 560dc44c3ecd8312ff14efa13500fa5521365f9c Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 30 Aug 2026 17:32:12 +0800 Subject: [PATCH 2/2] fix(providers): also skip hand-written platform entries by source import marker Follow-up to the import-free ownership precheck: the `-platform` name suffix only covers PluginManager-generated ids. A hand-written `plugins.enabled` entry can carry any name, so its adapter would still be imported and hit the same half-initialized `gateway.config` import chain. Scan the (already resolved) module source for the documented `from gateway.config import Platform` adapter base import as a third skip signal; unresolvable sources keep the fail-open load path. Suggested-by: kokhlo --- providers/__init__.py | 33 ++++++-- tests/providers/test_entry_point_discovery.py | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+), 7 deletions(-) diff --git a/providers/__init__.py b/providers/__init__.py index eed7fff06b6b9..ba2b4e58ee493 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -47,6 +47,11 @@ _PROVIDER_LIST_CACHE: list[ProviderProfile] | None = None _discovered = False +# Import-free platform-adapter signal: every documented adapter does this +# import at module top (see gateway/platforms/*), while model-provider +# plugins never touch gateway.config. Scanned, never executed. +_PLATFORM_IMPORT_MARKER = "from gateway.config import Platform" + # Repo-root ``plugins/model-providers/`` — populated at discovery time. _BUNDLED_PLUGINS_DIR = ( Path(__file__).resolve().parent.parent / "plugins" / "model-providers" @@ -224,17 +229,31 @@ def _discover_entry_point_providers() -> None: # hermes_cli/config.py) ``gateway.config`` is still half-initialized, # so the import fails and the platform silently disappears. Skip # entry points that are provably NOT model providers: a - # ``-platform`` name (the manager's platform-id convention) or - # a memory-provider source signature. Anything else falls through to - # the load below — a real provider must never be dropped just - # because its source could not be classified. + # ``-platform`` name (the manager's platform-id convention), a + # memory-provider source signature, or the documented adapter import + # in the source (hand-written ``plugins.enabled`` entries may carry + # any name, so the suffix alone can miss them). Anything else falls + # through to the load below — a real provider must never be dropped + # just because its source could not be classified. try: - from hermes_cli.plugins import _classify_entrypoint_value_kind + from hermes_cli.plugins import ( + _classify_entrypoint_value_kind, + _resolve_module_source, + ) value = getattr(ep, "value", "") - if ep.name.endswith("-platform") or ( + skip = ep.name.endswith("-platform") or ( value and _classify_entrypoint_value_kind(value) == "exclusive" - ): + ) + if not skip and value: + try: + module_name = str(value).split(":", 1)[0].strip() + skip = bool(module_name) and _PLATFORM_IMPORT_MARKER in ( + _resolve_module_source(module_name) + ) + except Exception: + skip = False # unresolvable source — fail open + if skip: logger.debug( "entry-point %r skipped by provider scan: platform or " "memory-provider plugin owned by the PluginManager", diff --git a/tests/providers/test_entry_point_discovery.py b/tests/providers/test_entry_point_discovery.py index 685f1b5aff952..84cc3d0cde0a7 100644 --- a/tests/providers/test_entry_point_discovery.py +++ b/tests/providers/test_entry_point_discovery.py @@ -324,3 +324,84 @@ def register(): assert providers.get_provider_profile("ep-unresolvable") is not None finally: _clear_provider_caches() + + +def test_platform_import_marker_entry_point_not_loaded(monkeypatch, tmp_path): + """A hand-written ``plugins.enabled`` entry whose name lacks the + ``-platform`` suffix is still skipped when its source carries the + documented platform-adapter import (#98438). + + The name convention only covers PluginManager-generated ids; the + ``from gateway.config import Platform`` base import is the remaining + import-free platform signal (model-provider plugins never touch + gateway.config). + """ + loads = [] + (tmp_path / "stub_platform_plugin.py").write_text( + "from gateway.config import Platform, PlatformConfig\n" + "class StubAdapter:\n" + " pass\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + def _adapter_side_effect(): + loads.append("stub-adapter") # the import itself would already fail + return object() + + fake_eps = _FakeEntryPoints( + [ + _FakeEP( + "stub-adapter", + _adapter_side_effect, + value="stub_platform_plugin", + ) + ] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "stub-adapter") + _clear_provider_caches() + try: + providers._discover_providers() + assert loads == [] # never imported + finally: + sys.modules.pop("stub_platform_plugin", None) + _clear_provider_caches() + + +def test_plain_plugin_without_markers_still_loaded(monkeypatch, tmp_path): + """A source-bearing entry point with NO platform/memory markers keeps + the historical load path — the marker scan must not over-skip.""" + from providers.base import ProviderProfile + + (tmp_path / "stub_plain_plugin.py").write_text( + "# a plain plugin: no gateway import, no memory markers\n" + "def register():\n" + " pass\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + def _plain_side_effect(): + def register(): + providers.register_provider( + ProviderProfile(name="ep-plain", base_url="https://d.test/v1") + ) + + return register + + fake_eps = _FakeEntryPoints( + [_FakeEP("ep-plain", _plain_side_effect, value="stub_plain_plugin")] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "ep-plain") + _clear_provider_caches() + try: + assert providers.get_provider_profile("ep-plain") is not None + finally: + sys.modules.pop("stub_plain_plugin", None) + _clear_provider_caches()