"""Python dependencies declared by user plugins: read, resolve against Hermes' own ranges, install, and re-apply after ``hermes update`` rebuilds the venv. A plugin declares deps in its ``pyproject.toml`` (``[project].dependencies``) or, without one, in ``plugin.yaml`` ``python_dependencies`` (``pip_dependencies`` is an accepted alias). ``python_runtime: external`` in the manifest opts a plugin out: it manages its own interpreter (sidecar venv) and its tree is never handed to the resolver. Contract (agreed with the Mnemosyne team, Sep 2026): the union of every enabled plugin's declarations is resolved together with Hermes' declared ranges; a candidate that has no solution is refused without touching the live venv or disabling anything already installed. After an update, the union is re-applied; if core moved and the union no longer resolves, non-memory plugins are dropped first and disabled with a loud warning, because a Hermes that boots without memory reads as data loss. Lifted in shape from ethernet8023's ``pm/plugin_declarations.py`` / ``pm/workspace.py`` (#102765). """ from __future__ import annotations import logging import re import tomllib from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable, Optional import yaml from hermes_constants import get_hermes_home from packaging.markers import InvalidMarker, UndefinedEnvironmentName from packaging.requirements import InvalidRequirement, Requirement from packaging.utils import canonicalize_name logger = logging.getLogger(__name__) _MANIFEST_FILES = ("plugin.yaml", "plugin.yml") _DEP_KEYS = ("python_dependencies", "pip_dependencies") EXTERNAL_RUNTIME_KEY = "python_runtime" _CORE_DIST = "hermes-agent" # Substrings uv/pip print only when the resolver itself proved there is no solution. Deliberately # narrow: a fetch timeout or index outage must never be misread as a conflict. _CONFLICT_MARKERS = ( "no solution found", "conflicting requirements", "resolutionimpossible", "because only the following versions", ) class DependencyConflict(Exception): """The resolver proved core + enabled plugins + candidate has no valid solution.""" class DependencyInstallError(Exception): """The install failed for a reason other than a proven conflict (network, build, gate).""" @dataclass(frozen=True) class PythonDeclaration: plugin_dir: Path name: str specs: tuple[str, ...] source: str # "pyproject" | "manifest" | "" external: bool = False memory_provider: bool = False @property def wants_install(self) -> bool: return bool(self.specs) and not self.external # ── reading ─────────────────────────────────────────────────────────────────── def _read_manifest(plugin_dir: Path) -> dict: for candidate in _MANIFEST_FILES: path = plugin_dir / candidate if path.is_file(): data = yaml.safe_load(path.read_text(encoding="utf-8-sig")) or {} return data if isinstance(data, dict) else {} return {} def _pyproject_specs(plugin_dir: Path) -> Optional[list[str]]: """``[project].dependencies`` from the plugin's pyproject, or ``None`` when there is none.""" path = plugin_dir / "pyproject.toml" if not path.is_file(): return None document = tomllib.loads(path.read_text(encoding="utf-8-sig")) specs = document.get("project", {}).get("dependencies", []) if not isinstance(specs, list) or any(not isinstance(s, str) for s in specs): raise ValueError(f"{path}: [project].dependencies must be a list of strings") return specs def _manifest_specs(manifest: dict) -> list[str]: specs: list[str] = [] for key in _DEP_KEYS: values = manifest.get(key) or [] if not isinstance(values, list) or any(not isinstance(v, str) for v in values): raise ValueError(f"{key} must be a list of requirement strings") specs.extend(v.strip() for v in values if v.strip()) return list(dict.fromkeys(specs)) def _is_memory_provider(plugin_dir: Path, manifest: dict) -> bool: if str(manifest.get("kind") or "").strip().lower() == "exclusive": return True init = plugin_dir / "__init__.py" if not init.is_file(): return False head = init.read_text(encoding="utf-8", errors="replace")[:8192] return "register_memory_provider" in head or "MemoryProvider" in head def read_declaration(plugin_dir: Path) -> PythonDeclaration: """One effective dependency surface for a plugin directory. A real pyproject owns packaging; the manifest list bridges plugins without one. Raises ``ValueError`` on a malformed declaration.""" plugin_dir = Path(plugin_dir) manifest = _read_manifest(plugin_dir) name = str(manifest.get("name") or plugin_dir.name) external = str(manifest.get(EXTERNAL_RUNTIME_KEY) or "").strip().lower() == "external" pyproject = _pyproject_specs(plugin_dir) specs, source = (pyproject, "pyproject") if pyproject is not None else (_manifest_specs(manifest), "manifest") return PythonDeclaration( plugin_dir=plugin_dir, name=name, specs=tuple(specs), source=source if specs else "", external=external, memory_provider=_is_memory_provider(plugin_dir, manifest)) # ── requirement hygiene ─────────────────────────────────────────────────────── def _parse_requirement(spec: str) -> Requirement: try: return Requirement(spec) except InvalidRequirement as exc: raise ValueError(f"invalid requirement {spec!r}: {exc}") from exc def _marker_applies(req: Requirement) -> bool: try: return req.marker is None or req.marker.evaluate() except (InvalidMarker, UndefinedEnvironmentName) as exc: raise ValueError(f"invalid marker in {req!s}: {exc}") from exc def applicable_requirement(spec: str) -> Optional[str]: """``spec`` reduced to ``name[extras]specifier`` when Hermes should install it here; ``None`` when its environment marker excludes this platform, when it names Hermes itself (always satisfied by the running checkout — installing ``hermes-agent`` from an index would clobber it), or when it points at a URL/path (see :func:`unsupported_specs`; the resolver must only see index packages).""" req = _parse_requirement(spec) if req.url or canonicalize_name(req.name) == _CORE_DIST or not _marker_applies(req): return None extras = f"[{','.join(sorted(req.extras))}]" if req.extras else "" return f"{req.name}{extras}{req.specifier}" def applicable_specs(specs: Iterable[str]) -> list[str]: return [r for r in (applicable_requirement(s) for s in specs) if r] def unsupported_specs(specs: Iterable[str]) -> list[str]: """Direct URL/path requirements: never installed by Hermes (arbitrary git/tarball sources are a supply-chain surface the reviewed pin does not cover); surfaced so the user can install them.""" return [s for s in specs if _parse_requirement(s).url] def core_constraints(project_root: Path) -> list[str]: """Hermes' own declared ranges (``[project].dependencies`` + every extra), as constraint lines. Plugins resolve inside these, so they can move transitives but never a core package out of range.""" document = tomllib.loads((Path(project_root) / "pyproject.toml").read_text(encoding="utf-8")) project = document.get("project", {}) declared: list[str] = list(project.get("dependencies", [])) for group in project.get("optional-dependencies", {}).values(): declared.extend(group) lines = [] for spec in declared: try: reduced = applicable_requirement(spec) except ValueError: continue if reduced and not reduced.lower().startswith(_CORE_DIST): lines.append(_strip_extras(reduced)) return sorted(set(lines)) def _strip_extras(spec: str) -> str: """Constraint files may not carry extras.""" return re.sub(r"\[[^\]]*\]", "", spec, count=1) # ── enumeration across homes ────────────────────────────────────────────────── def _name_set(config: dict, key: str) -> set[str]: values = (config.get("plugins") or {}).get(key) if isinstance(config.get("plugins"), dict) else None return set(values) if isinstance(values, list) else set() def _plugin_enabled(decl: PythonDeclaration, enabled: set[str], disabled: set[str]) -> bool: keys = {decl.name, decl.plugin_dir.name} return bool(keys & enabled) and not (keys & disabled) def _read_home_config(home: Path) -> dict: """*home*'s effective config through the real loader, scoped to that home for the duration.""" from hermes_cli.config import load_config_readonly from hermes_constants import reset_hermes_home_override, set_hermes_home_override token = set_hermes_home_override(home) try: return load_config_readonly() finally: reset_hermes_home_override(token) def enabled_declarations(home: Path) -> list[PythonDeclaration]: """Declarations of every enabled, non-external user plugin under ``/plugins`` that declares something. Malformed declarations are skipped with a warning (one bad plugin must not stop the union for everyone else).""" plugins_dir = Path(home) / "plugins" if not plugins_dir.is_dir(): return [] config = _read_home_config(Path(home)) enabled, disabled = _name_set(config, "enabled"), _name_set(config, "disabled") found: list[PythonDeclaration] = [] for plugin_dir in sorted(p for p in plugins_dir.iterdir() if p.is_dir() and not p.name.startswith(".")): try: decl = read_declaration(plugin_dir) except (ValueError, OSError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc: logger.warning("Plugin %s: dependency declaration skipped: %s", plugin_dir.name, exc) continue if decl.wants_install and _plugin_enabled(decl, enabled, disabled): found.append(decl) return found def dependency_homes() -> list[Path]: """Every home whose plugins share this venv: the default home plus live named profiles.""" from hermes_cli.profiles import profiles_to_serve return [home for _name, home in profiles_to_serve(multiplex=True)] def union_specs(declarations: Iterable[PythonDeclaration]) -> list[str]: seen: dict[str, None] = {} for decl in declarations: for spec in applicable_specs(decl.specs): seen.setdefault(spec, None) return list(seen) # ── resolve / install ───────────────────────────────────────────────────────── def _classify(result) -> Optional[Exception]: if result.ok: return None text = (result.stderr + result.stdout).lower() if result.blocked: return DependencyInstallError(result.reason) if any(marker in text for marker in _CONFLICT_MARKERS): return DependencyConflict(_tail(result.stderr or result.stdout)) return DependencyInstallError(_tail(result.stderr or result.stdout) or "installer failed") def _tail(text: str, limit: int = 600) -> str: return text.strip()[-limit:] def resolve(specs: list[str], constraints: list[str], *, dry_run: bool, timeout: int = 600): """Run the shared installer ladder on *specs* under *constraints*. Raises ``DependencyConflict`` or ``DependencyInstallError``; returns the installer result on success.""" from tools.lazy_deps import install_specs result = install_specs(specs, timeout=timeout, constraints=constraints, dry_run=dry_run) error = _classify(result) if error is not None: raise error return result def check_candidate(candidate: PythonDeclaration, *, home: Path, project_root: Path) -> list[str]: """Prove core + every enabled plugin + *candidate* resolves, without installing. Returns the candidate's applicable specs (empty when it declares nothing for this platform).""" own = applicable_specs(candidate.specs) if not own or candidate.external: return [] resolve(union_specs([candidate, *_peers(candidate, home)]), core_constraints(project_root), dry_run=True) return own def _satisfied(spec: str) -> bool: """Installed and inside the specifier (extras are not checked; a missing extra surfaces at import).""" from importlib.metadata import PackageNotFoundError, version req = Requirement(spec) try: installed = version(req.name) except PackageNotFoundError: return False return req.specifier.contains(installed, prereleases=True) def _peers(decl: PythonDeclaration, home: Path) -> list[PythonDeclaration]: """Enabled plugins other than *decl* itself (matched by directory, so a re-enable is not its own peer).""" return [d for d in enabled_declarations(home) if d.plugin_dir.resolve() != decl.plugin_dir.resolve()] def install_declaration(decl: PythonDeclaration, *, home: Path, project_root: Path, timeout: int = 900) -> list[str]: """Install one plugin's applicable specs into the venv, resolved TOGETHER with every enabled peer's specs under core constraints, so installing A can never downgrade what enabled B needs (uv refuses instead). No-op when every spec is already satisfied. Returns what applies.""" specs = applicable_specs(decl.specs) if specs and not decl.external and not all(_satisfied(s) for s in specs): resolve(union_specs([decl, *_peers(decl, home)]), core_constraints(project_root), dry_run=False, timeout=timeout) return specs # ── install-time glue shared by CLI, dashboard and pack installs ────────────── @dataclass(frozen=True) class DepsOutcome: """What happened to a freshly installed plugin's Python dependencies. ``status`` is one of ``none`` (nothing declared / external runtime), ``installed``, ``failed`` (network, build, gate; the plugin stays installed and the loader warns at import), ``invalid`` (malformed declaration).""" status: str specs: tuple[str, ...] = () detail: str = "" skipped: tuple[str, ...] = () # direct-URL requirements Hermes never installs @property def message(self) -> str: text = _OUTCOME_MESSAGES[self.status](self) if self.skipped: text += (f"\nNot installed (direct URL requirements are left to you): " f"uv pip install {' '.join(repr(s) for s in self.skipped)}") return text.strip() _OUTCOME_MESSAGES: dict[str, Callable[[DepsOutcome], str]] = { "none": lambda o: "", "installed": lambda o: f"Installed Python dependencies: {', '.join(o.specs)}", "failed": lambda o: (f"Python dependencies not installed ({o.detail}). Run manually: " f"uv pip install {' '.join(o.specs)}"), "invalid": lambda o: f"Python dependency declaration ignored: {o.detail}", } def project_root() -> Path: return Path(__file__).resolve().parent.parent def refuse_conflicting_candidate(plugin_dir: Path, *, home: Path) -> None: """Install-time gate, run BEFORE a plugin tree is moved into place: a candidate whose deps cannot resolve with core + the enabled plugins is refused; a malformed declaration is refused too. A network failure during the dry run is not a verdict and lets the install continue.""" try: check_candidate(read_declaration(plugin_dir), home=home, project_root=project_root()) except DependencyConflict as exc: raise DependencyConflict( f"its Python dependencies conflict with Hermes or an enabled plugin:\n{exc}") from exc except DependencyInstallError as exc: logger.warning("Dependency pre-check skipped (%s); the install will retry for real", exc) except (ValueError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc: raise ValueError(f"invalid Python dependency declaration: {exc}") from exc def install_for_plugin_dir(plugin_dir: Path) -> DepsOutcome: """Install an installed plugin's declared deps into the venv. Never raises.""" try: decl = read_declaration(plugin_dir) except (ValueError, tomllib.TOMLDecodeError, yaml.YAMLError) as exc: return DepsOutcome("invalid", detail=str(exc)) skipped = () if decl.external else tuple(unsupported_specs(decl.specs)) try: specs = install_declaration(decl, home=get_hermes_home(), project_root=project_root()) except (DependencyConflict, DependencyInstallError) as exc: return DepsOutcome("failed", tuple(applicable_specs(decl.specs)), detail=_tail(str(exc), 300), skipped=skipped) return DepsOutcome("installed" if specs else "none", tuple(specs), skipped=skipped) # ── post-update re-apply ────────────────────────────────────────────────────── @dataclass class ReapplyReport: installed: list[str] dropped: list[tuple[str, str]] # (plugin name, reason) failed: str = "" @property def ok(self) -> bool: return not self.failed def _drop_order(declarations: list[PythonDeclaration]) -> list[PythonDeclaration]: """Plugins in the order they may be sacrificed on conflict: non-memory first, memory last.""" return sorted(declarations, key=lambda d: (d.memory_provider, d.name)) def _resolves(declarations: list[PythonDeclaration], constraints: list[str]) -> bool: try: resolve(union_specs(declarations), constraints, dry_run=True) except DependencyConflict: return False return True def _first_resolving_subset(declarations: list[PythonDeclaration], constraints: list[str] ) -> tuple[list[PythonDeclaration], list[PythonDeclaration]]: """Largest subset of *declarations* that resolves. Fast path: the whole union. Otherwise every plugin that cannot resolve on its own against core is a culprit and is dropped (never an innocent neighbour); what remains is peeled non-memory-first only for plugin-vs-plugin conflicts.""" if _resolves(declarations, constraints): return list(declarations), [] kept = [d for d in declarations if _resolves([d], constraints)] dropped = [d for d in declarations if d not in kept] order = _drop_order(kept) while kept and not _resolves(kept, constraints): victim = order.pop(0) kept.remove(victim) dropped.append(victim) return kept, dropped def reapply_all(*, project_root: Path, disable: Callable[[Path, str], None]) -> ReapplyReport: """Re-install the union of every enabled plugin's deps after a venv rebuild. Conflicts drop non-memory plugins first and disable them through *disable(home, name)*; anything else (network, gate) is reported without changing plugin state.""" per_home = [(home, enabled_declarations(home)) for home in dependency_homes()] everything = [d for _home, decls in per_home for d in decls] if not everything: return ReapplyReport(installed=[], dropped=[]) constraints = core_constraints(project_root) try: kept, dropped = _first_resolving_subset(everything, constraints) specs = union_specs(kept) if specs: resolve(specs, constraints, dry_run=False) except DependencyInstallError as exc: return ReapplyReport(installed=[], dropped=[], failed=str(exc)) report = ReapplyReport(installed=specs, dropped=[]) for victim in dropped: home = next(h for h, decls in per_home if victim in decls) reason = "its Python dependencies no longer resolve against this Hermes" disable(home, victim.name) report.dropped.append((victim.name, reason)) return report