From 718e4e2e7ec86db5492deb124bf33c858a9fa251 Mon Sep 17 00:00:00 2001 From: emozilla Date: Tue, 28 Apr 2026 13:24:29 -0400 Subject: [PATCH] fix(plugins): register dynamically-loaded modules in sys.modules before exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard plugin API routes (web_server._mount_plugin_api_routes) and gateway event hooks (gateway.hooks.HookRegistry.discover_and_load) both loaded Python files via importlib.util.spec_from_file_location + exec_module without registering the resulting module in sys.modules. That breaks any plugin or hook handler that uses `from __future__ import annotations` together with a Pydantic BaseModel / dataclass / anything that introspects `__module__`: at first request Pydantic tries to resolve string-form type hints against the defining module's namespace, can't find it by name, and raises: PydanticUserError: TypeAdapter[...] is not fully defined; you should define ... and all referenced types, then call `.rebuild()` on the instance. This is what broke the kanban dashboard's 'triage' button — POST /api/plugins/kanban/tasks validated against CreateTaskBody (a Pydantic model in a file using `from __future__ import annotations`) and returned 500 on every click. The fix, applied symmetrically to both loaders: 1. Compute module_name once. 2. Register the module in sys.modules BEFORE exec_module. 3. On exec_module failure, pop the half-initialized stub so subsequent reloads don't pick up broken state. GETs were unaffected because they don't build a body TypeAdapter, which is why this only surfaced when users started POSTing. --- gateway/hooks.py | 19 ++++++++++++++++--- hermes_cli/web_server.py | 18 ++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/gateway/hooks.py b/gateway/hooks.py index f887cf5df0..5ab4511920 100644 --- a/gateway/hooks.py +++ b/gateway/hooks.py @@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline. import asyncio import importlib.util +import sys from typing import Any, Callable, Dict, List, Optional import yaml @@ -97,16 +98,28 @@ class HookRegistry: print(f"[hooks] Skipping {hook_name}: no events declared", flush=True) continue - # Dynamically load the handler module + # Dynamically load the handler module. + # Register in sys.modules BEFORE exec_module so Pydantic / + # dataclasses / typing introspection can resolve forward + # references (triggered by `from __future__ import annotations` + # in the handler). Without this, a handler that declares a + # Pydantic BaseModel for webhook/event payloads fails at first + # dispatch with "TypeAdapter ... is not fully defined". + module_name = f"hermes_hook_{hook_name}" spec = importlib.util.spec_from_file_location( - f"hermes_hook_{hook_name}", handler_path + module_name, handler_path ) if spec is None or spec.loader is None: print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True) continue module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise handle_fn = getattr(module, "handle", None) if handle_fn is None: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 569449f188..c9f8f6bf1b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3224,13 +3224,23 @@ def _mount_plugin_api_routes(): _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) continue try: - spec = importlib.util.spec_from_file_location( - f"hermes_dashboard_plugin_{plugin['name']}", api_path, - ) + module_name = f"hermes_dashboard_plugin_{plugin['name']}" + spec = importlib.util.spec_from_file_location(module_name, api_path) if spec is None or spec.loader is None: continue mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) + # Register in sys.modules BEFORE exec_module so pydantic/FastAPI + # can resolve forward references (e.g. models defined in a file + # that uses `from __future__ import annotations`). Without this, + # TypeAdapter lazy-build fails at first request with + # "is not fully defined" because the module namespace isn't + # reachable by name for string-annotation resolution. + sys.modules[module_name] = mod + try: + spec.loader.exec_module(mod) + except Exception: + sys.modules.pop(module_name, None) + raise router = getattr(mod, "router", None) if router is None: _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])