diff --git a/hermes_cli/plugin_packs.py b/hermes_cli/plugin_packs.py index 5487f37f68..d88c93507a 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -317,6 +317,7 @@ def install_pack_plugins( _get_disabled_set, _get_enabled_set, _install_plugin_core, + _install_python_dependencies, _prompt_plugin_env_vars, _run_capability_consent, _save_disabled_set, @@ -350,6 +351,7 @@ def install_pack_plugins( _prompt_plugin_env_vars(manifest, console) except Exception: logger.debug("requires_env prompt failed for %s", installed_name, exc_info=True) + _install_python_dependencies(target, console) enabled = _get_enabled_set() disabled = _get_disabled_set() diff --git a/hermes_cli/plugin_python_deps.py b/hermes_cli/plugin_python_deps.py new file mode 100644 index 0000000000..9cbfb152f4 --- /dev/null +++ b/hermes_cli/plugin_python_deps.py @@ -0,0 +1,440 @@ +"""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: + path = home / "config.yaml" + if not path.is_file(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return data if isinstance(data, dict) else {} + + +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 diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 59e8715c92..6c9880c831 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -342,25 +342,46 @@ def _missing_env_specs(manifest: dict) -> list[dict]: return [s for s in env_specs if not get_env_value(s["name"])] -def _print_python_dependencies(manifest: dict, console) -> None: - """Print declared ``python_dependencies`` with an install hint — Hermes never auto-installs - plugin pip dependencies. +def _refuse_conflicting_python_deps(tmp_target: Path, plugin_name: str) -> None: + """Dependency pre-check on the staged tree: a conflict with core or an enabled plugin refuses the + install before anything is moved into place (nothing installed, nothing disabled).""" + from hermes_cli.plugin_python_deps import DependencyConflict, refuse_conflicting_candidate + try: + refuse_conflicting_candidate(tmp_target, home=get_hermes_home()) + except DependencyConflict as exc: + raise PluginOperationError( + f"Plugin '{plugin_name}' was not installed: {exc}\n" + "The plugin author should relax that requirement. To install the plugin anyway and manage " + "its Python packages yourself: hermes plugins install --no-deps") from exc + except ValueError as exc: + raise PluginOperationError(f"Plugin '{plugin_name}' was not installed: {exc}") from exc - See #64165. - See #15220, #64165. - """ - deps = manifest.get("python_dependencies") or [] - if not isinstance(deps, list): + +def _install_python_dependencies_quietly(target: Path, warnings: list[str]) -> list[str]: + """Dashboard variant: install, append a failure to *warnings*, return the applicable specs.""" + from hermes_cli.plugin_python_deps import install_for_plugin_dir + outcome = install_for_plugin_dir(target) + if outcome.status in ("failed", "invalid"): + warnings.append(outcome.message) + return list(outcome.specs) + + +def _install_python_dependencies_for_key(key: str, console) -> None: + """``plugins enable`` variant: a user plugin enabled after a bare install gets its deps now.""" + entry = _find_plugin_entry(key) + if entry is not None and entry[4]: + _install_python_dependencies(Path(entry[4]), console) + + +def _install_python_dependencies(target: Path, console, *, skip: bool = False) -> None: + """Install the plugin's declared Python deps (pyproject ``[project].dependencies`` or manifest + ``python_dependencies``) into the venv and report; the plugin stays installed on failure.""" + from hermes_cli.plugin_python_deps import install_for_plugin_dir + outcome = install_for_plugin_dir(target) if not skip else None + if outcome is None or outcome.status == "none": return - deps = [d.strip() for d in deps if isinstance(d, str) and d.strip()] - if not deps: - return - plugin_name = manifest.get("name", "this plugin") - console.print(f"\n[bold]{plugin_name}[/bold] declares Python dependencies (not installed automatically):") - for dep in deps: - console.print(f" - {dep}") - console.print( - f"[dim]Install them yourself if needed: pip install {' '.join(repr(d) for d in deps)}[/dim]\n") + style = {"installed": "green", "failed": "yellow", "invalid": "yellow"}[outcome.status] + console.print(f"[{style}]{'✓' if outcome.status == 'installed' else '⚠'}[/{style}] {outcome.message}") def _prompt_plugin_env_vars(manifest: dict, console) -> None: @@ -683,11 +704,13 @@ def _install_plugin_core( ref: Optional[str] = None, scan_decision_cb=None, reviewed_pin: Optional[str] = None, + python_deps: bool = True, ) -> tuple[Path, dict, str]: """Clone a Git plugin and atomically record its source and exact revision. *reviewed_pin* is the curated-catalog sha for this install; the scan trusts the tree - only when the checked-out revision is exactly that sha.""" + only when the checked-out revision is exactly that sha. *python_deps* False skips the + dependency conflict gate (``--no-deps``: the user installs them by hand).""" requested_revision = _normalize_exact_revision(ref) if ref is not None else None try: git_url, subdir = _resolve_git_url(identifier) @@ -721,6 +744,8 @@ def _install_plugin_core( # Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked. _scan_plugin_tree(tmp_target, identifier, force=force, scan_decision_cb=scan_decision_cb, reviewed_pin=bool(reviewed_pin) and installed_revision == reviewed_pin) + if python_deps: + _refuse_conflicting_python_deps(tmp_target, plugin_name) if target.exists() and not force: raise PluginOperationError( @@ -751,6 +776,7 @@ def cmd_install( enable: Optional[bool] = None, ref: Optional[str] = None, allow_removed: bool = False, + no_deps: bool = False, ) -> None: """Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand. @@ -798,10 +824,12 @@ def cmd_install( try: if entry is not None: target, installed_manifest, installed_name = catalog.install_catalog_entry( - entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision) + entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision, + python_deps=not no_deps) else: target, installed_manifest, installed_name = _install_plugin_core( - identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision) + identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision, + python_deps=not no_deps) except PluginOperationError as e: _fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}") if not _looks_like_plugin_dir(target): @@ -809,7 +837,7 @@ def cmd_install( f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, " f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.") _prompt_plugin_env_vars(installed_manifest, console) - _print_python_dependencies(installed_manifest, console) + _install_python_dependencies(target, console, skip=no_deps) _display_after_install(target, identifier) if enable is None: @@ -921,11 +949,13 @@ def _rescan_after_update(target: Path, name: str, console) -> None: def _post_pull_housekeeping(target: Path, console) -> None: - """After ``git pull``: drop stale ``__pycache__`` and copy any new ``.example`` files.""" + """After ``git pull``: drop stale ``__pycache__``, copy any new ``.example`` files, and install + dependencies the new revision declares (a version bump commonly adds or moves a package).""" # Same stale-bytecode class as the main checkout (#6207/#60242): the pull just changed .py files under # this plugin dir, so drop any __pycache__ compiled from the previous revision. _clear_plugin_bytecode(target) _copy_example_files(target, console) + _install_python_dependencies(target, console) def _remove_plugin_core(target: Path) -> None: @@ -1094,6 +1124,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: # Built-in tool override is a privileged grant; bundled plugins are trusted. if source == "bundled": return + _install_python_dependencies_for_key(key, console) # When the manifest declares capabilities the consent screen is the canonical grant path # (it covers tools.override too); the legacy prompt then only runs on an explicit flag. # See #64228. @@ -1818,9 +1849,11 @@ def dashboard_install_plugin( if enable: _set_plugin_enabled(installed_name, enable=True) + deps = _install_python_dependencies_quietly(target, warnings) ap = target / "after-install.md" return { "ok": True, "plugin_name": installed_name, "warnings": warnings, + "python_dependencies": deps, "missing_env": [s["name"] for s in _missing_env_specs(installed_manifest)], "after_install_path": str(ap) if ap.exists() else None, "enabled": enable, } @@ -1916,7 +1949,10 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]: try: if sidecar: sha, changed = catalog.repin_catalog_plugin(target, sidecar) - return {"ok": True, "name": name, "sha": sha, "unchanged": not changed} + warnings: list[str] = [] + deps = _install_python_dependencies_quietly(target, warnings) if changed else [] + return {"ok": True, "name": name, "sha": sha, "unchanged": not changed, + "python_dependencies": deps, "warnings": warnings} msg = _pull_plugin_update( target, lambda rec: ( @@ -2134,7 +2170,8 @@ _PLUGIN_ACTIONS = { force=getattr(args, "force", False), enable=_tri_state_flag(args, "enable", "no_enable"), ref=getattr(args, "ref", None), - allow_removed=getattr(args, "allow_removed", False)), + allow_removed=getattr(args, "allow_removed", False), + no_deps=getattr(args, "no_deps", False)), "search": lambda args: _catalog().cmd_search( getattr(args, "term", "") or "", json_output=getattr(args, "json", False)), "browse": lambda args: _catalog().cmd_search(""), diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index 10d86373ca..213a1839f3 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -106,7 +106,7 @@ def removed_annotation(name: str, dir_path) -> Optional[str]: # ── Catalog-aware install / update ─────────────────────────────────────────── def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None, - allow_removed: bool = False, scan_decision_cb=None) -> tuple: + allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True) -> tuple: """``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance sidecar. Returns the core's ``(target, manifest, installed_name)``.""" from hermes_cli.plugins_cmd import _install_plugin_core @@ -114,7 +114,7 @@ def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Option raise_if_removed(entry.name, entry.repo) target, manifest, installed_name = _install_plugin_core( entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb, - reviewed_pin=entry.sha) + reviewed_pin=entry.sha, python_deps=python_deps) write_catalog_sidecar(target, entry) return target, manifest, installed_name @@ -147,6 +147,9 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console) -> None: raise SystemExit(1) verb = "updated to" if changed else "is already at catalog pin" console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] {verb} {sha[:8]}.") + if changed: + from hermes_cli.plugins_cmd import _install_python_dependencies + _install_python_dependencies(target, console) # ── search / browse / info / validate ──────────────────────────────────────── diff --git a/hermes_cli/plugins_loader.py b/hermes_cli/plugins_loader.py index 788aeb744c..3af0120ebf 100644 --- a/hermes_cli/plugins_loader.py +++ b/hermes_cli/plugins_loader.py @@ -217,13 +217,9 @@ class PluginLoaderMixin: ) def _warn_python_dependencies(self, manifest: PluginManifest) -> None: - """Warn about missing declared pip dependencies with an install hint — NEVER auto-install. - - See #64165. - python_dependencies is a declaration seam ONLY: Hermes validates and prints the requirements with an - install hint but NEVER auto-installs them. The isolation design (constraints installs vs. vendored - dirs vs. conflict-detection-and-refusal) is an explicitly deferred follow-up — see the round-2 - review on #64165 and #15220. + """Warn about declared pip dependencies missing at load time. Installing happens at + ``hermes plugins install``/``enable`` and after ``hermes update`` (``hermes_cli.plugin_python_deps``) + under core constraints; the loader itself never installs — import time is not a consent point. """ deps = manifest.python_dependencies if not deps: @@ -233,9 +229,9 @@ class PluginLoaderMixin: if missing: logger.warning( "Plugin %s declares Python dependencies that are not " - "installed: %s. Hermes does not install plugin dependencies " - "automatically; install them yourself, e.g.: pip install %s", - key, ", ".join(missing), " ".join(f"'{m}'" for m in missing), + "installed: %s. Run `hermes plugins enable %s` to install them, " + "or install them yourself: pip install %s", + key, ", ".join(missing), key, " ".join(f"'{m}'" for m in missing), ) else: logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps)) diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index ce5c34e40a..92b36904b3 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -36,7 +36,7 @@ _KNOWN_MANIFEST_FIELDS: Set[str] = { "pip_dependencies", "provides_browser_providers", "provides_web_providers", "manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema", "license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends", - "requires_hermes", + "requires_hermes", "python_runtime", } # Highest manifest schema version this Hermes understands. diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index e67e63fc82..c977e35e68 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -29,6 +29,10 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: plugins_install.add_argument( "--allow-removed", action="store_true", help="DANGEROUS: bypass the catalog removed-plugin blocklist check") + plugins_install.add_argument( + "--no-deps", action="store_true", + help="Skip the plugin's declared Python dependencies (no conflict check, nothing installed); " + "you manage them yourself") _install_enable_group = plugins_install.add_mutually_exclusive_group() _install_enable_group.add_argument( "--enable", action="store_true", diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 816f59a85a..a40fa522fd 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -80,6 +80,7 @@ from hermes_cli.update_cmd_deps import ( # noqa: F401 _ensure_venv_pip, _install_psutil_android_compat, _is_android_python, _npm_bin_exists, _npm_lockfile_changed, _npm_manifest_paths, _npm_manifests_digest, _path_uid, _rebuild_desktop_after_update, _record_npm_lockfile_hash, _refresh_active_lazy_features, + _reapply_plugin_python_dependencies, _refresh_active_memory_provider_dependencies, _refuse_update_if_venv_foreign_owned, _repair_node_deps_on_current_checkout, _restore_active_tool_dependencies, _sync_python_dependencies_after_pull, _update_node_dependencies, @@ -620,6 +621,7 @@ def _repair_venv_on_current_checkout( _m()._install_python_dependencies_with_optional_fallback(repair_prefix, env=repair_env, group="all") _m()._refresh_active_lazy_features(repair_prefix, env=repair_env, features=active_lazy_features) _m()._restore_active_tool_dependencies(active_tool_dependencies, repair_prefix, env=repair_env) + _m()._reapply_plugin_python_dependencies() # Core ``.[all]`` install finished. Clear the generic core breadcrumb before the lazy-refresh phase — # that phase uses its own marker so a later lazy failure cannot be "healed" by clearing the core marker # based on a narrow 7-package import probe (#58004 review). @@ -710,6 +712,7 @@ def _repair_current_checkout( "to finish import-based venv repair.") _m()._restore_active_tool_dependencies( active_tool_dependencies, repair_prefix, env=repair_env) + _m()._reapply_plugin_python_dependencies() current_checkout_complete = _repair_node_deps_on_current_checkout( _print_verified_update_completion, assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index 4198a8b735..0452d0f5b7 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -388,6 +388,41 @@ def _refresh_active_memory_provider_dependencies() -> None: print(f" ⚠ {provider} dependencies failed to refresh: {exc}") +def _reapply_plugin_python_dependencies() -> None: + """Re-install every enabled user plugin's declared Python deps after the venv was rebuilt (a + ``uv sync``/reinstall strips anything Hermes' own lock does not know). Non-memory plugins whose + deps no longer resolve are disabled loudly, memory providers last. Never raises.""" + from hermes_cli.plugin_python_deps import reapply_all + from hermes_cli.update_cmd import _m + + def _disable(home, name: str) -> None: + # Write through the real config writer (comments/defaults preserved), scoped to *home*. + from hermes_cli.config import load_config, save_config + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + token = set_hermes_home_override(home) + try: + config = load_config() + plugins = config.setdefault("plugins", {}) + plugins["enabled"] = sorted(set(plugins.get("enabled") or []) - {name}) + plugins["disabled"] = sorted(set(plugins.get("disabled") or []) | {name}) + save_config(config, merge_existing=True) + finally: + reset_hermes_home_override(token) + + try: + report = reapply_all(project_root=_m().PROJECT_ROOT, disable=_disable) + except Exception as exc: # the update must finish even if the plugin step blows up + print(f" ⚠ Plugin Python dependencies not re-applied: {exc}") + return + if report.installed: + print(f" ✓ Plugin Python dependencies re-applied: {', '.join(report.installed)}") + for name, reason in report.dropped: + print(f" ✗ Plugin '{name}' DISABLED: {reason}. Fix the plugin's declared dependencies, " + f"then `hermes plugins enable {name}`.") + if report.failed: + print(f" ⚠ Plugin Python dependencies not re-applied: {report.failed}") + + def _is_android_python() -> bool: from hermes_cli.update_cmd import _m return _m().sys.platform == "android" @@ -1046,6 +1081,7 @@ def _sync_python_dependencies_after_pull( # Heal memory-provider bridge packages last — the steps above may have stripped them. _m()._refresh_active_memory_provider_dependencies() + _m()._reapply_plugin_python_dependencies() # Remaining import failures are real breakage. Warn only — never roll back: `cannot import # name X` is also the stale-bytecode signature, which self-heals next launch. diff --git a/hermes_cli/update_cmd_zip.py b/hermes_cli/update_cmd_zip.py index 86cf6ef5b4..5a981cc0e2 100644 --- a/hermes_cli/update_cmd_zip.py +++ b/hermes_cli/update_cmd_zip.py @@ -354,6 +354,7 @@ def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None: _m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=install_env) # Parity with git-pull path: heal the active memory provider's bridge packages after the reinstall. _m()._refresh_active_memory_provider_dependencies() + _m()._reapply_plugin_python_dependencies() def _update_via_zip(args, *, had_desktop_app_before_update: bool = False, _windows_gateway_resume=None) -> bool: diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 850070ad1d..88dbbc325f 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -300,7 +300,8 @@ this Hermes understands still loads with a warning. | `manifest_version` | int | Manifest **file-format** version. Absent = `1`. Current max: `2`. Independent from `api_version`. | | `api_version` | int | Runtime **plugin API generation** the plugin targets (ctx surface / hook signatures). Deliberately a separate axis from `manifest_version` — an `api_version: 1` plugin can use a v2 manifest. | | `requires_plugins` | list | Inter-plugin dependencies: `- id: other-plugin` with optional `version_range: ">=1.0,<2"`. **Advisory**: a missing dependency logs a clear warning but the plugin still loads — probe at runtime with `ctx.has_plugin("other-plugin")`. Load **order** honors these edges: when A requires B, B's `register()` runs before A's (topological sort, alphabetical tiebreak; cycles warn and fall back to alphabetical order). | -| `python_dependencies` | list of str | Declared pip requirements (e.g. `"requests>=2.0,<3"`). **Declaration seam only** — Hermes validates them, and `hermes plugins install` / `hermes plugins doctor` surface missing ones with a `pip install` hint, but Hermes **never auto-installs** them. Pin upper bounds. | +| `python_dependencies` | list of str | PEP 508 requirements (e.g. `"requests>=2.0,<3"`). Installed into Hermes' venv on `hermes plugins install` / `enable` and **re-applied after every `hermes update`** (see [Python dependencies](#python-dependencies)). A `pyproject.toml` beside `plugin.yaml` with `[project].dependencies` is the equivalent, preferred form. | +| `python_runtime` | str | `external` — the plugin manages its own interpreter/venv (sidecar pattern); Hermes installs nothing and leaves any `pyproject.toml` alone. | | `config_schema` | mapping | JSON-schema-ish description of keys under `plugins.entries..settings`: `api_url: {type: str, default: "", description: "...", required: false}`. Validated at load; mismatches log actionable warnings naming the key and expected type — never load failures. Types: `str`, `int`, `float`, `bool`, `list`, `dict` (plus JSON-schema aliases). | | `license` | str | SPDX-style license id (e.g. `MIT`). | | `homepage` | str | Project URL. | @@ -319,21 +320,57 @@ requires_plugins: - id: other-plugin version_range: ">=1.0,<2" python_dependencies: - - "somepkg>=1.0,<2" # surfaced, never auto-installed + - "somepkg>=1.0,<2" # installed on install/enable, re-applied after hermes update config_schema: api_url: {type: str, default: "", description: "Service endpoint"} ``` -:::note pip-dependency isolation is deferred -`python_dependencies` is intentionally declare-and-surface only. Installing -arbitrary packages into Hermes' shared venv is a conflict and supply-chain -surface, so the install seam's isolation design (constraints-file installs -against the host lock vs. per-plugin vendored dirs vs. conflict detection -with refusal) is an explicitly deferred follow-up — see the round-2 review on -[#64165](https://github.com/NousResearch/hermes-agent/issues/64165) and -[#15220](https://github.com/NousResearch/hermes-agent/issues/15220). Plugin -packs (#64166) build on these v2 fields. -::: +### Python dependencies + +A directory plugin can bring its own PyPI packages. Declare them either in the manifest +(`python_dependencies`, above) or, preferably, in a `pyproject.toml` next to `plugin.yaml`: + +```toml +[project] +name = "my-plugin" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [ + "somepkg>=1.0,<2", + "other[extra]>=3.11", +] +``` + +When both exist the `pyproject.toml` wins. What Hermes does with them: + +- **Install / enable** — the declared packages are installed into Hermes' venv with + `uv pip install` (pip fallback) under a **constraints file built from Hermes' own pinned + dependencies**, so a plugin can never move a core package (httpx, pydantic, …) off the version + Hermes was tested with. Environment markers (`; sys_platform == "win32"`) are honoured. +- **Conflict = refusal, never a silent drop** — before the plugin tree is moved into place, its + dependencies are dry-run resolved together with every already-enabled plugin's. A candidate that + cannot resolve is *not installed* and the error names the conflict; existing plugins are untouched. +- **`hermes update` re-applies them** — the update's `uv sync` rebuilds the venv from Hermes' lock + and strips anything else. Afterwards Hermes walks every profile's enabled plugins and reinstalls + their declared dependencies. If the union no longer resolves (a core pin moved), non-memory + plugins are dropped one at a time until it does; each dropped plugin is **disabled with a loud + message** naming it, and memory providers are kept over everything else, because a Hermes that + boots without memory looks like data loss. +- **`hermes plugins update`** re-runs the install for whatever the new revision declares. +- **`--no-deps`** on `hermes plugins install` skips all of this for one plugin (no conflict gate, + nothing installed) when you would rather manage its packages yourself. +- **Opt out with `python_runtime: external`** — plugins that keep a heavy runtime (torch, native + extensions) in their own sidecar venv and talk to it over a subprocess declare this in + `plugin.yaml`; Hermes then installs nothing and the plugin never joins the shared resolution. +- **Nothing to load is an error** — `hermes plugins validate` (and the catalog CI) fail a + `plugin.yaml` with no `__init__.py`, `desktop/plugin.js` or `plugin.json` beside it. A pip-layout + package whose code sits under `src/` behind an entry point needs a thin directory-plugin wrapper + whose `pyproject.toml` depends on the package. +- `security.allow_lazy_installs: false` disables all of this; the plugin installs, its dependencies + do not, and the loader warns at import. + +`HERMES_HOME/plugins/` survives `hermes update` and Desktop updates: the updater only rebuilds the +venv and the checkout, never the home directory. ## Step 3: Write the tool schemas