Independent review of the plugin host found eight defects; each is fixed here and the reproducible ones are covered by two new invariant tests (red on the previous head, green now). - Async plugin code: `await ctx.<facade>.<async method>` works (the facade probe reports coroutine methods and the host returns a coroutine function), and ctx calls made from coroutines keep the caller's session. The wire origin is now a ContextVar carried onto host event-loop tasks, and the host's fallback context starts empty instead of copying whichever caller created the host. - Memory / context-engine / cron providers survive a host crash: proxies record the host generation, and a category proxy reloads its plugin into the new host on next use (bounded by the same restart budget). A proxy from an earlier host can no longer address a recycled object id. - Hosted provider objects no longer leak: the host imports a category plugin's module once and drops objects whose Hermes proxies were collected. - Every public attribute of a hosted object is read live (class-level defaults included), and attributes Hermes assigns reach the plugin's object unless they hold a live handle. - A user memory provider's config_schema.py runs in the plugin host. - Dashboard plugin APIs resolve the plugin in the requesting profile (its copy, its plugins.enabled, its host) and keep repeated headers such as Set-Cookie. - platform_actions coroutines run on the gateway loop their adapters use. - Each incoming request gets its own thread (no pool exhaustion under nested callbacks); a late close from a replaced host no longer triggers a reload; the model-provider extraction cache is keyed by full path.
586 lines
28 KiB
Python
586 lines
28 KiB
Python
"""The plugin host process: imports third-party plugins and serves their callbacks to Hermes.
|
|
|
|
Started by :class:`hermes_cli.plugin_host.PluginHost` as ``python -m hermes_cli.plugin_host_child``
|
|
(optionally under ``plugins.host.launcher``, e.g. a sandbox runner) with the profile's environment.
|
|
Plugins get a :class:`RemotePluginContext` that looks like ``PluginContext``: registrations and
|
|
queries are forwarded to Hermes, callables and provider objects stay here and are invoked by
|
|
reference. Nothing in this module is imported by the Hermes process itself.
|
|
|
|
stdin/stdout carry the protocol, so the real file descriptors are duplicated for the channel and
|
|
fd 0/1 are pointed at /dev/null and stderr before any plugin code runs: a stray ``print()`` in a
|
|
plugin lands in the host log instead of corrupting the wire.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
import importlib
|
|
import importlib.metadata
|
|
import importlib.util
|
|
import inspect
|
|
import itertools
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
import types
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from hermes_cli.plugin_host_wire import (
|
|
Channel, Opaque, PROTOCOL_VERSION, PluginHostUnsupported, bind_serving_request, decode, describe_signature,
|
|
encode, is_async_callable, serving_request,
|
|
)
|
|
|
|
logger = logging.getLogger("hermes_cli.plugin_host_child")
|
|
|
|
_ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
|
|
|
|
|
class RemoteRegistration:
|
|
"""Child-side twin of ``PluginRegistration``: ``dispose()`` releases the Hermes-side entry."""
|
|
|
|
def __init__(self, runtime: "HostRuntime", handle_id: int, kind: str, key: str):
|
|
self._runtime, self._id, self.kind, self.key = runtime, handle_id, kind, key
|
|
self._disposed = False
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
return not self._disposed
|
|
|
|
def dispose(self) -> None:
|
|
if self._disposed:
|
|
return
|
|
self._disposed = True
|
|
self._runtime.channel.call("dispose", {"handle": self._id})
|
|
|
|
|
|
class RemoteFacade:
|
|
"""``ctx.state`` / ``ctx.llm`` / ...: every method call runs on the Hermes-side facade."""
|
|
|
|
def __init__(self, runtime: "HostRuntime", plugin_key: str, name: str):
|
|
self._runtime, self._plugin_key, self._name = runtime, plugin_key, name
|
|
self._methods: Dict[str, bool] = {} # method name -> is a coroutine function in Hermes
|
|
|
|
def __getattr__(self, attr: str) -> Any:
|
|
if attr.startswith("_"):
|
|
raise AttributeError(attr)
|
|
if attr not in self._methods:
|
|
value = self._runtime.facade_call(self._plugin_key, self._name, attr, _probe=True)
|
|
if not (isinstance(value, dict) and value.get("__method__")):
|
|
return value # a plain attribute (``ctx.state.data_dir``): read fresh every time
|
|
self._methods[attr] = bool(value.get("async"))
|
|
call = functools.partial(self._runtime.facade_call, self._plugin_key, self._name, attr)
|
|
if not self._methods[attr]:
|
|
return call
|
|
|
|
async def remote(*args: Any, **kwargs: Any) -> Any: # ``await ctx.llm.acomplete(...)``
|
|
return await asyncio.to_thread(call, *args, **kwargs)
|
|
|
|
remote.__name__ = attr
|
|
return remote
|
|
|
|
|
|
class RemotePluginContext:
|
|
"""What ``register(ctx)`` receives inside the host. Mirrors ``PluginContext``'s public surface as
|
|
announced by Hermes at load, so ``hasattr(ctx, "register_x")`` answers the same as in-process."""
|
|
|
|
def __init__(self, runtime: "HostRuntime", plugin_key: str, info: Dict[str, Any]):
|
|
self._runtime, self._plugin_key = runtime, plugin_key
|
|
self._methods = set(info.get("ctx_methods") or ())
|
|
self._facades: Dict[str, RemoteFacade] = {}
|
|
self.manifest = types.SimpleNamespace(**(info.get("manifest") or {}))
|
|
self.plugin_id = info.get("plugin_id") or plugin_key
|
|
self.profile_name = info.get("profile_name")
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
if name.startswith("_"):
|
|
raise AttributeError(name)
|
|
from hermes_cli.plugin_isolation import (
|
|
HOST_REMOTE_FACADES, HOST_SKIPPED_CTX_METHODS, HOST_UNSUPPORTED_CTX_METHODS,
|
|
)
|
|
if name in HOST_UNSUPPORTED_CTX_METHODS:
|
|
def unsupported(*_args: Any, **_kwargs: Any) -> Any:
|
|
raise PluginHostUnsupported(
|
|
f"ctx.{name}() cannot run in the plugin host: {HOST_UNSUPPORTED_CTX_METHODS[name]}")
|
|
return unsupported
|
|
if name in HOST_SKIPPED_CTX_METHODS:
|
|
def skipped(*_args: Any, **_kwargs: Any) -> None:
|
|
logger.warning("Plugin '%s': ctx.%s() is skipped in the plugin host (%s)",
|
|
self.manifest.name, name, HOST_SKIPPED_CTX_METHODS[name])
|
|
return None
|
|
return skipped
|
|
if name in HOST_REMOTE_FACADES:
|
|
return self._facades.setdefault(name, RemoteFacade(self._runtime, self._plugin_key, name))
|
|
if name not in self._methods:
|
|
raise AttributeError(f"'PluginContext' object has no attribute {name!r}")
|
|
return functools.partial(self._runtime.ctx_call, self._plugin_key, name)
|
|
|
|
def on_unload(self, callback: Callable[[], Any]) -> None:
|
|
if not callable(callback):
|
|
raise TypeError("on_unload() callback must be callable")
|
|
self._runtime.unload_callbacks.setdefault(self._plugin_key, []).append(callback)
|
|
|
|
def spawn_task(self, coro: Any, *, name: Optional[str] = None) -> Any:
|
|
return self._runtime.spawn(coro, name=name)
|
|
|
|
|
|
class HostRuntime:
|
|
"""Reference tables, the asyncio loop for async plugin code, and the request handler."""
|
|
|
|
def __init__(self, reader, writer):
|
|
self.refs: Dict[int, Any] = {}
|
|
self.owners: Dict[int, str] = {}
|
|
self._ids = itertools.count(1)
|
|
self._lock = threading.Lock()
|
|
self.unload_callbacks: Dict[str, List[Callable[[], Any]]] = {}
|
|
self.modules: Dict[str, str] = {}
|
|
self.asgi_apps: Dict[str, Any] = {}
|
|
self.profiles: Dict[str, Dict[str, Any]] = {}
|
|
self.instance_modules: Dict[str, Any] = {}
|
|
self.stopped = threading.Event()
|
|
self.loop = asyncio.new_event_loop()
|
|
threading.Thread(target=self.loop.run_forever, name="plugin-host-loop", daemon=True).start()
|
|
self.channel = Channel(reader, writer, self.handle, name="plugin-host",
|
|
on_close=lambda _reason: self.stopped.set())
|
|
|
|
# -- references -----------------------------------------------------------------------------
|
|
def _remember(self, plugin_key: str, value: Any) -> int:
|
|
with self._lock:
|
|
ref = next(self._ids)
|
|
self.refs[ref] = value
|
|
self.owners[ref] = plugin_key
|
|
return ref
|
|
|
|
def ref_for(self, plugin_key: str, value: Any, *, allow_objects: bool) -> Optional[dict]:
|
|
if isinstance(value, type):
|
|
return None
|
|
if callable(value) and not (allow_objects and _is_provider_object(value)):
|
|
return {"__callable__": self._remember(plugin_key, value), "async": is_async_callable(value),
|
|
"sig": describe_signature(value), "name": getattr(value, "__name__", "callback"),
|
|
"qualname": getattr(value, "__qualname__", None),
|
|
"module": getattr(value, "__module__", None)}
|
|
if allow_objects and _is_provider_object(value):
|
|
return self._describe_object(plugin_key, value)
|
|
return None
|
|
|
|
def _describe_object(self, plugin_key: str, obj: Any) -> dict:
|
|
"""Method table + attribute plan for a provider object; every method runs here, in the host."""
|
|
methods: Dict[str, Any] = {}
|
|
instance = sorted(k for k in vars(obj) if not k.startswith("_")) if hasattr(obj, "__dict__") else []
|
|
live: List[str] = list(instance)
|
|
for name in dir(obj):
|
|
if name.startswith("_") or name in instance:
|
|
continue
|
|
raw = inspect.getattr_static(obj, name, None)
|
|
if isinstance(raw, property) or type(raw).__name__ == "cached_property":
|
|
live.append(name)
|
|
continue
|
|
value = getattr(obj, name, None)
|
|
if callable(value):
|
|
methods[name] = {"async": is_async_callable(value), "sig": describe_signature(value)}
|
|
else:
|
|
# Class-level defaults too: an engine that sets ``context_length`` only later in
|
|
# ``update_model()`` must not read as the base-class default forever in Hermes.
|
|
live.append(name)
|
|
return {"__object__": self._remember(plugin_key, obj), "methods": methods, "live": live,
|
|
"type": type(obj).__name__}
|
|
|
|
def resolve_from_parent(self, ref: dict) -> Any:
|
|
if "__handle__" in ref:
|
|
return RemoteRegistration(self, int(ref["__handle__"]), str(ref.get("kind") or ""),
|
|
str(ref.get("key") or ""))
|
|
return Opaque("callable" if "__callable__" in ref else "object")
|
|
|
|
# -- outgoing ---------------------------------------------------------------------------------
|
|
def _send_call(self, method: str, plugin_key: str, payload: Dict[str, Any], *, allow_objects: bool) -> Any:
|
|
refs = functools.partial(self.ref_for, plugin_key, allow_objects=allow_objects)
|
|
payload = {**payload, "plugin": plugin_key,
|
|
"args": encode(list(payload.pop("args", ())), refs),
|
|
"kwargs": encode(dict(payload.pop("kwargs", {})), refs)}
|
|
return decode(self.channel.call(method, payload), self.resolve_from_parent)
|
|
|
|
def ctx_call(self, plugin_key: str, method: str, *args: Any, **kwargs: Any) -> Any:
|
|
from hermes_cli.plugin_isolation import HOST_OBJECT_BASES
|
|
return self._send_call("ctx", plugin_key, {"method": method, "args": args, "kwargs": kwargs},
|
|
allow_objects=method in HOST_OBJECT_BASES)
|
|
|
|
def facade_call(self, plugin_key: str, facade: str, method: str, *args: Any, _probe: bool = False,
|
|
**kwargs: Any) -> Any:
|
|
return self._send_call("facade", plugin_key, {"facade": facade, "method": method, "probe": _probe,
|
|
"args": args, "kwargs": kwargs}, allow_objects=False)
|
|
|
|
def spawn(self, coro: Any, *, name: Optional[str] = None) -> Any:
|
|
if not inspect.iscoroutine(coro):
|
|
raise TypeError("spawn_task() requires a coroutine object")
|
|
return asyncio.run_coroutine_threadsafe(_on_behalf_of(coro, serving_request()), self.loop)
|
|
|
|
# -- incoming ---------------------------------------------------------------------------------
|
|
def handle(self, method: str, params: Dict[str, Any], _origin: Optional[int]) -> Any:
|
|
handler = _HANDLERS.get(method)
|
|
if handler is None:
|
|
raise ValueError(f"unknown plugin host request {method!r}")
|
|
return handler(self, params)
|
|
|
|
def _run(self, result: Any) -> Any:
|
|
"""Await plugin coroutines on the host loop, still attributed to the request being served
|
|
(their ``ctx`` calls then run in that caller's session, not a bare context)."""
|
|
if inspect.isawaitable(result):
|
|
return asyncio.run_coroutine_threadsafe(_on_behalf_of(result, serving_request()), self.loop).result()
|
|
return result
|
|
|
|
def _encode_result(self, plugin_key: str, value: Any) -> Any:
|
|
return encode(value, functools.partial(self.ref_for, plugin_key, allow_objects=False))
|
|
|
|
def _decode_args(self, params: Dict[str, Any]):
|
|
return decode(params.get("args") or [], None), decode(params.get("kwargs") or {}, None)
|
|
|
|
def op_hello(self, _params: Dict[str, Any]) -> Dict[str, Any]:
|
|
return {"protocol": PROTOCOL_VERSION, "pid": os.getpid(), "python": sys.version.split()[0]}
|
|
|
|
def op_load(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
plugin_key = str(params["plugin_key"])
|
|
target = _import_plugin(params)
|
|
register = target if callable(target) and not isinstance(target, types.ModuleType) \
|
|
else getattr(target, "register", None)
|
|
if not callable(register):
|
|
raise AttributeError(f"Plugin '{params.get('name')}' has no register() function")
|
|
module_name = getattr(target, "__name__", None) or getattr(register, "__module__", None)
|
|
self.modules[plugin_key] = str(module_name or "")
|
|
self._run(register(RemotePluginContext(self, plugin_key, params)))
|
|
return {"module": module_name}
|
|
|
|
def op_load_instance(self, params: Dict[str, Any]) -> Any:
|
|
"""Category plugins (memory provider, context engine, cron scheduler): import the directory,
|
|
run ``register(ctx)`` capturing the one ``capture`` registration, else instantiate the first
|
|
subclass of ``base``. Other registrations reach Hermes only when it bound a ctx for them."""
|
|
plugin_key = str(params["plugin_key"])
|
|
# Hermes asks for a fresh instance many times (agent cache, doctor, dashboard); like the
|
|
# in-process loader, the module body runs once per host and only the instance is new.
|
|
module = self.instance_modules.get(str(params["path"]))
|
|
if module is None:
|
|
module = self.instance_modules[str(params["path"])] = _import_plugin(params)
|
|
self.modules[plugin_key] = module.__name__
|
|
base = _import_base(str(params["base"]))
|
|
capture = str(params["capture"])
|
|
captured: List[Any] = []
|
|
register = getattr(module, "register", None)
|
|
if callable(register):
|
|
ctx = _CapturingContext(self, plugin_key, params, capture, base, captured,
|
|
forward=bool(params.get("forward")))
|
|
try:
|
|
self._run(register(ctx))
|
|
except Exception as exc:
|
|
if not captured:
|
|
logger.debug("register() failed for %s: %s", params.get("name"), exc)
|
|
if not captured:
|
|
for attr in dir(module):
|
|
value = getattr(module, attr, None)
|
|
if isinstance(value, type) and issubclass(value, base) and value is not base:
|
|
try:
|
|
captured.append(value())
|
|
break
|
|
except Exception:
|
|
continue
|
|
if not captured:
|
|
return None
|
|
return self._describe_object(plugin_key, captured[0])
|
|
|
|
def op_profile_call(self, params: Dict[str, Any]) -> Any:
|
|
"""Run one overridden method (or callable field) of a model-provider profile, loading the
|
|
plugin on first use in this host process. Addressed by name, so it survives host restarts."""
|
|
profiles = self.profiles.get(str(params["path"]))
|
|
if profiles is None:
|
|
profiles = self.profiles[str(params["path"])] = {
|
|
p.name: p for p in capture_profiles(str(params["path"]), str(params["module_name"]))}
|
|
target = getattr(profiles[str(params["profile"])], str(params["attr"]))
|
|
args, kwargs = self._decode_args(params)
|
|
return encode(self._run(target(*args, **kwargs)))
|
|
|
|
def op_asgi(self, params: Dict[str, Any]) -> Any:
|
|
"""Serve one dashboard API request with the plugin's FastAPI ``router`` (loaded once)."""
|
|
api_path = Path(str(params["dashboard_dir"])) / str(params["api_file"])
|
|
app = self.asgi_apps.get(str(api_path))
|
|
if app is None:
|
|
from fastapi import FastAPI
|
|
module_name = f"hermes_dashboard_plugin_{params['plugin']}"
|
|
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"cannot load dashboard api {api_path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[module_name] = module
|
|
spec.loader.exec_module(module)
|
|
router = getattr(module, "router", None)
|
|
if router is None:
|
|
raise AttributeError(f"dashboard api {api_path.name} has no 'router'")
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
self.asgi_apps[str(api_path)] = app
|
|
|
|
async def request() -> Dict[str, Any]:
|
|
import httpx
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://plugin-host") as client:
|
|
response = await client.request(
|
|
str(params["method"]), str(params["path"]), params=str(params.get("query") or ""),
|
|
headers=[tuple(h) for h in params.get("headers") or []],
|
|
content=decode(params.get("body")) or b"")
|
|
return {"status": response.status_code, "headers": list(response.headers.multi_items()),
|
|
"body": response.content}
|
|
|
|
return encode(self._run(request()))
|
|
|
|
def op_invoke(self, params: Dict[str, Any]) -> Any:
|
|
ref = int(params["ref"])
|
|
fn = self.refs.get(ref)
|
|
if fn is None:
|
|
raise LookupError(f"plugin host callable {ref} was released")
|
|
args, kwargs = self._decode_args(params)
|
|
return self._encode_result(self.owners.get(ref, ""), self._run(fn(*args, **kwargs)))
|
|
|
|
def op_obj_invoke(self, params: Dict[str, Any]) -> Any:
|
|
ref = int(params["ref"])
|
|
obj = self.refs.get(ref)
|
|
if obj is None:
|
|
raise LookupError(f"plugin host object {ref} was released")
|
|
method = str(params["method"])
|
|
if method.startswith("_"):
|
|
raise AttributeError(method)
|
|
args, kwargs = self._decode_args(params)
|
|
return self._encode_result(self.owners.get(ref, ""), self._run(getattr(obj, method)(*args, **kwargs)))
|
|
|
|
def op_obj_getattr(self, params: Dict[str, Any]) -> Any:
|
|
obj = self.refs.get(int(params["ref"]))
|
|
name = str(params["name"])
|
|
if obj is None:
|
|
raise LookupError(f"plugin host object {params['ref']} was released")
|
|
if name.startswith("_") or not hasattr(obj, name):
|
|
return {"__missing__": name}
|
|
return self._encode_result(self.owners.get(int(params["ref"]), ""), getattr(obj, name))
|
|
|
|
def op_release(self, params: Dict[str, Any]) -> None:
|
|
"""Drop objects whose Hermes-side proxies were garbage-collected."""
|
|
with self._lock:
|
|
for ref in params.get("refs") or ():
|
|
self.refs.pop(int(ref), None)
|
|
self.owners.pop(int(ref), None)
|
|
|
|
def op_config_schema(self, params: Dict[str, Any]) -> Any:
|
|
"""A memory provider's ``config_schema.py`` ``CONFIG_SCHEMA`` (user code: it runs here)."""
|
|
path = Path(str(params["path"]))
|
|
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{path.parent.name}", path)
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"cannot load {path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return encode(getattr(module, "CONFIG_SCHEMA", None))
|
|
|
|
def op_obj_setattr(self, params: Dict[str, Any]) -> None:
|
|
obj = self.refs.get(int(params["ref"]))
|
|
name = str(params["name"])
|
|
if obj is None or name.startswith("_"):
|
|
raise AttributeError(name)
|
|
setattr(obj, name, decode(params.get("value"), None))
|
|
|
|
def op_unload(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
plugin_key = str(params["plugin_key"])
|
|
errors = []
|
|
for callback in reversed(self.unload_callbacks.pop(plugin_key, [])):
|
|
try:
|
|
self._run(callback())
|
|
except Exception as exc:
|
|
errors.append(f"{type(exc).__name__}: {exc}")
|
|
with self._lock:
|
|
for ref in [r for r, owner in self.owners.items() if owner == plugin_key]:
|
|
self.refs.pop(ref, None)
|
|
self.owners.pop(ref, None)
|
|
module_name = self.modules.pop(plugin_key, "")
|
|
if module_name:
|
|
for name in [m for m in sys.modules if m == module_name or m.startswith(module_name + ".")]:
|
|
sys.modules.pop(name, None)
|
|
return {"errors": errors}
|
|
|
|
def op_shutdown(self, _params: Dict[str, Any]) -> None:
|
|
for plugin_key in list(self.unload_callbacks):
|
|
self.op_unload({"plugin_key": plugin_key})
|
|
self.stopped.set()
|
|
|
|
|
|
_HANDLERS: Dict[str, Callable[[HostRuntime, Dict[str, Any]], Any]] = {
|
|
"hello": HostRuntime.op_hello, "load": HostRuntime.op_load, "load_instance": HostRuntime.op_load_instance,
|
|
"asgi": HostRuntime.op_asgi, "profile_call": HostRuntime.op_profile_call,
|
|
"invoke": HostRuntime.op_invoke,
|
|
"obj_invoke": HostRuntime.op_obj_invoke, "obj_getattr": HostRuntime.op_obj_getattr,
|
|
"obj_setattr": HostRuntime.op_obj_setattr, "unload": HostRuntime.op_unload,
|
|
"release": HostRuntime.op_release, "config_schema": HostRuntime.op_config_schema,
|
|
"shutdown": HostRuntime.op_shutdown,
|
|
}
|
|
|
|
|
|
class _CapturingContext(RemotePluginContext):
|
|
"""``register(ctx)`` for a category plugin: the category's own registration is kept here; the rest
|
|
forward to Hermes when it bound a context (memory providers), else are no-ops."""
|
|
|
|
def __init__(self, runtime: HostRuntime, plugin_key: str, info: Dict[str, Any], capture: str,
|
|
base: type, captured: List[Any], *, forward: bool):
|
|
super().__init__(runtime, plugin_key, info)
|
|
self._capture, self._base, self._captured, self._forward = capture, base, captured, forward
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
if name == self._capture:
|
|
def capture(obj: Any, *_args: Any, **_kwargs: Any) -> None:
|
|
if isinstance(obj, self._base) and not self._captured:
|
|
self._captured.append(obj)
|
|
return capture
|
|
if not self._forward and (name.startswith("register_") or name in {"subscribe"}):
|
|
return lambda *_args, **_kwargs: None
|
|
return super().__getattr__(name)
|
|
|
|
|
|
def _import_base(ref: str) -> type:
|
|
module, attr = ref.split(":")
|
|
return getattr(importlib.import_module(module), attr)
|
|
|
|
|
|
def _is_provider_object(value: Any) -> bool:
|
|
"""A plugin-defined instance (provider/engine), as opposed to a function or bound method."""
|
|
return not isinstance(value, (types.FunctionType, types.MethodType, types.BuiltinFunctionType,
|
|
functools.partial, type)) and type(value).__module__ != "builtins"
|
|
|
|
|
|
async def _on_behalf_of(awaitable: Any, origin: Optional[int]) -> Any:
|
|
bind_serving_request(origin) # this task's context only; tasks it spawns inherit it
|
|
return await awaitable
|
|
|
|
|
|
def _import_plugin(params: Dict[str, Any]) -> Any:
|
|
"""Import a directory plugin under the module name Hermes assigned, or resolve an entry point."""
|
|
if params.get("entrypoint"):
|
|
for ep in importlib.metadata.entry_points().select(group=_ENTRY_POINTS_GROUP):
|
|
if ep.name == params.get("name"):
|
|
return ep.load()
|
|
raise ImportError(f"Entry point '{params.get('name')}' not found in group '{_ENTRY_POINTS_GROUP}'")
|
|
plugin_dir = Path(str(params["path"]))
|
|
init_file = plugin_dir / "__init__.py"
|
|
if not init_file.exists():
|
|
raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
|
|
module_name = str(params["module_name"])
|
|
parts = module_name.split(".")[:-1]
|
|
for i in range(1, len(parts) + 1): # synthetic parent packages (hermes_plugins, _hermes_user_memory)
|
|
parent = ".".join(parts[:i])
|
|
if parent not in sys.modules:
|
|
ns_pkg = types.ModuleType(parent)
|
|
ns_pkg.__path__ = [] # type: ignore[attr-defined]
|
|
ns_pkg.__package__ = parent
|
|
sys.modules[parent] = ns_pkg
|
|
spec = importlib.util.spec_from_file_location(module_name, init_file,
|
|
submodule_search_locations=[str(plugin_dir)])
|
|
if spec is None or spec.loader is None:
|
|
raise ImportError(f"Cannot create module spec for {init_file}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
module.__package__ = module_name
|
|
module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
|
sys.modules[module_name] = module
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except BaseException:
|
|
for name in [m for m in sys.modules if m == module_name or m.startswith(module_name + ".")]:
|
|
sys.modules.pop(name, None)
|
|
raise
|
|
return module
|
|
|
|
|
|
def _take_protocol_streams():
|
|
"""Duplicate stdin/stdout for the channel, then point fd 0 at /dev/null and fd 1 at stderr."""
|
|
reader = os.fdopen(os.dup(0), "rb") # windows-footgun: ok — binary protocol stream
|
|
writer = os.fdopen(os.dup(1), "wb") # windows-footgun: ok — binary protocol stream
|
|
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
os.dup2(devnull, 0)
|
|
os.close(devnull)
|
|
os.dup2(2, 1)
|
|
sys.stdin = open(os.devnull, encoding="utf-8-sig") # noqa: SIM115 — lives for the process
|
|
sys.stdout = sys.stderr
|
|
return reader, writer
|
|
|
|
|
|
def capture_profiles(path: str, module_name: str) -> List[Any]:
|
|
"""Import a model-provider plugin and return the profiles it passes to ``register_provider``."""
|
|
import providers
|
|
from providers.base import ProviderProfile
|
|
providers.list_providers() # bundled + known profiles register before the capture window
|
|
captured: List[Any] = []
|
|
original = providers.register_provider
|
|
providers.register_provider = captured.append # type: ignore[assignment]
|
|
try:
|
|
_import_plugin({"path": path, "module_name": module_name, "name": Path(path).name})
|
|
finally:
|
|
providers.register_provider = original # type: ignore[assignment]
|
|
for profile in captured:
|
|
if type(profile).create_client is not ProviderProfile.create_client:
|
|
raise PluginHostUnsupported(
|
|
f"provider profile {getattr(profile, 'name', '?')!r} overrides create_client(), which returns "
|
|
"a live SDK client; it runs only in-process")
|
|
return captured
|
|
|
|
|
|
def describe_profile(profile: Any) -> Dict[str, Any]:
|
|
"""Profile data (instance fields) plus the methods and callable fields that must run here."""
|
|
from providers.base import ProviderProfile
|
|
fields: Dict[str, Any] = {}
|
|
calls: Dict[str, Any] = {}
|
|
for name, value in vars(profile).items():
|
|
if name.startswith("_"):
|
|
continue
|
|
if callable(value):
|
|
calls[name] = {"async": is_async_callable(value), "sig": describe_signature(value), "field": True}
|
|
else:
|
|
fields[name] = encode(value)
|
|
for name in dir(type(profile)):
|
|
if name.startswith("_") or name in fields or name in calls:
|
|
continue
|
|
attr = getattr(type(profile), name)
|
|
if attr is getattr(ProviderProfile, name, object()):
|
|
continue
|
|
if isinstance(inspect.getattr_static(profile, name), property) or not callable(attr):
|
|
fields[name] = encode(getattr(profile, name))
|
|
continue
|
|
calls[name] = {"async": is_async_callable(attr), "sig": describe_signature(getattr(profile, name)),
|
|
"field": False}
|
|
return {"name": profile.name, "type": type(profile).__name__, "fields": fields, "calls": calls}
|
|
|
|
|
|
def extract_profiles_main(path: str, module_name: str) -> int:
|
|
"""``--extract-profiles``: one-shot, credential-free; prints the profiles as JSON on stdout."""
|
|
import json
|
|
reader, writer = _take_protocol_streams()
|
|
del reader
|
|
import hermes_bootstrap # noqa: F401
|
|
try:
|
|
payload = {"profiles": [describe_profile(p) for p in capture_profiles(path, module_name)]}
|
|
except Exception as exc:
|
|
payload = {"error": f"{type(exc).__name__}: {exc}"}
|
|
writer.write(json.dumps(payload).encode("utf-8"))
|
|
writer.flush()
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
reader, writer = _take_protocol_streams()
|
|
import hermes_bootstrap # noqa: F401 — PM dependency environments, like every Hermes entry point
|
|
logging.basicConfig(stream=sys.stderr, level=os.environ.get("HERMES_PLUGIN_HOST_LOG_LEVEL", "WARNING"),
|
|
format="%(levelname)s %(name)s: %(message)s")
|
|
runtime = HostRuntime(reader, writer)
|
|
runtime.channel.start()
|
|
runtime.stopped.wait()
|
|
runtime.channel.close("host shutdown")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
code = (extract_profiles_main(sys.argv[2], sys.argv[3]) if sys.argv[1:2] == ["--extract-profiles"]
|
|
else main())
|
|
sys.stderr.flush()
|
|
# A plugin thread stuck in a handler must not keep the host alive once Hermes has gone.
|
|
os._exit(code)
|