Files
hermes-agent/hermes_cli/plugin_host_profiles.py
2026-10-08 17:22:54 +02:00

154 lines
7.1 KiB
Python

"""Model-provider plugins under ``plugins.isolation: host``.
Model-provider discovery runs while ``hermes_cli.config`` / ``hermes_cli.auth`` are still importing,
so it must not start the plugin host (building the host's environment imports those same modules).
Profile DATA therefore comes from a one-shot, credential-free extraction
(``plugin_host_child --extract-profiles``), cached under the profile home by the plugin's file
fingerprint. Overridden methods and callable fields run later in the profile's plugin host, addressed
by (plugin dir, profile name, attribute) so they survive host restarts.
"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Callable, Dict, List
from hermes_cli.plugin_host_wire import PluginHostUnavailable, decode, signature_from
logger = logging.getLogger(__name__)
_EXTRACT_TIMEOUT_SECS = 60.0
_CACHE_VERSION = 1
def load_hosted_profiles(plugin_dir: Path, module_name: str) -> list[Any]:
"""ProviderProfile proxies for a model-provider plugin whose code runs in the plugin host."""
from providers.base import ProviderProfile
payload = _cached_extraction(Path(plugin_dir), module_name)
if payload.get("error"):
raise PluginHostUnavailable(str(payload["error"]))
return [_profile_proxy(ProviderProfile, str(plugin_dir), module_name, entry)
for entry in payload.get("profiles") or []]
def _fingerprint(plugin_dir: Path) -> str:
digest = hashlib.sha256(f"{_CACHE_VERSION}|{sys.executable}".encode())
try:
from hermes_cli import __version__
digest.update(str(__version__).encode())
except Exception:
pass
for path in sorted(plugin_dir.rglob("*")):
if not path.is_file() or "__pycache__" in path.parts or ".git" in path.parts:
continue
stat = path.stat()
digest.update(f"{path.relative_to(plugin_dir)}|{stat.st_size}|{stat.st_mtime_ns}".encode())
return digest.hexdigest()
def _cached_extraction(plugin_dir: Path, module_name: str) -> dict[str, Any]:
from hermes_constants import get_hermes_home
# Keyed by the full path too: a user and a project plugin may share a directory name.
path_key = hashlib.sha256(str(plugin_dir.resolve()).encode("utf-8")).hexdigest()[:12]
cache = get_hermes_home() / "cache" / "plugin_host" / "model-providers" / f"{plugin_dir.name}-{path_key}.json"
fingerprint = _fingerprint(plugin_dir)
try:
cached = json.loads(cache.read_text(encoding="utf-8-sig"))
if cached.get("fingerprint") == fingerprint and cached.get("module_name") == module_name:
return cached["payload"]
except (OSError, ValueError, KeyError):
pass
payload = _extract(plugin_dir, module_name)
if not payload.get("error"):
cache.parent.mkdir(parents=True, exist_ok=True)
tmp = cache.with_suffix(f".{os.getpid()}.tmp")
tmp.write_text(json.dumps({"fingerprint": fingerprint, "module_name": module_name,
"payload": payload}), encoding="utf-8")
os.replace(tmp, cache)
return payload
def _extract(plugin_dir: Path, module_name: str) -> dict[str, Any]:
"""Import the plugin in a throwaway host process with no credentials and read its profiles."""
from hermes_cli.plugin_isolation import HOST_PROCESS_ENV, host_launcher
from hermes_constants import get_hermes_home
repo_root = str(Path(__file__).resolve().parents[1])
env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL", "TZ", "SYSTEMROOT") if key in os.environ}
# Windows resolves Path.home() from USERPROFILE, never HOME: without it the child cannot start.
env.update(HOME=str(Path.home()), USERPROFILE=str(Path.home()), HERMES_HOME=str(get_hermes_home()),
PYTHONPATH=repo_root,
**{HOST_PROCESS_ENV: "1"})
argv = [*host_launcher(), sys.executable, "-m", "hermes_cli.plugin_host_child", "--extract-profiles",
str(plugin_dir), module_name]
try:
done = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, env=env,
timeout=_EXTRACT_TIMEOUT_SECS, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"error": f"profile extraction failed: {exc}"}
try:
return json.loads(done.stdout.decode("utf-8"))
except ValueError:
tail = done.stderr.decode("utf-8", "replace").strip().splitlines()[-3:]
return {"error": f"profile extraction exited {done.returncode}: {' | '.join(tail)}"}
def _host_call(plugin_dir: str, module_name: str, profile: str, attr: str) -> Callable[..., Any]:
def call(*args: Any, **kwargs: Any) -> Any:
from hermes_cli.plugin_isolation import user_plugin_host
host = user_plugin_host()
if host is None:
raise PluginHostUnavailable("plugins.isolation is no longer 'host'; restart Hermes")
return host.profile_call(plugin_dir, module_name, profile, attr, args, kwargs)
return call
def _profile_proxy(base: type, plugin_dir: str, module_name: str, entry: dict[str, Any]) -> Any:
name = str(entry["name"])
namespace: dict[str, Any] = {"__module__": __name__,
"__repr__": lambda self_: f"<plugin-host profile {name!r}>"}
fields = {key: decode(value) for key, value in (entry.get("fields") or {}).items()}
for attr, meta in (entry.get("calls") or {}).items():
call = _host_call(plugin_dir, module_name, name, attr)
if meta.get("async"):
sync_call = call
async def call(*args: Any, _sync: Callable[..., Any] = sync_call, **kwargs: Any) -> Any:
return await asyncio.to_thread(_sync, *args, **kwargs)
signature = signature_from(meta.get("sig"))
if signature is not None:
call.__signature__ = signature # type: ignore[attr-defined]
call.__name__ = call.__qualname__ = attr
if meta.get("field"):
fields[attr] = call
else:
namespace[attr] = _as_method(call)
cls = type(f"Hosted{entry.get('type') or base.__name__}", (base,), namespace)
profile = object.__new__(cls)
for key, value in fields.items():
object.__setattr__(profile, key, value)
return profile
def _as_method(call: Callable[..., Any]) -> Callable[..., Any]:
if asyncio.iscoroutinefunction(call):
async def method(self_: Any, *args: Any, **kwargs: Any) -> Any:
return await call(*args, **kwargs)
else:
def method(self_: Any, *args: Any, **kwargs: Any) -> Any:
return call(*args, **kwargs)
method.__name__ = method.__qualname__ = call.__name__
signature = getattr(call, "__signature__", None)
if signature is not None: # callers introspect accepted kwargs; bound access drops ``self`` again
self_param = inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)
method.__signature__ = signature.replace( # type: ignore[attr-defined]
parameters=[self_param, *signature.parameters.values()])
return method