mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
A home with model.provider: auto, auth.json active_provider: copilot and an env token resolved "auto", so recover_for_provider found no left-core provider and returned before checking the home's stored choice. Auth then ignored copilot (no registry row without the plugin) and the quiet CLI, which resolves before any gateway or agent start can migrate, showed the generic setup error instead of installing the plugin. "auto" (or no name) now stands for auth.json's active_provider, the provider the resolver's auto rung follows.
1195 lines
66 KiB
Python
1195 lines
66 KiB
Python
"""Move a home onto the catalog plugin of a feature that left core.
|
|
|
|
A feature (gateway platform, toolset, bundled plugin) that moves from core into a standalone catalog
|
|
plugin keeps its names, config keys and env vars, so migrating is only "install the plugin". Each row of
|
|
:data:`LEFT_CORE` names the catalog plugin and a read-only predicate "does this home use it". The
|
|
contract is the one memory providers established (``memory_provider_migration``, whose helpers this
|
|
reuses; memory is not a row because its plugin name comes from ``memory.provider``, not a table):
|
|
|
|
* ``hermes update`` installs the plugin for every profile home sharing the venv that uses the feature.
|
|
* Agent start and gateway start retry once per process for the active home (Desktop users update
|
|
through the app and never run ``hermes update``), honouring ``security.allow_lazy_installs``.
|
|
* After a failed attempt (catalog miss or unreachable, failed install) starts skip the row for
|
|
an hour, doubling with each further failure up to a week, so an offline
|
|
machine pays the network round trip once, not on every ``hermes chat``, and a home whose install
|
|
cannot succeed stops failing every hour; ``hermes update`` always retries.
|
|
* A home gets the plugin automatically at most once (``_left_core_installed`` in its config.yaml):
|
|
``hermes plugins remove`` afterwards is the user's choice and sticks.
|
|
|
|
Installs go through the normal catalog install path at the reviewed pin (kill list, dependency
|
|
constraints, enable). Unattended dependency consent covers only these rows: the feature shipped in
|
|
core, so its users already accepted its dependencies. Agent/gateway start always installs with it
|
|
(nothing can answer a prompt there); ``hermes update`` in a terminal still asks. Every outcome
|
|
reaches the user (terminal, Desktop, chat); an outcome no terminal shows waits in the home for its next
|
|
turn someone sees (:func:`claim_kept_notices`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
from hermes_cli.memory_provider_migration import (
|
|
_failed_recently, _home_consent, _home_label, _install_command, _interactive,
|
|
_note_failure, _unattended_consent,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ``terminal.*`` keys a left-core plugin still reads -> the ``.env`` copy core's ``hermes config set`` also
|
|
# wrote. Not in DEFAULT_CONFIG (a seeded default would hide the plugin's env fallback), so ``config
|
|
# set/get`` recognise them here, and ``config unset`` removes that copy too: the plugin falls back to it,
|
|
# and the old value would return instead of the default.
|
|
LEFT_CORE_TERMINAL_KEYS = {"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE"}
|
|
|
|
|
|
def _read_config(home: Path) -> dict:
|
|
from hermes_cli.config_backend import read_config_doc
|
|
try:
|
|
data = read_config_doc(home / "config.yaml")
|
|
except Exception: # missing/unreadable/invalid YAML: nothing we can see is in use
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def platform_configured_on(home: Path, name: str) -> bool:
|
|
"""Platform *name* is on in *home*'s config files as the gateway loader reads them: legacy
|
|
``gateway.json`` base layer, then config.yaml's ``gateway.platforms`` / ``platforms`` /
|
|
``gateway.<name>`` blocks merged in the loader's order (managed overlay and ``${VAR}`` applied),
|
|
so the last explicit ``enabled`` wins. A stored token with no ``enabled`` anywhere counts too.
|
|
Env credentials are the caller's to check. Read-only; works before the platform's plugin loads."""
|
|
from gateway import config_loader
|
|
from gateway.config import PlatformConfig
|
|
gw_data = config_loader.load_legacy_gateway_json(home)
|
|
gw_data = gw_data if isinstance(gw_data, dict) else {}
|
|
try:
|
|
yaml_cfg = config_loader.read_yaml_layers(home)
|
|
except Exception: # malformed config.yaml: the gateway falls back to gateway.json alone too
|
|
yaml_cfg = {}
|
|
yaml_cfg = yaml_cfg if isinstance(yaml_cfg, dict) else {}
|
|
block = config_loader.merge_platform_sections(
|
|
yaml_cfg, yaml_cfg.get("gateway"), gw_data, also=frozenset({name})).get(name)
|
|
if not isinstance(block, dict):
|
|
return False
|
|
platform = PlatformConfig.from_dict(block)
|
|
return platform.enabled or ("enabled" not in block and bool(str(platform.token or "").strip()))
|
|
|
|
|
|
def _toolset_listed(config: dict, names: frozenset[str], *, own_platform: str = "") -> bool:
|
|
"""A toolset in *names* is selected in ``platform_toolsets`` or the top-level ``toolsets`` list.
|
|
|
|
*own_platform*'s row is skipped: it scopes that platform's own sessions, which only exist once the
|
|
platform is on (:func:`platform_configured_on`). The installers copy cli-config.yaml.example into
|
|
config.yaml, and it carried ``homeassistant: [hermes-homeassistant]`` until Home Assistant left core,
|
|
so that row alone is not use."""
|
|
rows = config.get("platform_toolsets")
|
|
selections = [sel for platform, sel in rows.items() if platform != own_platform] if isinstance(rows, dict) else []
|
|
selections.append(config.get("toolsets"))
|
|
return any(isinstance(sel, list) and any(str(item) in names for item in sel) for sel in selections)
|
|
|
|
|
|
def homeassistant_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""What made core run Home Assistant for *home*: ``HASS_TOKEN`` in its ``.env`` (it enabled both
|
|
the gateway platform and the tools), the platform on in its gateway config
|
|
(:func:`platform_configured_on`), or the ``homeassistant`` / ``hermes-homeassistant`` toolset selected for
|
|
another platform or in the top-level ``toolsets`` list. *process_env* (the active home at startup only)
|
|
also counts a ``HASS_TOKEN`` the process received from its environment (systemd unit, Docker, shell export)."""
|
|
from agent.secret_scope import load_env_file
|
|
if (load_env_file(home / ".env").get("HASS_TOKEN") or "").strip():
|
|
return True
|
|
if process_env:
|
|
try:
|
|
from agent.secret_scope import get_secret
|
|
if (get_secret("HASS_TOKEN", "") or "").strip():
|
|
return True
|
|
except Exception:
|
|
pass
|
|
if platform_configured_on(home, "homeassistant"):
|
|
return True
|
|
return _toolset_listed(
|
|
_read_config(home), frozenset({"homeassistant", "hermes-homeassistant"}), own_platform="homeassistant")
|
|
|
|
|
|
def _auth_store(home: Path) -> dict:
|
|
"""*home*'s ``auth.json``; ``{}`` when missing, unreadable or invalid (no login we can see)."""
|
|
import json
|
|
try:
|
|
store = json.loads((home / "auth.json").read_text(encoding="utf-8-sig"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return store if isinstance(store, dict) else {}
|
|
|
|
|
|
def spotify_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""What made core's Spotify tools usable for *home*: a login stored in its ``auth.json``
|
|
(``providers.spotify``, written by ``hermes auth spotify``) or the ``spotify`` toolset selected
|
|
for a platform. The tools were opt-in and login-gated, so a client id alone is not use."""
|
|
providers = _auth_store(home).get("providers")
|
|
state = providers.get("spotify") if isinstance(providers, dict) else None
|
|
if isinstance(state, dict) and (state.get("access_token") or state.get("refresh_token")):
|
|
return True
|
|
return _toolset_listed(_read_config(home), frozenset({"spotify"}))
|
|
|
|
|
|
def _plugin_lists(home: Path, plugin: str) -> tuple[bool, bool]:
|
|
"""Whether *plugin*'s key or name is in *home*'s ``plugins.enabled`` / ``plugins.disabled``, read with the
|
|
managed overlay applied like the loader's config, so an administrator's pinned list counts."""
|
|
from gateway import config_loader
|
|
feature = next(f for f in LEFT_CORE if f.plugin == plugin)
|
|
try:
|
|
plugins = config_loader.read_yaml_layers(home).get("plugins")
|
|
except Exception: # malformed config.yaml: the loader enables nothing from it either
|
|
logger.debug("left-core: unreadable config for %s", home, exc_info=True)
|
|
return False, False
|
|
plugins = plugins if isinstance(plugins, dict) else {}
|
|
|
|
def listed(key: str) -> bool:
|
|
names = plugins.get(key)
|
|
return isinstance(names, list) and any(str(n) in (feature.plugin_key, feature.plugin) for n in names)
|
|
return listed("enabled"), listed("disabled")
|
|
|
|
|
|
def _bundled_plugin_selected(home: Path, plugin: str) -> bool:
|
|
"""*plugin*'s bundled copy was on for *home*: in ``plugins.enabled`` and not in ``plugins.disabled``
|
|
(which wins, as in the loader)."""
|
|
enabled, disabled = _plugin_lists(home, plugin)
|
|
return enabled and not disabled
|
|
|
|
|
|
def langfuse_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""What made core trace to Langfuse for *home*: the bundled ``observability/langfuse`` selected
|
|
(:func:`_bundled_plugin_selected`). The plugin was opt-in, so keys in ``.env`` alone are not use."""
|
|
return _bundled_plugin_selected(home, "langfuse")
|
|
|
|
|
|
def singularity_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""What made core run its Singularity backend for *home*: ``terminal.backend`` (or the legacy
|
|
``terminal.env_type``) set to ``singularity`` in its config (managed overlay applied, as the bridge
|
|
reads it), else ``TERMINAL_ENV=singularity`` from its dotenv layers as ``load_hermes_dotenv`` resolves
|
|
them (the home's ``.env``, then the administrator-managed ``.env`` with override, ``${VAR}``
|
|
references expanded: :func:`~hermes_cli.env_loader.resolve_home_dotenv`). config.yaml wins over
|
|
both, as in the bridge. *process_env* (startup) also counts a ``TERMINAL_ENV`` the process received
|
|
from its environment (systemd unit, Docker, shell export), for the launch home only: ``TERMINAL_*``
|
|
is process-global (``get_secret`` never scopes it), so for any other served home it is the launch
|
|
profile's backend, not this one's."""
|
|
from gateway import config_loader
|
|
try:
|
|
terminal = config_loader.read_yaml_layers(home).get("terminal")
|
|
except Exception: # malformed config.yaml: the bridge reads nothing from it either
|
|
logger.debug("left-core: unreadable config for %s", home, exc_info=True)
|
|
terminal = None
|
|
terminal = terminal if isinstance(terminal, dict) else {}
|
|
configured = str(terminal.get("backend") or terminal.get("env_type") or "").strip()
|
|
if not configured:
|
|
from hermes_cli.env_loader import resolve_home_dotenv
|
|
configured = resolve_home_dotenv(home).get("TERMINAL_ENV", "").strip()
|
|
if not configured and process_env:
|
|
from hermes_constants import get_routing_process_hermes_home, hermes_home_key
|
|
if hermes_home_key(home) == hermes_home_key(get_routing_process_hermes_home()):
|
|
configured = os.environ.get("TERMINAL_ENV", "").strip()
|
|
return configured.lower() == "singularity"
|
|
|
|
|
|
def agent_imports_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""*home* registered a ``hermes import-agent`` source for ``--sync`` (its ``import-sync.json``,
|
|
which scheduled syncs read). A one-shot import leaves no other trace, and installing the plugin
|
|
for every home would add a command nobody asked for; the old spelling names the install."""
|
|
return (home / "import-sync.json").is_file()
|
|
|
|
|
|
|
|
def _home_env(home: Path, name: str, *, process_env: bool) -> str:
|
|
"""*name* as *home*'s dotenv layers resolve it (:func:`~hermes_cli.env_loader.resolve_home_dotenv`),
|
|
else, with *process_env* and for the launch home only, the process environment."""
|
|
from hermes_cli.env_loader import resolve_home_dotenv
|
|
value = resolve_home_dotenv(home).get(name, "").strip()
|
|
if not value and process_env:
|
|
from hermes_constants import get_routing_process_hermes_home, hermes_home_key
|
|
if hermes_home_key(home) == hermes_home_key(get_routing_process_hermes_home()):
|
|
value = os.environ.get(name, "").strip()
|
|
return value
|
|
|
|
|
|
def camofox_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""What made core run its Camofox backend for *home*, read the way core selected it
|
|
(``tools/browser_camofox.is_camofox_mode``): a CDP endpoint (``browser.cdp_url`` or
|
|
``BROWSER_CDP_URL``) won over Camofox; a stored selection (``browser.cloud_provider``, or
|
|
``use_gateway`` = the managed row) decided alone, Camofox only when it names ``camofox``; with
|
|
none stored, ``CAMOFOX_URL`` in its dotenv layers (the process env too for the launch home with
|
|
*process_env*). ``browser.camofox`` settings never selected it: a leftover block without an
|
|
address ran browser_exec. Config is the raw config.yaml, as core read it; read-only."""
|
|
from utils import is_truthy_value
|
|
browser = _read_config(home).get("browser")
|
|
browser = browser if isinstance(browser, dict) else {}
|
|
if str(browser.get("cdp_url") or "").strip() or _home_env(home, "BROWSER_CDP_URL", process_env=process_env):
|
|
return False
|
|
if is_truthy_value(browser.get("use_gateway")):
|
|
return False
|
|
provider = browser.get("cloud_provider")
|
|
selected = str(provider).strip().lower() if provider is not None else ""
|
|
if selected:
|
|
return selected == "camofox"
|
|
return bool(_home_env(home, "CAMOFOX_URL", process_env=process_env))
|
|
|
|
|
|
def _route_slots(home: Path) -> list[dict]:
|
|
"""Every inference route *home* configures, read with core's own loaders: config.yaml with the managed
|
|
overlay (``read_yaml_layers``): ``model``, the fallback chain (``get_fallback_chain``: legacy
|
|
``fallback_model`` merged), each ``auxiliary.<task>``, ``delegation`` and its fallback chain, every
|
|
delegation backend route (:func:`_delegation_backend_routes`) and its chain, every MoA preset's reference
|
|
models and aggregator (``normalize_moa_config``), the cron-wide ``cron.model_provider``, each cron job's pin
|
|
and the user's own ``providers:`` / ``custom_providers:`` endpoints (``get_compatible_custom_providers``)."""
|
|
from cron.scheduler_preflight import cron_default_provider
|
|
from gateway import config_loader
|
|
from hermes_cli.config_providers import get_compatible_custom_providers
|
|
from hermes_cli.fallback_config import get_fallback_chain
|
|
from hermes_cli.moa_config import normalize_moa_config
|
|
try:
|
|
cfg = config_loader.read_yaml_layers(home)
|
|
except Exception: # malformed config.yaml: the agent reads nothing from it either
|
|
logger.debug("left-core: unreadable config for %s", home, exc_info=True)
|
|
cfg = {}
|
|
cfg = cfg if isinstance(cfg, dict) else {}
|
|
|
|
def block(value) -> dict:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
def with_chain(slot: dict) -> list[dict]:
|
|
return [slot, *get_fallback_chain({"fallback_providers": slot.get("fallback_providers")})]
|
|
delegation = block(cfg.get("delegation"))
|
|
slots = [block(cfg.get("model")), *get_fallback_chain(cfg), *map(block, block(cfg.get("auxiliary")).values()),
|
|
*with_chain(delegation), {"provider": cron_default_provider(cfg)}]
|
|
for preset in normalize_moa_config(cfg.get("moa"))["presets"].values():
|
|
slots += [*map(block, preset.get("reference_models") or []), block(preset.get("aggregator"))]
|
|
for route in _delegation_backend_routes(delegation):
|
|
slots += with_chain(route)
|
|
slots += [{"base_url": entry.get("base_url")} for entry in get_compatible_custom_providers(cfg)]
|
|
import json
|
|
try:
|
|
jobs = json.loads((home / "cron" / "jobs.json").read_text(encoding="utf-8-sig")).get("jobs")
|
|
except (OSError, ValueError, AttributeError): # no (readable) job store: no pins
|
|
jobs = None
|
|
return slots + ([block(job) for job in jobs] if isinstance(jobs, list) else [])
|
|
|
|
|
|
def _provider_choices(home: Path) -> set[str]:
|
|
"""Every provider *home* routes inference through (:func:`_route_slots`), plus the ``auth.json``
|
|
``active_provider`` the resolver's auto rung follows."""
|
|
chosen = {str(slot.get("provider") or "").strip().lower() for slot in _route_slots(home)}
|
|
chosen.add(str(_auth_store(home).get("active_provider") or "").strip().lower())
|
|
return chosen - {""}
|
|
|
|
|
|
def routes_at_hosts(home: Path, hosts: tuple[str, ...]) -> bool:
|
|
"""A route *home* configures (:func:`_route_slots`) has its ``base_url`` at one of *hosts* (the host or
|
|
a subdomain), whatever provider name it goes by (``custom``, a named endpoint)."""
|
|
urls = {str(slot.get("base_url") or "").strip() for slot in _route_slots(home)} - {""} if hosts else set()
|
|
return any(_at_host(url, host) for url in urls for host in hosts)
|
|
|
|
|
|
def _at_host(url: str, host: str) -> bool:
|
|
"""*url* is at *host* or a subdomain. A URL ``urlparse`` rejects (an unclosed IPv6 bracket) is at no host:
|
|
that route fails on its own, and one bad slot must not abort the home's other migrations."""
|
|
from utils import base_url_host_matches
|
|
try:
|
|
return base_url_host_matches(url, host)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _delegation_backend_routes(delegation: dict) -> list[dict]:
|
|
"""The routes of every ``delegation.backends.<name>`` block: each entry of its ``options`` list (the
|
|
``model_map`` backend's titled provider/model routes, the shape ``web_routers/delegation.py`` writes).
|
|
While a backend is active, these are the only routes delegated children take. The backends are
|
|
plugins, so no core loader reads them; any backend using the ``options`` shape counts."""
|
|
backends = delegation.get("backends")
|
|
blocks = [b for b in backends.values() if isinstance(b, dict)] if isinstance(backends, dict) else []
|
|
return [o for b in blocks if isinstance(b.get("options"), list) for o in b["options"] if isinstance(o, dict)]
|
|
|
|
|
|
def providers_selected(home: Path, names: tuple[str, ...], *, process_env: bool = False) -> set[str]:
|
|
"""The provider *names* (ids or aliases) *home* routes inference through (:func:`_provider_choices`),
|
|
or names in its ``HERMES_INFERENCE_PROVIDER`` (dotenv layers; the process env too for the launch home
|
|
with *process_env*). A credential alone is not a choice: core never routed to a provider for a key, and
|
|
a name that is the user's own custom endpoint (``custom_providers: [{name: github, ...}]``) is not one."""
|
|
chosen = _provider_choices(home) | {_home_env(home, "HERMES_INFERENCE_PROVIDER", process_env=process_env).lower()}
|
|
chosen &= {name.lower() for name in names}
|
|
return {name for name in chosen if not _names_custom_endpoint(home, name)} if chosen else chosen
|
|
|
|
|
|
def _names_custom_endpoint(home: Path, name: str) -> bool:
|
|
"""*name* resolves to one of *home*'s own ``providers:`` / ``custom_providers:`` endpoints (the
|
|
resolver's named-custom rung: ``named_custom_provider_config_path``), not to the provider it aliases."""
|
|
from gateway import config_loader
|
|
from hermes_cli.runtime_provider_custom import named_custom_provider_config_path
|
|
try:
|
|
return named_custom_provider_config_path(name, config_loader.read_yaml_layers(home)) is not None
|
|
except Exception: # malformed config.yaml: the resolver finds no custom entry in it either
|
|
logger.debug("left-core: unreadable config for %s", home, exc_info=True)
|
|
return False
|
|
|
|
|
|
# GitHub Copilot: the API provider and the Copilot CLI over ACP, ids and every alias their profiles declared.
|
|
_COPILOT_API = ("copilot", "github-copilot", "github-models", "github-model", "github")
|
|
_COPILOT_ACP = ("copilot-acp", "github-copilot-acp", "copilot-acp-agent")
|
|
|
|
|
|
def copilot_in_use(home: Path, *, process_env: bool = False) -> bool:
|
|
"""Core routed inference through GitHub Copilot for *home*: ``copilot`` or ``copilot-acp`` (any alias)
|
|
is a provider it chose (:func:`providers_selected`). A GH_TOKEN in ``.env`` alone is not use."""
|
|
return bool(providers_selected(home, _COPILOT_API + _COPILOT_ACP, process_env=process_env))
|
|
|
|
|
|
def copilot_relogin_notice(home: Path, *, process_env: bool = False) -> str:
|
|
"""The Copilot API provider is in use in *home* with no token the plugin reads: no
|
|
COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN in its dotenv layers (process env too for the launch home
|
|
with *process_env*) and no Hermes login in its ``auth.json`` (a ``manual`` credential-pool row from
|
|
``hermes auth add copilot``, or ``providers.copilot``). Core also took ``gh auth token`` and the
|
|
GitHub/Copilot CLI's token files, which the plugin does not; read-only, never runs ``gh``."""
|
|
if not providers_selected(home, _COPILOT_API, process_env=process_env):
|
|
return ""
|
|
if any(_home_env(home, name, process_env=process_env)
|
|
for name in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")):
|
|
return ""
|
|
store = _auth_store(home)
|
|
pool = store.get("credential_pool")
|
|
rows = pool.get("copilot") if isinstance(pool, dict) else None
|
|
if isinstance(rows, list) and any(
|
|
isinstance(row, dict) and str(row.get("source") or "").split(":", 1)[0] == "manual" for row in rows):
|
|
return ""
|
|
providers = store.get("providers")
|
|
if isinstance(providers, dict) and isinstance(providers.get("copilot"), dict) and providers["copilot"]:
|
|
return ""
|
|
return ("This profile's Copilot login came from `gh auth token` or the GitHub/Copilot CLI, which the plugin "
|
|
"no longer reads: run `hermes auth add copilot` to sign in.")
|
|
|
|
|
|
def _no_notice(home: Path, *, process_env: bool = False) -> str:
|
|
return ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LeftCoreFeature:
|
|
plugin: str # catalog entry name == installed plugin name
|
|
label: str # user-facing feature name
|
|
in_use: Callable[..., bool] # (home, *, process_env=False) -> bool, read-only
|
|
unchanged: str # what migrated users keep, shown on success
|
|
# Credentials core stripped from every child process while it shipped the feature. Core keeps
|
|
# stripping them (tools/environments/local_env_policy.py): with the plugin absent (migration
|
|
# pending, declined or failed) no manifest declares them, and they would reach every child.
|
|
secret_env: tuple[str, ...] = ()
|
|
# Non-secret settings core kept out of children by default (provider blocklist, Tier 2).
|
|
private_env: tuple[str, ...] = ()
|
|
# Toolsets the plugin registers. Core resolved them like any built-in toolset (on only where the
|
|
# platform's saved list or default composite carried them); a plugin toolset is on for every
|
|
# platform whose known_plugin_toolsets does not name it, so :func:`_record_migration` records
|
|
# them there wherever core had them off.
|
|
toolsets: tuple[str, ...] = ()
|
|
# Platforms whose core default composite (``hermes-<platform>``) never carried those toolsets.
|
|
off_platforms: tuple[str, ...] = ()
|
|
# Core-era toolsets the plugin's toolsets replace while core keeps the name for something else
|
|
# (core's ``browser`` is browser_exec now; Camofox's tools live in the plugin's ``camofox``). The
|
|
# one-time scope conversion treats them as the plugin's toolsets for "did core carry it" and turns
|
|
# them off in the home: a saved platform list naming one names the plugin's toolsets instead, and
|
|
# ``agent.disabled_toolsets`` takes them for platforms on a default composite.
|
|
replaces_toolsets: tuple[str, ...] = ()
|
|
# Gateway platform the plugin ships: a running gateway serves it only after a restart.
|
|
platform: str = ""
|
|
# Channel ownership core declared for that platform, kept while the plugin is absent (migration
|
|
# pending, declined, failed) so a channel-less profile clone still strips the source's identity
|
|
# (hermes_cli/profile_channels.py): the env whose presence enabled the adapter, and the env
|
|
# prefixes it shared with the feature's tools (stripped only when the source runs the adapter).
|
|
enable_env: tuple[str, ...] = ()
|
|
channel_env_prefixes: tuple[str, ...] = ()
|
|
# Top-level command the plugin registers in place of core's ``hermes auth <cli>`` login
|
|
# (``hermes <cli> login|status|logout``); :func:`moved_command_hint` points the old spelling at it.
|
|
cli: str = ""
|
|
# Top-level command the plugin registers: core's own command kept under its name (``hermes
|
|
# import-agent``), or in place of core's nested ``old_command`` (``hermes mcp serve`` -> ``hermes
|
|
# mcp-serve``); :func:`moved_command_hint` points the old spelling at it.
|
|
command: str = ""
|
|
old_command: str = ""
|
|
# Other top-level commands core shipped that the same plugin now registers (``hermes claw``, the
|
|
# OpenClaw migration's spelling before ``hermes import-agent openclaw``); hinted like *command*.
|
|
old_cli: tuple[str, ...] = ()
|
|
# Key of the bundled plugin core shipped (``observability/langfuse``). It stays an alias of the
|
|
# catalog plugin in ``plugins.enabled`` / ``plugins.disabled`` (the loader's gate, an
|
|
# administrator's pinned list) and for every `hermes plugins <verb>` (:func:`legacy_plugin_alias`).
|
|
plugin_key: str = ""
|
|
# ``terminal.backend`` value the plugin registers: unknown-backend errors name the install command.
|
|
terminal_backend: str = ""
|
|
# Config schema version that shipped without the feature, for a row whose conversion changes what a
|
|
# home runs (``replaces_toolsets``): only a home from before it ran the core feature, so the row fires
|
|
# only while the home's config is below it or when that version's config step found the feature in
|
|
# use (:func:`record_core_use`). A newer home that sets the plugin up itself keeps core's toolsets.
|
|
left_at: int = 0
|
|
# Provider ids and aliases the plugin's model-provider profiles register. A name nothing resolves to
|
|
# gets :func:`provider_install_hint` appended to its "unknown provider" error, runtime resolution
|
|
# installs the plugin first for a home that uses it (:func:`recover_for_provider`), and those names
|
|
# never resolve through another rung (the models.dev catalog) while the plugin is not loaded.
|
|
providers: tuple[str, ...] = ()
|
|
# Env names the plugin's providers read that core kept profile-managed: scrubbed when inherited but
|
|
# absent from the profile's .env (env_loader._PROFILE_MANAGED_ENV_KEYS) and routed to .env by `hermes
|
|
# config set` (config._EXTRA_ENV_KEYS). Read statically: env_loader runs before provider discovery.
|
|
profile_env: tuple[str, ...] = ()
|
|
# The subset of those names that choose an executable or its argv: the generic env writers refuse them
|
|
# on every home (:func:`launch_env_names`), as core did. A loaded plugin's profile declares them itself;
|
|
# a home whose plugin is absent, disabled, failed to load or quarantined would otherwise accept a value
|
|
# that runs as the provider's binary once the plugin loads.
|
|
launch_env: tuple[str, ...] = ()
|
|
# Hosts the plugin's providers serve (a host covers its subdomains). Core attached their wire quirks by
|
|
# host for any provider name, so a home with a route at one (a ``custom`` endpoint included) uses the
|
|
# feature (:func:`feature_in_use`), and a request rejected there names the install
|
|
# (:func:`host_install_hint`).
|
|
hosts: tuple[str, ...] = ()
|
|
# (home, *, process_env=False) -> str: one more sentence for every migration outcome in *home* (a
|
|
# credential source core read that the plugin does not, and the command that replaces it), "" = none.
|
|
notice: Callable[..., str] = _no_notice
|
|
|
|
|
|
LEFT_CORE: tuple[LeftCoreFeature, ...] = (
|
|
LeftCoreFeature(
|
|
plugin="homeassistant", label="Home Assistant", in_use=homeassistant_in_use,
|
|
unchanged="HASS_TOKEN/HASS_URL, platforms.homeassistant and the ha_* tool names are unchanged",
|
|
secret_env=("HASS_TOKEN",), private_env=("HASS_URL",),
|
|
toolsets=("homeassistant",), off_platforms=("acp", "webhook"), platform="homeassistant",
|
|
enable_env=("HASS_TOKEN",), channel_env_prefixes=("HASS_",),
|
|
),
|
|
LeftCoreFeature(
|
|
plugin="spotify", label="Spotify", in_use=spotify_in_use,
|
|
unchanged="your Spotify login, the spotify toolset and the spotify_* tools are unchanged; "
|
|
"`hermes auth spotify` is now `hermes spotify login`",
|
|
cli="spotify",
|
|
),
|
|
LeftCoreFeature(
|
|
plugin="langfuse", label="Langfuse", in_use=langfuse_in_use,
|
|
unchanged="your Langfuse keys and HERMES_LANGFUSE_* settings are unchanged; the plugin is now "
|
|
"`langfuse` (was observability/langfuse)",
|
|
secret_env=("HERMES_LANGFUSE_SECRET_KEY",),
|
|
private_env=("HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_BASE_URL"),
|
|
plugin_key="observability/langfuse",
|
|
),
|
|
LeftCoreFeature(
|
|
# MCP clients that launch `hermes mcp serve` are configured outside Hermes, so no home shows the
|
|
# feature in use: nothing installs automatically, and the old spelling prints where it went.
|
|
plugin="mcp-serve", label="Hermes as an MCP server", in_use=lambda home, **_: False,
|
|
unchanged="the conversation tools keep their names; MCP clients change their args to [\"mcp-serve\"]",
|
|
command="mcp-serve", old_command="hermes mcp serve",
|
|
),
|
|
LeftCoreFeature(
|
|
# CAMOFOX_URL is an address, not a credential: core kept it out of children only through its
|
|
# `category: tool` OPTIONAL_ENV_VARS entry (the Tier 2 provider blocklist), as HASS_URL. The
|
|
# bearer token is a credential.
|
|
plugin="camofox", label="Camofox browser backend", in_use=camofox_in_use,
|
|
unchanged="CAMOFOX_URL, the browser.camofox settings and the browser_navigate/browser_click/... tool "
|
|
"names are unchanged; they are the `camofox` toolset now, and core's `browser` toolset "
|
|
"(browser_exec) is turned off in this profile",
|
|
secret_env=("CAMOFOX_API_KEY",), private_env=("CAMOFOX_URL",),
|
|
toolsets=("camofox",), off_platforms=("webhook",), replaces_toolsets=("browser",), left_at=52,
|
|
),
|
|
LeftCoreFeature(
|
|
plugin="singularity", label="Singularity/Apptainer terminal backend", in_use=singularity_in_use,
|
|
unchanged="terminal.backend: singularity, terminal.singularity_image (TERMINAL_SINGULARITY_IMAGE) "
|
|
"and the container_* limits are unchanged",
|
|
terminal_backend="singularity",
|
|
),
|
|
LeftCoreFeature(
|
|
plugin="agent-importers", label="`hermes import-agent`", in_use=agent_imports_in_use,
|
|
unchanged="`hermes import-agent`, its flags and your import-sync.json sync sources are unchanged",
|
|
command="import-agent", old_cli=("claw",),
|
|
),
|
|
LeftCoreFeature(
|
|
# Core kept COPILOT_GITHUB_TOKEN and the two base-URL overrides out of children as provider env
|
|
# (Tier 2: credentialed CLIs still get them); GH_TOKEN/GITHUB_TOKEN stay on core's static lists.
|
|
plugin="github-copilot", label="GitHub Copilot", in_use=copilot_in_use,
|
|
unchanged="your COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN and `hermes auth add copilot` logins, model "
|
|
"names and copilot-acp settings are unchanged",
|
|
private_env=("COPILOT_GITHUB_TOKEN", "COPILOT_API_BASE_URL", "COPILOT_ACP_BASE_URL"),
|
|
providers=_COPILOT_API + _COPILOT_ACP, hosts=("githubcopilot.com", "models.github.ai"),
|
|
profile_env=("HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH",
|
|
"COPILOT_ACP_BASE_URL"),
|
|
launch_env=("HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH"),
|
|
notice=copilot_relogin_notice,
|
|
),
|
|
)
|
|
|
|
def _install_sentence(feature: LeftCoreFeature) -> str:
|
|
from hermes_constants import get_hermes_home
|
|
return (f"{feature.label} moved out of core into the '{feature.plugin}' plugin: install it with "
|
|
f"`{_install_command(feature.plugin, Path(get_hermes_home()))}`")
|
|
|
|
|
|
def platform_install_hint(platform: str) -> str:
|
|
"""For an unknown platform name that left core: the sentence that names its install command for
|
|
the active home, else ``""``. Appended to "unknown platform" errors (``send_message``)."""
|
|
feature = next((f for f in LEFT_CORE if f.platform and f.platform == platform), None)
|
|
return f". {_install_sentence(feature)}" if feature else ""
|
|
|
|
|
|
def terminal_backend_install_hint(backend: str) -> str:
|
|
"""For a ``terminal.backend`` value that left core: the sentence that names its install command for
|
|
the active home, else ``""``. Appended to "unknown terminal backend" errors."""
|
|
feature = next((f for f in LEFT_CORE if f.terminal_backend and f.terminal_backend == backend), None)
|
|
return f". {_install_sentence(feature)}" if feature else ""
|
|
|
|
|
|
def _provider_feature(name: str) -> Optional[LeftCoreFeature]:
|
|
key = str(name or "").strip().lower()
|
|
return next((f for f in LEFT_CORE if key and key in f.providers), None)
|
|
|
|
|
|
def provider_install_hint(name: str) -> str:
|
|
"""For a provider name nothing resolves to that a left-core plugin supplies: the sentence naming its
|
|
install command for the active home (its enable command when installed but disabled; its load error and
|
|
update command when installed but it failed to load), else ``""``.
|
|
Appended to every "unknown provider" error (runtime resolution, ``/model``, ``hermes model``, ``hermes
|
|
auth add``)."""
|
|
feature = _provider_feature(name)
|
|
if feature is None:
|
|
return ""
|
|
from hermes_constants import get_hermes_home
|
|
home = Path(get_hermes_home())
|
|
if not plugin_present(feature.plugin, home):
|
|
return f"{_install_sentence(feature)}."
|
|
command = _install_command(feature.plugin, home)
|
|
if _plugin_lists(home, feature.plugin)[1]:
|
|
return (f"{feature.label} moved out of core into the '{feature.plugin}' plugin, which is installed but "
|
|
f"disabled: run `{command.replace(' install ', ' enable ')}`.")
|
|
# A model-provider plugin loads without plugins.enabled: present, not disabled and no profile = its load failed.
|
|
error = _load_error(feature)
|
|
return (f"{feature.label} moved out of core into the '{feature.plugin}' plugin, which is installed but failed "
|
|
f"to load{f' ({error})' if error else ''}: run `{command.replace(' install ', ' update ')}`, or "
|
|
f"see `hermes logs` for the cause.")
|
|
|
|
|
|
def _load_error(feature: LeftCoreFeature) -> str:
|
|
"""The error *feature*'s provider plugin raised on import in the active home, else ``""``."""
|
|
from providers import plugin_load_error
|
|
return plugin_load_error(feature.plugin)
|
|
|
|
|
|
def provider_left_core(name: str) -> bool:
|
|
"""*name* is a provider a left-core plugin supplies and no registered profile serves it now: no other
|
|
resolution rung (the models.dev catalog) may stand in for it (it would send the user's token to the
|
|
vendor's endpoint through a generic client)."""
|
|
if _provider_feature(name) is None:
|
|
return False
|
|
from providers import get_provider_profile
|
|
return get_provider_profile(str(name).strip().lower()) is None
|
|
|
|
|
|
def feature_in_use(feature: LeftCoreFeature, home: Path, *, process_env: bool = False) -> bool:
|
|
"""*home* uses *feature*: its :attr:`~LeftCoreFeature.in_use` predicate, or a configured route at one of
|
|
its :attr:`~LeftCoreFeature.hosts` (:func:`routes_at_hosts`)."""
|
|
return feature.in_use(home, process_env=process_env) or bool(feature.hosts and routes_at_hosts(home, feature.hosts))
|
|
|
|
|
|
def host_install_hint(base_url: str) -> str:
|
|
"""For a request rejected by an endpoint at a host a left-core plugin serves while none of that plugin's
|
|
providers is registered (core attached the host's wire quirks itself): the install (or enable) sentence
|
|
for the active home, else ``""``. Printed with non-retryable request errors."""
|
|
feature = next((f for f in LEFT_CORE if any(_at_host(str(base_url or ""), h) for h in f.hosts)), None)
|
|
if feature is None or not all(provider_left_core(name) for name in feature.providers):
|
|
return ""
|
|
return provider_install_hint(feature.providers[0])
|
|
|
|
|
|
def recover_for_provider(name: Optional[str]) -> None:
|
|
"""Runtime resolution of *name*, a provider a left-core plugin supplies, in a home that uses it and lacks
|
|
the plugin: run :func:`recover_at_startup` now. The CLI and Desktop resolve the provider before the agent
|
|
(whose start runs the migration) exists, so they would otherwise stop at "unknown provider" on a home no
|
|
``hermes update`` migrated. Outcomes print in a declared terminal front end (:func:`serve_notices_on_stderr`),
|
|
else wait for the home's next turn someone sees."""
|
|
from hermes_constants import get_hermes_home, hermes_home_key
|
|
from providers import get_provider_profile
|
|
home = Path(get_hermes_home())
|
|
name = str(name or "").strip().lower()
|
|
if name in ("", "auto"): # the auto rung follows auth.json's active_provider (a core-era choice)
|
|
name = str(_auth_store(home).get("active_provider") or "").strip().lower()
|
|
feature = _provider_feature(name)
|
|
if feature is None or _installs_held.get(): # held: a read-only diagnostic consumes no kept notice either
|
|
return
|
|
# A terminal front end shows outcomes now, also those another process (the gateway this CLI started) kept;
|
|
# otherwise (Desktop, gateway, a detached turn) the home's next watched turn in any process gets them. Never
|
|
# inferred from a TTY: a foreground `hermes gateway run` has one, and its resolutions serve the chat user.
|
|
say = (lambda message: print(message, file=sys.stderr)) if _terminal_front_end else None
|
|
if say is not None:
|
|
for message in _claim_notices(home):
|
|
say(message)
|
|
if hermes_home_key(home) in _attempted: # this process already tried: skip the config/auth/.env reads
|
|
return
|
|
if plugin_present(feature.plugin, home) or get_provider_profile(name) is not None:
|
|
return
|
|
if feature_in_use(feature, home, process_env=True):
|
|
recover_at_startup(say=say)
|
|
|
|
|
|
_terminal_front_end = False
|
|
|
|
|
|
def serve_notices_on_stderr() -> None:
|
|
"""This process is a terminal front end (the classic CLI, whose turns run in the gateway): outcomes of
|
|
:func:`recover_for_provider` print to its stderr, also when its output is piped (``hermes chat -q``),
|
|
since a resolution that fails before any agent exists (no credentials) leaves no agent to deliver them.
|
|
Only a declaration counts: a foreground gateway has a TTY too, and its outcomes belong to the chat user."""
|
|
global _terminal_front_end
|
|
_terminal_front_end = True
|
|
|
|
|
|
def profile_env_names() -> frozenset[str]:
|
|
"""Every row's :attr:`LeftCoreFeature.profile_env` (static: no discovery)."""
|
|
return frozenset(name for feature in LEFT_CORE for name in feature.profile_env)
|
|
|
|
|
|
def launch_env_names() -> frozenset[str]:
|
|
"""Every row's :attr:`LeftCoreFeature.launch_env` (static): the generic env writers refuse them."""
|
|
return frozenset(name for feature in LEFT_CORE for name in feature.launch_env)
|
|
|
|
|
|
def legacy_plugin_alias(name: str) -> str:
|
|
"""The catalog plugin a bundled key that left core now names (``observability/langfuse`` ->
|
|
``langfuse``), else ``""``."""
|
|
return next((f.plugin for f in LEFT_CORE if f.plugin_key and f.plugin_key == name), "")
|
|
|
|
|
|
def legacy_plugin_keys(plugin: str) -> set[str]:
|
|
"""Bundled keys that now name catalog *plugin* (inverse of :func:`legacy_plugin_alias`)."""
|
|
return {f.plugin_key for f in LEFT_CORE if f.plugin_key and f.plugin == plugin}
|
|
|
|
|
|
def moved_command_hint(prog: str, value: str, action: str = "") -> str:
|
|
"""For a command a left-core plugin took over: ``hermes auth [<action>] <cli>`` (core's old login
|
|
spelling), core's old nested spelling (``hermes mcp serve``), or the plugin's own ``hermes <cli>`` /
|
|
``hermes <command>`` / ``hermes <old_cli>`` while the plugin is not loaded, the line saying where it
|
|
went and how to get it for the active home, else ``""``."""
|
|
from hermes_constants import get_hermes_home
|
|
home = Path(get_hermes_home())
|
|
moved = next((f for f in LEFT_CORE if f.old_command and f.old_command == f"{prog} {value}"), None)
|
|
if moved is not None:
|
|
present = plugin_present(moved.plugin, home)
|
|
get = "" if present else f" Install it with: `{_install_command(moved.plugin, home)}`."
|
|
return (f"{moved.label} moved out of core into the '{moved.plugin}' plugin: `{moved.old_command}` is now "
|
|
f"`hermes {moved.command}`.{get}")
|
|
if prog == "hermes auth":
|
|
feature = next((f for f in LEFT_CORE if f.cli and f.cli == value), None)
|
|
elif prog == "hermes":
|
|
feature = next((f for f in LEFT_CORE if value and value in (f.cli, f.command, *f.old_cli)), None)
|
|
else:
|
|
feature = None
|
|
if feature is None:
|
|
return ""
|
|
install = _install_command(feature.plugin, home)
|
|
if not plugin_present(feature.plugin, home):
|
|
get = f" Install it with: `{install}`."
|
|
else:
|
|
get = f" Enable it with: `{install.replace(' install ', ' enable ')}`." if prog == "hermes" else ""
|
|
if prog == "hermes auth":
|
|
old = " ".join(p for p in ("hermes auth", action, value) if p)
|
|
return (f"{feature.label} moved out of core into the '{feature.plugin}' plugin: `{old}` is now "
|
|
f"`hermes {value} {action or 'login'}`.{get}")
|
|
label = f"`hermes {value}`" if value in feature.old_cli else feature.label
|
|
return f"{label} moved out of core into the '{feature.plugin}' plugin, which is not loaded.{get}"
|
|
|
|
|
|
def moved_plugin_hint(name: str) -> str:
|
|
"""For a plugin name nothing resolves to (so the plugin is not installed): the bundled key of a
|
|
plugin that left core (``observability/langfuse``) or its catalog name, the line naming the install
|
|
command for the active home, else ``""``. Appended to "No plugin named ..." errors."""
|
|
feature = next((f for f in LEFT_CORE if f.plugin_key and name in (f.plugin_key, f.plugin)), None)
|
|
return f"{_install_sentence(feature)}." if feature else ""
|
|
|
|
|
|
_attempted: set[str] = set()
|
|
_installs_held: ContextVar[bool] = ContextVar("left_core_installs_held", default=False)
|
|
|
|
|
|
@contextmanager
|
|
def installs_held():
|
|
"""No migration attempt inside (a read-only diagnostic such as ``hermes doctor``, whose provider checks run
|
|
the runtime resolver): the unknown-provider and host hints still name the install command."""
|
|
token = _installs_held.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_installs_held.reset(token)
|
|
|
|
|
|
def defer_startup_notice(message: str) -> None:
|
|
"""Hand *message* to the active home's next turn someone sees (:func:`claim_kept_notices`), once.
|
|
For boot-path work that runs before any agent exists."""
|
|
from hermes_constants import get_hermes_home
|
|
logger.warning(message)
|
|
_queue_notice(Path(get_hermes_home()), message)
|
|
|
|
|
|
def _notice_file(home: Path) -> Path:
|
|
return home / "cache" / "left-core-notices.jsonl"
|
|
|
|
|
|
@contextmanager
|
|
def _notices_locked(home: Path):
|
|
"""The cross-process lock every append and claim of *home*'s kept notices holds: an append never lands
|
|
in a file a claimant already read, and a claimant never reads a half-written line."""
|
|
import threading
|
|
from hermes_cli.auth import _file_lock
|
|
path = _notice_file(home)
|
|
# A fresh holder: the helper's per-thread reentrancy must never skip one home's lock inside another's.
|
|
with _file_lock(path.with_name(f"{path.name}.lock"), threading.local(), 5.0,
|
|
f"Timed out waiting for the left-core notice lock ({path})"):
|
|
yield path
|
|
|
|
|
|
def _queue_notice(home: Path, message: str) -> None:
|
|
"""Keep *message* for *home*'s next watched turn in ANY process (:func:`_claim_notices`): the process that
|
|
produced it (a CLI resolving the provider, a gateway start) often runs no agent for that home itself.
|
|
A line already kept is not kept again: every start with ``allow_lazy_installs`` off reports the same row."""
|
|
import json
|
|
line = json.dumps(message)
|
|
try:
|
|
with _notices_locked(home) as path:
|
|
if path.exists() and line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
return
|
|
with open(path, "a", encoding="utf-8") as fh:
|
|
fh.write(line + "\n")
|
|
except OSError as exc: # unwritable home or lock timeout: the outcome is still logged
|
|
logger.debug("left-core notice not kept for %s: %s", home, exc)
|
|
|
|
|
|
def _claim_notices(home: Path) -> list[str]:
|
|
"""Take every notice kept for *home*, exactly once across processes: read and removed under the notice
|
|
lock, so of several agents starting at once only one gets (and delivers) them. A crash before the
|
|
removal leaves them for the next claimant rather than losing them."""
|
|
import json
|
|
if not _notice_file(home).exists(): # the common case: no lock taken
|
|
return []
|
|
try:
|
|
with _notices_locked(home) as path:
|
|
lines = path.read_text(encoding="utf-8-sig").splitlines()
|
|
path.unlink()
|
|
except OSError: # another claimant took them first, or the lock timed out (they stay kept)
|
|
return []
|
|
messages = []
|
|
for line in dict.fromkeys(lines):
|
|
try:
|
|
messages.append(str(json.loads(line)))
|
|
except ValueError: # a line from outside Hermes: skip it only
|
|
continue
|
|
return messages
|
|
|
|
|
|
def claim_kept_notices() -> list[str]:
|
|
"""Every notice kept for the active home in any process, for a turn someone sees (exactly once)."""
|
|
from hermes_constants import get_hermes_home
|
|
return _claim_notices(Path(get_hermes_home()))
|
|
|
|
|
|
def plugin_present(plugin: str, home: Path) -> bool:
|
|
"""Installed in *home* (enabled or not: a user who disabled it chose to). Read-only."""
|
|
return (home / "plugins" / plugin).is_dir()
|
|
|
|
|
|
def _core_carried(feature: LeftCoreFeature, selection: list) -> bool:
|
|
"""Whether core resolved *feature*'s toolsets on for a platform selecting *selection*: named
|
|
directly, or through a ``hermes`` / ``hermes-<platform>`` composite that included them."""
|
|
def carries(name: str) -> bool:
|
|
if name in feature.toolsets or name in feature.replaces_toolsets or name == "hermes":
|
|
return True
|
|
return name.startswith("hermes-") and name[len("hermes-"):].replace("-", "_") not in feature.off_platforms
|
|
return any(carries(str(name)) for name in selection)
|
|
|
|
|
|
_SCOPED_KEY = "_left_core_scoped" # config.yaml: rows whose toolset scope this home already converted
|
|
# config.yaml: rows this home has had installed (by the migration, or by the user before it ran).
|
|
# Never written before the plugin dir exists, so a home whose install never succeeded keeps retrying.
|
|
_INSTALLED_KEY = "_left_core_installed"
|
|
|
|
|
|
# config.yaml: rows whose ``left_at`` config step found the core feature in use in this home.
|
|
_USED_KEY = "_left_core_used"
|
|
|
|
|
|
def record_core_use(home: Path, config: dict, version: int) -> bool:
|
|
"""Config step *version* hook, before it writes *config* (*home*'s raw config.yaml, still showing the
|
|
core-era setup): mark every row that left core at *version* and that *home* uses. Returns whether
|
|
*config* changed. The launch home's process env counts, as at agent start."""
|
|
used = [feature for feature in LEFT_CORE if feature.left_at == version
|
|
and not _marked(config, _USED_KEY, feature) and feature_in_use(feature, home, process_env=True)]
|
|
for feature in used:
|
|
_mark(config, _USED_KEY, feature)
|
|
return bool(used)
|
|
|
|
|
|
def _predates_split(home: Path, feature: LeftCoreFeature) -> bool:
|
|
"""*home* ran *feature* in core: its config is still below ``left_at`` (this pass ran before the
|
|
config step), that step recorded the feature in use, or config.yaml is missing or unstamped. Every
|
|
config.yaml Hermes writes carries a stamp, and the first save that stamps one records the rows in use
|
|
(``config_version_stamp.note_first_stamp``), so only a home from before the split lacks it; core ran
|
|
the feature there from its .env alone."""
|
|
if not feature.left_at:
|
|
return True
|
|
config = _read_config(home)
|
|
if _marked(config, _USED_KEY, feature) or "_config_version" not in config:
|
|
return True
|
|
try:
|
|
return int(config["_config_version"]) < feature.left_at
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _marked(config: dict, key: str, feature: LeftCoreFeature) -> bool:
|
|
done = config.get(key)
|
|
return isinstance(done, list) and feature.plugin in map(str, done)
|
|
|
|
|
|
def _scope_kept(config: dict, feature: LeftCoreFeature) -> bool:
|
|
return not feature.toolsets or _marked(config, _SCOPED_KEY, feature)
|
|
|
|
|
|
def _mark(config: dict, key: str, feature: LeftCoreFeature) -> None:
|
|
done = config.get(key)
|
|
config[key] = sorted({*(map(str, done) if isinstance(done, list) else ()), feature.plugin})
|
|
|
|
|
|
def _turn_off_replaced(config: dict, feature: LeftCoreFeature) -> None:
|
|
"""Turn *feature*'s ``replaces_toolsets`` off in *config*: every saved platform list naming one names
|
|
the plugin's toolsets instead (so turning core's toolset back on later stays that platform's choice),
|
|
and ``agent.disabled_toolsets`` takes them, which also covers platforms on a default composite. One the
|
|
user had already disabled there disables the plugin's toolsets too (they are on by default)."""
|
|
from agent.skill_utils import parse_config_string_list
|
|
from hermes_cli.toolset_validation import parse_platform_toolsets_value
|
|
replaced = set(feature.replaces_toolsets)
|
|
saved = config.get("platform_toolsets")
|
|
for platform, value in (saved.items() if isinstance(saved, dict) else ()):
|
|
selection = parse_platform_toolsets_value(value)
|
|
if selection is not None and replaced & set(map(str, selection)):
|
|
kept = [item for item in selection if str(item) not in replaced]
|
|
saved[platform] = kept + [ts for ts in feature.toolsets if ts not in map(str, kept)]
|
|
agent = config.get("agent") if isinstance(config.get("agent"), dict) else {}
|
|
disabled = parse_config_string_list(agent.get("disabled_toolsets")) if agent.get("disabled_toolsets") else []
|
|
turned_off = replaced if replaced.isdisjoint(disabled) else replaced | set(feature.toolsets)
|
|
agent["disabled_toolsets"] = [*disabled, *sorted(turned_off - set(disabled))]
|
|
config["agent"] = agent
|
|
|
|
|
|
def _record_migration(home: Path, feature: LeftCoreFeature) -> None:
|
|
"""Keep *feature*'s core-era toolset scope, and mark it installed once its plugin is in *home*,
|
|
in one config write.
|
|
|
|
Scope: record the toolsets in ``known_plugin_toolsets[platform]`` (= off) for every platform
|
|
where core had them off: a saved ``platform_toolsets`` list that does not carry them, or no list
|
|
on an ``off_platforms`` platform. Platforms core resolved them on for are left alone, except that
|
|
a row's ``replaces_toolsets`` go off everywhere (:func:`_turn_off_replaced`). A one-time
|
|
conversion per home and row, independent of installation: a plugin installed before the update
|
|
(it stays inert on a core that still ships the feature) still needs it. Completion is marked
|
|
under ``_left_core_scoped`` so a later run never undoes a choice the user made since
|
|
(``hermes tools``). Raises when config.yaml cannot be read or written."""
|
|
from hermes_cli.config import atomic_config_write, read_user_config_raw
|
|
from hermes_cli.config_backend import config_exists
|
|
from hermes_cli.toolset_validation import parse_platform_toolsets_value
|
|
path = home / "config.yaml"
|
|
created = not config_exists(path)
|
|
config = read_user_config_raw(path)
|
|
installed = plugin_present(feature.plugin, home) and not _marked(config, _INSTALLED_KEY, feature)
|
|
if _scope_kept(config, feature) and not installed:
|
|
return
|
|
if installed:
|
|
_mark(config, _INSTALLED_KEY, feature)
|
|
if not _scope_kept(config, feature):
|
|
saved = config.get("platform_toolsets")
|
|
saved = saved if isinstance(saved, dict) else {}
|
|
selections = {str(p): parse_platform_toolsets_value(v) for p, v in saved.items()}
|
|
for platform in feature.off_platforms:
|
|
selections.setdefault(platform, None)
|
|
known = config.get("known_plugin_toolsets")
|
|
known = known if isinstance(known, dict) else {}
|
|
for platform, selection in selections.items():
|
|
if selection is None and platform not in feature.off_platforms:
|
|
continue # no (valid) saved list: core's default composite carried the toolsets
|
|
if selection is not None and _core_carried(feature, selection):
|
|
continue
|
|
current = known.get(platform) if isinstance(known.get(platform), list) else []
|
|
if missing := [ts for ts in feature.toolsets if ts not in current]:
|
|
known[platform] = sorted({*map(str, current), *missing})
|
|
if known:
|
|
config["known_plugin_toolsets"] = known
|
|
if feature.replaces_toolsets:
|
|
_turn_off_replaced(config, feature)
|
|
_mark(config, _SCOPED_KEY, feature)
|
|
if created:
|
|
_stamp_created(home, config)
|
|
atomic_config_write(path, config)
|
|
|
|
|
|
def _stamp_created(home: Path, config: dict) -> None:
|
|
"""A missing config.yaml reads as the current schema, so no config step ever ran on this home: the file
|
|
the conversion creates carries that stamp (unstamped, every boot warns it predates the support floor)
|
|
and, as a plain save's first stamp does, the rows in use, or the stamp makes the home look post-split."""
|
|
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
|
from hermes_cli.config_version_stamp import note_first_stamp
|
|
config["_config_version"] = DEFAULT_CONFIG["_config_version"]
|
|
note_first_stamp(home, {}, config)
|
|
|
|
|
|
def _pending(home: Path, *, say: Callable[[str], None], process_env: bool = False,
|
|
backoff: bool = False) -> list[LeftCoreFeature]:
|
|
"""Rows *home* uses whose plugin it never had and that the catalog ships (a catalog miss is
|
|
reported through *say*). Converts the toolset scope of every row *home* uses once
|
|
(:func:`_record_migration`), installed or not; a row whose scope cannot be recorded is
|
|
reported and skipped, never installed unscoped. A row marked installed is done for good.
|
|
*backoff* (startup) skips a row whose last attempt failed recently, before any network."""
|
|
from hermes_cli.memory_provider_migration import catalog_source
|
|
out = []
|
|
for feature in LEFT_CORE:
|
|
if _marked(_read_config(home), _INSTALLED_KEY, feature):
|
|
continue
|
|
if not feature_in_use(feature, home, process_env=process_env):
|
|
continue
|
|
if not _predates_split(home, feature):
|
|
_say_post_split(home, feature, say=say, backoff=backoff, process_env=process_env)
|
|
continue
|
|
present = plugin_present(feature.plugin, home)
|
|
try:
|
|
_record_migration(home, feature)
|
|
except Exception as exc:
|
|
say(f" ⚠ {feature.label} moved out of core into the '{feature.plugin}' plugin: its per-platform "
|
|
f"toolset selection could not be kept ({exc}). Check `hermes tools`"
|
|
+ ("." if present else f" and run `{_install_command(feature.plugin, home)}`."))
|
|
continue
|
|
if present:
|
|
continue
|
|
if backoff and _failed_recently(home, feature.plugin):
|
|
logger.info("%s plugin install failed recently for %s; retrying later or on `hermes update`",
|
|
feature.label, home)
|
|
continue
|
|
if catalog_source(feature.plugin) is None:
|
|
_note_failure(home, feature.plugin)
|
|
say(_noticed(f" ⚠ {feature.label} moved out of core into the '{feature.plugin}' plugin, which this "
|
|
f"Hermes cannot find in the plugin catalog yet. Run "
|
|
f"`{_install_command(feature.plugin, home)}` once it is listed.", home, feature, process_env))
|
|
continue
|
|
out.append(feature)
|
|
return out
|
|
|
|
|
|
def _say_post_split(home: Path, feature: LeftCoreFeature, *, say: Callable[[str], None], backoff: bool,
|
|
process_env: bool = False) -> None:
|
|
"""*home* uses *feature* while its stamp says it never ran it in core (a newer home, or a Nix-managed
|
|
config.yaml stamped at activation): nothing is converted or installed, but a home without the plugin is
|
|
told where the feature went. Starts repeat it on the install backoff (``_note_failure``)."""
|
|
if plugin_present(feature.plugin, home) or (backoff and _failed_recently(home, feature.plugin)):
|
|
return
|
|
_note_failure(home, feature.plugin)
|
|
say(_noticed(f" ⚠ {feature.label} moved out of core into the '{feature.plugin}' plugin, which this profile "
|
|
f"does not have, so it is off. Run `{_install_command(feature.plugin, home)}`.",
|
|
home, feature, process_env))
|
|
|
|
|
|
def _noticed(message: str, home: Path, feature: LeftCoreFeature, process_env: bool) -> str:
|
|
"""*message* with *feature*'s :attr:`~LeftCoreFeature.notice` for *home* appended."""
|
|
extra = feature.notice(home, process_env=process_env)
|
|
return f"{message} {extra}" if extra else message
|
|
|
|
|
|
def _install_into(home: Path, *, consent: Optional[bool] = None) -> Callable[[str], dict]:
|
|
def _install(name: str) -> dict:
|
|
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
token = set_hermes_home_override(home)
|
|
try:
|
|
return dashboard_install_plugin("", force=False, enable=True, catalog_name=name,
|
|
assume_deps_consent=_unattended_consent() if consent is None
|
|
else consent)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
return _install
|
|
|
|
|
|
def _first_cause(error: str) -> str:
|
|
"""One line for a notice: git's own ``fatal:`` / ``error:`` line when *error* ends with raw git
|
|
stderr (a failed clone), else its first non-empty line."""
|
|
lines = [line.strip() for line in error.splitlines() if line.strip()]
|
|
cause = next((line.split(":", 1)[1].strip() for line in lines if line.startswith(("fatal:", "error:"))), None)
|
|
return (cause or (lines[0] if lines else "unknown error")).rstrip(". ")
|
|
|
|
|
|
def _install_one(home: Path, feature: LeftCoreFeature, *, install: Callable[[str], dict],
|
|
say: Callable[[str], None], process_env: bool = False) -> bool:
|
|
try:
|
|
result = install(feature.plugin)
|
|
except Exception as exc: # network, uv, kill list — report, do not raise
|
|
result = {"ok": False, "error": str(exc)}
|
|
if result.get("ok"):
|
|
from providers import refresh_bound_home
|
|
refresh_bound_home() # a provider plugin resolves in this same call, not after the stamp TTL
|
|
try:
|
|
_record_migration(home, feature)
|
|
except Exception as exc: # the next run sees the plugin dir and records it then
|
|
logger.debug("left-core install marker not written for %s: %s", home, exc)
|
|
say(_noticed(f" ✓ {feature.label} moved out of core — installed the '{feature.plugin}' plugin from the "
|
|
f"catalog ({feature.unchanged}).", home, feature, process_env))
|
|
return True
|
|
_note_failure(home, feature.plugin)
|
|
error = _first_cause(str(result.get("error") or ""))
|
|
say(_noticed(f" ⚠ {feature.label} moved out of core and its '{feature.plugin}' plugin could not be installed "
|
|
f"automatically: {error}. Run `{_install_command(feature.plugin, home)}`.", home, feature, process_env))
|
|
return False
|
|
|
|
|
|
def migrate_home(home: Path, *, install: Callable[[str], dict], say: Callable[[str], None] = print,
|
|
process_env: bool = False) -> list[str]:
|
|
"""Install every left-core plugin *home* uses and lacks. Returns installed plugin names; never raises."""
|
|
installed = []
|
|
for feature in _pending(home, say=say, process_env=process_env):
|
|
if _install_one(home, feature, install=install, say=say, process_env=process_env):
|
|
installed.append(feature.plugin)
|
|
return installed
|
|
|
|
|
|
def migrate_all_homes(*, say: Callable[[str], None] = print) -> list[str]:
|
|
"""``hermes update`` hook: every profile home sharing this venv. Grouped by plugin and each home's
|
|
unattended consent like the memory migration: homes of one group share dependency answers, and a
|
|
failure names the rest of its group in one line instead of failing them one by one."""
|
|
from hermes_cli.plugins_cmd_install import shared_dependency_answers
|
|
from hermes_constants import get_routing_process_hermes_home, hermes_home_key
|
|
from pm.plugins_state import dependency_homes
|
|
|
|
def labelled(home: Path) -> Callable[[str], None]:
|
|
return lambda message: say(f" [{_home_label(home)}] {message.lstrip()}")
|
|
|
|
def launched(home: Path) -> bool: # this process's env (a shell export) is the launch home's, as at start
|
|
return hermes_home_key(home) == hermes_home_key(get_routing_process_hermes_home())
|
|
|
|
pending: dict[tuple[str, bool], tuple[LeftCoreFeature, list[Path]]] = {}
|
|
for home in dependency_homes():
|
|
try:
|
|
features = _pending(home, say=labelled(home), process_env=launched(home))
|
|
consent = bool(features) and _home_consent(home)
|
|
except Exception as exc:
|
|
logger.debug("left-core migration skipped for %s: %s", home, exc)
|
|
continue
|
|
for feature in features:
|
|
pending.setdefault((feature.plugin, consent), (feature, []))[1].append(home)
|
|
|
|
installed: list[str] = []
|
|
try:
|
|
for feature, homes in pending.values():
|
|
if len(homes) > 1 and _interactive():
|
|
say(f" {feature.label} is used in {len(homes)} profiles "
|
|
f"({', '.join(_home_label(h) for h in homes)}); your answers to its dependency "
|
|
f"questions apply to all of them.")
|
|
with shared_dependency_answers():
|
|
for index, home in enumerate(homes):
|
|
if _install_one(home, feature, install=_install_into(home), say=labelled(home),
|
|
process_env=launched(home)):
|
|
installed.append(feature.plugin)
|
|
continue
|
|
rest = homes[index + 1:]
|
|
if rest:
|
|
say(f" ⚠ The '{feature.plugin}' plugin was not installed for "
|
|
f"{', '.join(_home_label(h) for h in rest)} either. Run "
|
|
+ ", ".join(f"`{_install_command(feature.plugin, h)}`" for h in rest) + ".")
|
|
break
|
|
except KeyboardInterrupt:
|
|
say(" ⚠ Plugin migration cancelled. Profiles already migrated keep their plugin; run "
|
|
"`hermes plugins install <name>` (with `-p <profile>`) for the rest.")
|
|
return installed
|
|
|
|
|
|
def _gateway_serves(home: Path) -> bool:
|
|
"""A live gateway serves *home*: it loaded its platforms before this agent-start install."""
|
|
try:
|
|
from gateway.status import resolve_gateway_liveness
|
|
return resolve_gateway_liveness(profile_dir=home, use_cache=False).running
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def recover_at_startup(*, say: Optional[Callable[[str], None]] = None, gateway_start: bool = False) -> list[str]:
|
|
"""Agent/gateway start hook for the active home: one attempt per process per home. With *say* (a
|
|
terminal's sink) outcomes are delivered now, together with any an earlier attempt or boot maintenance
|
|
(:func:`defer_startup_notice`) kept for this home in any process; without it they are logged and kept in
|
|
the home (:func:`_queue_notice`) for its next turn someone sees (:func:`claim_kept_notices`), in
|
|
whichever process. *gateway_start*: the gateway itself, which needs no restart hint. Returns installed
|
|
plugin names."""
|
|
from agent.safe_worker_policy import worker_config_snapshot
|
|
if _installs_held.get():
|
|
return []
|
|
if worker_config_snapshot() is not None:
|
|
# A frozen safe/ignore-user-config worker never reads the profile's config.yaml and never
|
|
# installs customizations; the owner process runs this migration for the home.
|
|
return []
|
|
from hermes_constants import get_hermes_home, hermes_home_key
|
|
|
|
home = Path(get_hermes_home())
|
|
key = hermes_home_key(home)
|
|
|
|
delivered: set[str] = set()
|
|
|
|
def deliver(message: str) -> None:
|
|
if say is None:
|
|
_queue_notice(home, message)
|
|
return
|
|
if message in delivered: # a kept copy of what this attempt reports again (allow_lazy_installs off)
|
|
return
|
|
delivered.add(message)
|
|
try:
|
|
say(message)
|
|
except Exception:
|
|
logger.debug("left-core migration notification failed", exc_info=True)
|
|
|
|
if say is not None:
|
|
for message in _claim_notices(home):
|
|
deliver(message)
|
|
if key in _attempted:
|
|
return []
|
|
_attempted.add(key)
|
|
|
|
def report(message: str) -> None:
|
|
message = message.strip()
|
|
logger.warning(message)
|
|
deliver(message)
|
|
|
|
try:
|
|
features = _pending(home, say=report, process_env=True, backoff=True)
|
|
if not features:
|
|
return []
|
|
from pm.install import lazy_installs_allowed
|
|
if not lazy_installs_allowed():
|
|
for feature in features:
|
|
report(_noticed(f"⚠ {feature.label} moved out of core and its '{feature.plugin}' plugin is not "
|
|
f"installed, so it is off. security.allow_lazy_installs is off, so Hermes did not "
|
|
f"fetch it: run `{_install_command(feature.plugin, home)}`.", home, feature, True))
|
|
return []
|
|
installed = []
|
|
# Agent init cannot answer a dependency prompt: under the CLI the chat input owns the terminal,
|
|
# so the question hangs at "Initializing agent...". Every row shipped in core, so its users
|
|
# already accepted its dependencies (as memory_provider_migration.recover_at_startup does).
|
|
for feature in features:
|
|
if _install_one(home, feature, install=_install_into(home, consent=True), say=report, process_env=True):
|
|
installed.append(feature.plugin)
|
|
if not gateway_start and feature.platform and _gateway_serves(home):
|
|
report(f"Restart the gateway (`hermes gateway restart`) so it serves {feature.label}.")
|
|
return installed
|
|
except Exception as exc: # never take agent/gateway start down
|
|
logger.warning("left-core plugin migration failed: %s", exc, exc_info=True)
|
|
return []
|
|
|
|
|
|
def recover_for_command(name: Optional[str]) -> None:
|
|
"""``hermes <name>`` names a command a left-core plugin registers, and the active home uses that
|
|
feature but lacks the plugin: run :func:`recover_at_startup` now, before plugin commands are
|
|
discovered, so the command it installs runs in this same invocation. A home nothing migrated yet
|
|
(a Docker image swap runs no ``hermes update``) whose only Hermes process is a scheduled
|
|
``hermes import-agent --sync`` never starts an agent or a gateway to do it."""
|
|
from hermes_constants import get_hermes_home
|
|
home = Path(get_hermes_home())
|
|
if any(name and name in (f.cli, f.command, *f.old_cli) and not plugin_present(f.plugin, home)
|
|
and feature_in_use(f, home) for f in LEFT_CORE):
|
|
recover_at_startup(say=lambda message: print(message, file=sys.stderr))
|