feat(plugins): install declared Python dependencies and re-apply them after hermes update

A directory plugin's pyproject.toml [project].dependencies (or manifest
python_dependencies) are now installed into the Hermes venv on install/enable/
update, resolved under a constraints file built from Hermes' own pinned
dependencies together with every enabled peer's declarations. A candidate that
cannot resolve is refused before its tree is moved into place; nothing is
installed and no other plugin is touched. --no-deps opts a single install out.

hermes update rebuilds the venv from Hermes' lock and strips everything else, so
its git, zip and both repair paths now re-apply the union of every profile's
enabled plugins. When the union no longer resolves, each plugin is resolved on
its own so only culprits are dropped (never an alphabetical neighbour); a
plugin-vs-plugin conflict peels non-memory plugins first because a Hermes that
boots without memory reads as data loss. Dropped plugins are disabled through
the real config writer with a loud message naming the fix.

python_runtime: external lets sidecar-venv plugins (Mnemosyne's shape) opt out
of the union; hermes-agent self-dependencies and direct-URL requirements are
never installed (the latter are surfaced for the user to install by hand).
This commit is contained in:
teknium1
2026-09-16 22:56:59 -07:00
committed by Teknium
parent e3507ef2ce
commit 96e8a23222
11 changed files with 608 additions and 49 deletions

View File

@@ -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()

View File

@@ -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 ``<home>/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

View File

@@ -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 <identifier> --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(""),

View File

@@ -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 ────────────────────────────────────────

View File

@@ -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))

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,

View File

@@ -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.

View File

@@ -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:

View File

@@ -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.<id>.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