279 lines
12 KiB
Python
279 lines
12 KiB
Python
"""Shared directory-plugin loader for ``plugins/<kind>/<name>/`` discovery packages
|
|
(cron_providers, context_engine, memory): import ``__init__.py`` by path with siblings
|
|
pre-registered so relative imports work, then extract the provider via ``register(ctx)``
|
|
or an ABC-subclass fallback."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import logging
|
|
import sys
|
|
import threading
|
|
import time
|
|
from contextvars import ContextVar
|
|
from pathlib import Path
|
|
from typing import Any, Callable, List, Optional, Tuple
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_PLUGINS_ROOT = Path(__file__).parent
|
|
_MODULE_LOAD_LOCKS: dict[str, threading.RLock] = {}
|
|
_MODULE_LOAD_LOCKS_GUARD = threading.Lock()
|
|
# Inside bounded_load_wait(), how long a second caller waits for another thread's in-flight load of
|
|
# the same module. Past it the module is marked stalled (a hung import) and later bounded callers
|
|
# refuse at once until it finishes. Other callers (agent builds) wait for the load to finish.
|
|
_CONCURRENT_LOAD_WAIT_SECS = 10.0
|
|
_BOUNDED_WAIT: ContextVar[bool] = ContextVar("plugin_load_bounded_wait", default=False)
|
|
_STALLED_LOADS: set[str] = set()
|
|
_LOAD_OWNERS: dict[str, int] = {} # module -> thread id running its load
|
|
_LOAD_WAITERS: dict[int, str] = {} # thread id -> module whose load it waits for
|
|
|
|
|
|
def _waits_for(tid: int) -> set:
|
|
"""Threads *tid* is blocked on: a loader lock owner, or an importlib module-lock owner."""
|
|
owners = {_LOAD_OWNERS.get(_LOAD_WAITERS.get(tid, ""))}
|
|
blocked = getattr(importlib._bootstrap, "_blocking_on", {}).get(tid) # list on 3.12+, lock on 3.11
|
|
owners.update(getattr(lk, "owner", None) for lk in (blocked if isinstance(blocked, list) else [blocked]))
|
|
return owners - {None}
|
|
|
|
|
|
def _load_would_deadlock(me: int) -> bool:
|
|
"""Whether the wait graph (loader locks + importlib's module locks) leads back to *me*.
|
|
importlib cannot see the loader lock in its own deadlock check, so walk both kinds here."""
|
|
seen, todo = set(), list(_waits_for(me))
|
|
while todo:
|
|
tid = todo.pop()
|
|
if tid == me:
|
|
return True
|
|
if tid not in seen:
|
|
seen.add(tid)
|
|
todo.extend(_waits_for(tid))
|
|
return False
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def bounded_load_wait():
|
|
"""For per-turn callers that must not stall on another thread's hung plugin import: their
|
|
loads give up (None) after ``_CONCURRENT_LOAD_WAIT_SECS`` instead of waiting it out."""
|
|
token = _BOUNDED_WAIT.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_BOUNDED_WAIT.reset(token)
|
|
|
|
|
|
def _module_load_lock(module_name: str) -> threading.RLock:
|
|
with _MODULE_LOAD_LOCKS_GUARD:
|
|
return _MODULE_LOAD_LOCKS.setdefault(module_name, threading.RLock())
|
|
|
|
|
|
def register_synthetic_package(name: str, search_locations: list[str]) -> None:
|
|
"""Register an empty package shell so ``<name>.<child>`` relative imports resolve."""
|
|
if name in sys.modules:
|
|
return
|
|
spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
|
|
spec.submodule_search_locations = search_locations
|
|
sys.modules[name] = importlib.util.module_from_spec(spec)
|
|
|
|
|
|
def user_plugins_dir() -> Optional[Path]:
|
|
"""Return ``$HERMES_HOME/plugins/`` or None if unavailable."""
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
d = get_hermes_home() / "plugins"
|
|
return d if d.is_dir() else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def iter_plugin_dirs(root: Path) -> list[Path]:
|
|
"""Sorted child dirs of *root* that have an ``__init__.py`` (skips ``_``/``.`` names)."""
|
|
if not root.is_dir():
|
|
return []
|
|
dirs: list[Path] = []
|
|
for child in sorted(root.iterdir()):
|
|
if child.name.startswith(("_", ".")):
|
|
continue
|
|
try:
|
|
if child.is_dir() and (child / "__init__.py").exists():
|
|
dirs.append(child)
|
|
except OSError as exc: # one mode-000 / ACL-denied child must not abort the listing
|
|
_log.warning("Skipping unreadable plugin directory %s: %s", child, exc)
|
|
return dirs
|
|
|
|
|
|
def read_plugin_description(plugin_dir: Path) -> str:
|
|
"""Return ``description`` from ``plugin.yaml`` (empty string if absent/unreadable)."""
|
|
try:
|
|
from utils import fast_safe_load
|
|
|
|
with open(plugin_dir / "plugin.yaml", encoding="utf-8-sig") as f:
|
|
meta = fast_safe_load(f) or {}
|
|
return meta.get("description", "")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _new_module(name: str, file: Path, search_locations: Optional[list[str]] = None) -> Optional[Any]:
|
|
"""spec -> module -> sys.modules[name] (NOT executed); None if no spec."""
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, str(file), submodule_search_locations=search_locations)
|
|
if not spec:
|
|
return None
|
|
mod = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = mod
|
|
return mod
|
|
|
|
|
|
def _exec(mod: Any, logger: Optional[logging.Logger] = None) -> bool:
|
|
"""Exec a ``_new_module`` module (None -> False); False + debug-log if it raised. The sys.modules
|
|
entry stays on failure; callers needing a clean retry pop it themselves."""
|
|
if mod is None:
|
|
return False
|
|
try:
|
|
mod.__spec__.loader.exec_module(mod)
|
|
return True
|
|
except Exception as e:
|
|
if logger:
|
|
logger.debug("Failed to exec_module %s: %s", mod.__name__, e)
|
|
return False
|
|
|
|
|
|
def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: tuple[str, ...],
|
|
logger: logging.Logger, synthetic_namespace: Optional[str] = None) -> Optional[Any]:
|
|
"""Import ``plugin_dir/__init__.py`` as *module_name* (reusing sys.modules when loaded).
|
|
Order matters: parents first (relative imports need them), then siblings as ``module_name.<stem>``
|
|
(so ``from ._x import Y`` resolves), then the module. Finally child is bound onto parent and
|
|
siblings onto module — the shape normal imports produce, which monkeypatch relies on."""
|
|
init_file = plugin_dir / "__init__.py"
|
|
if not init_file.exists():
|
|
return None
|
|
if synthetic_namespace: # user code: never imported in-process under plugins.isolation: host
|
|
from hermes_cli.plugin_isolation import in_process_import_refusal
|
|
refusal = in_process_import_refusal(f"plugin {plugin_dir.name!r} (loaded as {module_name})")
|
|
if refusal:
|
|
logger.warning("%s", refusal)
|
|
return None
|
|
# _new_module publishes a package shell before executing it so sibling relative
|
|
# imports work. Serialize the complete load so another thread cannot observe
|
|
# that half-built shell as a loaded plugin.
|
|
# The wait polls for a cross-thread cycle through importlib's module locks; in a cycle, accept the
|
|
# partial module like importlib does. It is bounded only inside bounded_load_wait() (per-turn callers).
|
|
lock, me = _module_load_lock(module_name), threading.get_ident()
|
|
deadline = float("inf") if not _BOUNDED_WAIT.get() else time.monotonic() + (
|
|
0 if module_name in _STALLED_LOADS else _CONCURRENT_LOAD_WAIT_SECS)
|
|
_LOAD_WAITERS[me] = module_name
|
|
try:
|
|
while not lock.acquire(timeout=0.05):
|
|
if _load_would_deadlock(me):
|
|
logger.debug("Concurrent circular load of %s; using the partial module", module_name)
|
|
return sys.modules.get(module_name)
|
|
if time.monotonic() >= deadline:
|
|
_STALLED_LOADS.add(module_name)
|
|
logger.warning("Skipping plugin %s: another thread's load of it has not finished "
|
|
"(import still running after %.0fs)", module_name, _CONCURRENT_LOAD_WAIT_SECS)
|
|
return None
|
|
finally:
|
|
_LOAD_WAITERS.pop(me, None)
|
|
outermost = module_name not in _LOAD_OWNERS # the RLock re-enters on this thread's recursive loads
|
|
_LOAD_OWNERS.setdefault(module_name, me)
|
|
try:
|
|
return _load_plugin_module_locked(module_name, plugin_dir, init_file, parents, logger,
|
|
synthetic_namespace)
|
|
finally:
|
|
if outermost:
|
|
_LOAD_OWNERS.pop(module_name, None)
|
|
_STALLED_LOADS.discard(module_name)
|
|
lock.release()
|
|
|
|
|
|
def _load_plugin_module_locked(module_name: str, plugin_dir: Path, init_file: Path,
|
|
parents: tuple[str, ...], logger: logging.Logger,
|
|
synthetic_namespace: Optional[str]) -> Optional[Any]:
|
|
# A synthetic package shell has no __file__; only reuse modules loaded from disk.
|
|
cached = sys.modules.get(module_name)
|
|
if cached is not None and getattr(cached, "__file__", None):
|
|
return cached
|
|
for parent in parents:
|
|
parent_path = _PLUGINS_ROOT.joinpath(*parent.split(".")[1:])
|
|
if parent not in sys.modules and (parent_path / "__init__.py").exists():
|
|
_exec(_new_module(parent, parent_path / "__init__.py", [str(parent_path)]))
|
|
if synthetic_namespace:
|
|
register_synthetic_package(synthetic_namespace, [])
|
|
# Reserve the name before siblings exec so their relative imports resolve.
|
|
mod = _new_module(module_name, init_file, [str(plugin_dir)])
|
|
if mod is None:
|
|
return None
|
|
loaded_submodules = []
|
|
for sub_file in plugin_dir.glob("*.py"):
|
|
full_sub_name = f"{module_name}.{sub_file.stem}"
|
|
if sub_file.name == "__init__.py" or full_sub_name in sys.modules:
|
|
continue
|
|
sub_mod = _new_module(full_sub_name, sub_file)
|
|
if _exec(sub_mod, logger):
|
|
loaded_submodules.append((sub_file.stem, sub_mod))
|
|
else:
|
|
sys.modules.pop(full_sub_name, None)
|
|
if not _exec(mod, logger):
|
|
sys.modules.pop(module_name, None)
|
|
return None
|
|
parent_name, child_name = module_name.rsplit(".", 1)
|
|
parent_mod = sys.modules.get(parent_name)
|
|
if parent_mod is not None:
|
|
setattr(parent_mod, child_name, mod)
|
|
for sub_name, sub_mod in loaded_submodules:
|
|
setattr(mod, sub_name, sub_mod)
|
|
return mod
|
|
|
|
|
|
class NoopPluginContext:
|
|
"""Base for fake ``register(ctx)`` contexts: no-op registrations except the one a subclass overrides."""
|
|
|
|
def _noop(self, *args, **kwargs):
|
|
pass
|
|
|
|
register_tool = register_hook = register_cli_command = register_memory_provider = _noop
|
|
|
|
|
|
def instance_from_module(mod: Any, *, collector: Any, collected_attr: str, base_cls: type, name: str,
|
|
logger: logging.Logger) -> Optional[Any]:
|
|
"""Extract the provider instance: ``register(ctx)`` first, then any ``base_cls`` subclass."""
|
|
if hasattr(mod, "register"):
|
|
try:
|
|
mod.register(collector)
|
|
instance = getattr(collector, collected_attr)
|
|
if instance:
|
|
return instance
|
|
except Exception as e:
|
|
logger.debug("register() failed for %s: %s", name, e)
|
|
for attr_name in dir(mod):
|
|
attr = getattr(mod, attr_name, None)
|
|
if isinstance(attr, type) and issubclass(attr, base_cls) and attr is not base_cls:
|
|
with contextlib.suppress(Exception):
|
|
return attr()
|
|
return None
|
|
|
|
|
|
def load_named(name: str, plugin_dir: Path, load_from_dir: Callable[[Path], Optional[Any]], *, kind: str,
|
|
noun: str, logger: logging.Logger) -> Optional[Any]:
|
|
"""Shared body of ``load_<kind>(name)``: load from *plugin_dir*, warn + None on failure."""
|
|
try:
|
|
instance = load_from_dir(plugin_dir)
|
|
except Exception as e:
|
|
logger.warning("Failed to load %s '%s': %s", kind.lower(), name, e)
|
|
return None
|
|
if not instance:
|
|
logger.warning("%s '%s' loaded but no %s instance found", kind, name, noun)
|
|
return instance or None
|
|
|
|
|
|
def probe_availability(load: Callable[[], Optional[Any]]) -> bool:
|
|
"""True iff *load()* returns an instance whose ``is_available()`` (if any) is truthy."""
|
|
try:
|
|
instance = load()
|
|
return instance is not None and (instance.is_available() if hasattr(instance, "is_available") else True)
|
|
except Exception:
|
|
return False
|