mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
puts back what that build deliberately left for the remote backend, on main's seam shape: - RemoteBackend implements main's protocol (read_user_doc, probe_readable) and is not a FileBackend; _route returns (backend, home) and explicit files go through _ExplicitFile, so the is_layer branches are gone. - One bootstrap: _ensure_bootstrap_env / bootstrap_deployment select the backend from the process env, the launch home's .env and the managed .env. #134362's _dotenv_backend_override did not survive its file-only refactor, so nothing stacks; its rules are now tests (stale shell remote + .env file boots file; empty .env value resets to file; .env-only remote selects remote; an absent key keeps the shell value). - The file-tooling gates main removed (backup/import/snapshot restore, config edit, migration write-back, config backups, profile clone and distribution install, raw config editor, xAI migration, gateway --config). - profile_serve_signature uses the backend version for config.yaml. - restore_config_model_settings_if_rewritten is a no-op without file tooling. - PM's dependency runtime reads a home's selection from the local file it publishes to (config_backend.local_file_layer), so a remote deployment never needs the plane there or fails PM closed. - Ratchet: _container_slots/_validate_quoted_containers move to config_value_checks, _count_cron_jobs to backup_restore, the gateway --config load to gateway/config_loader.
123 lines
5.4 KiB
Python
123 lines
5.4 KiB
Python
"""Value checks for single config sections (voice, timezone, web backends), run by
|
|
``hermes_cli.config.validate_config_structure`` for doctor and the startup warnings."""
|
|
|
|
from typing import Any
|
|
|
|
from hermes_cli.config_issues import ConfigIssue, _issue
|
|
|
|
|
|
def _validate_voice(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
|
|
voice_cfg = config.get("voice")
|
|
if not (isinstance(voice_cfg, dict) and "submit_mode" in voice_cfg):
|
|
return
|
|
submit_mode = voice_cfg.get("submit_mode")
|
|
normalized = submit_mode.strip().lower() if isinstance(submit_mode, str) else None
|
|
if normalized not in {"direct", "draft"}:
|
|
_issue(issues, "error", f"voice.submit_mode must be 'direct' or 'draft', got {submit_mode!r}",
|
|
"Set voice.submit_mode to direct (submit immediately) or draft (edit before sending)")
|
|
|
|
|
|
def _validate_timezone(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
|
|
"""``timezone`` must be an IANA name the runtime can load.
|
|
|
|
``hermes_time._get_zoneinfo()`` swallows an invalid name behind a single WARNING in the
|
|
gateway log, then runs the agent clock AND every cron schedule on server-local time.
|
|
Surface it here, where doctor and the startup check both look. Silent when the
|
|
interpreter has no tz database at all (bare Windows without ``tzdata``) — nothing can be
|
|
judged there.
|
|
"""
|
|
if "timezone" not in config:
|
|
return
|
|
tz = config.get("timezone")
|
|
hint = ("Use an IANA zone name such as America/New_York or Asia/Tokyo (see "
|
|
"`timedatectl list-timezones`). With an invalid value the agent clock and cron "
|
|
"schedules silently fall back to server-local time. HERMES_TIMEZONE overrides "
|
|
"this key when set.")
|
|
if tz is not None and not isinstance(tz, str):
|
|
_issue(issues, "error", f"timezone must be an IANA zone name string, got {tz!r}", hint)
|
|
return
|
|
if not (isinstance(tz, str) and tz.strip()):
|
|
return
|
|
name = tz.strip()
|
|
try:
|
|
import zoneinfo
|
|
zoneinfo.ZoneInfo("UTC") # is a tz database available at all?
|
|
except Exception:
|
|
return
|
|
try:
|
|
zoneinfo.ZoneInfo(name)
|
|
except Exception:
|
|
_issue(issues, "error", f"timezone {name!r} is not a valid IANA zone name", hint)
|
|
|
|
|
|
def _validate_web_backends(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
|
|
"""A stale web backend selection otherwise fails only at the first web_search/web_extract
|
|
call with a generic "no registered provider" error; warn at startup instead."""
|
|
# See #99199.
|
|
web_cfg = config.get("web")
|
|
if not isinstance(web_cfg, dict):
|
|
return
|
|
try:
|
|
from tools.tool_backend_helpers import removed_backend_note
|
|
except Exception:
|
|
return
|
|
seen: set = set()
|
|
for _key in ("backend", "search_backend", "extract_backend"):
|
|
_val = str(web_cfg.get(_key) or "").strip().lower()
|
|
if not _val or _val in seen:
|
|
continue
|
|
seen.add(_val)
|
|
note = removed_backend_note("web", _val)
|
|
if note:
|
|
_issue(issues, "warning",
|
|
f"web.{_key} is set to '{_val}', but {note} — "
|
|
"web_search/web_extract will fail until it is changed",
|
|
"Run 'hermes tools' and pick a different Web Search & Extract provider")
|
|
|
|
|
|
def _container_slots() -> dict[str, str]:
|
|
"""Dotted key -> ``"list"``/``"mapping"`` for every slot the schema fixes to a container:
|
|
``DEFAULT_CONFIG`` (sections included) plus the known-container table for roots it omits."""
|
|
from hermes_cli.config import _KNOWN_CONTAINER_TYPES, DEFAULT_CONFIG # late: the facade imports this module
|
|
slots: dict[str, str] = {}
|
|
|
|
def walk(node: dict[str, Any], prefix: str) -> None:
|
|
for key, value in node.items():
|
|
path = f"{prefix}.{key}" if prefix else key
|
|
if isinstance(value, dict):
|
|
slots[path] = "mapping"
|
|
walk(value, path)
|
|
elif isinstance(value, list):
|
|
slots[path] = "list"
|
|
|
|
walk(DEFAULT_CONFIG, "")
|
|
slots.update(_KNOWN_CONTAINER_TYPES)
|
|
return slots
|
|
|
|
|
|
def _validate_quoted_containers(config: dict[str, Any], issues: list[ConfigIssue]) -> None:
|
|
"""A container slot holding ONE quoted string (``enabled: '["a","b"]'``) is skipped by every
|
|
isinstance-gated reader while ``config get`` echoes it back, so plugins silently unmount and
|
|
exclusions silently lapse (#83308, #105706). Finding only — the file is never rewritten."""
|
|
import shlex
|
|
|
|
import hermes_yaml as yaml
|
|
from hermes_cli.config import _SCALAR_AS_ONE_ITEM_LIST_KEYS, _looks_structured_value, cfg_get
|
|
for key, kind in _container_slots().items():
|
|
# ``parse_config_string_list`` readers accept the quoted form; nothing is ignored there.
|
|
if key in _SCALAR_AS_ONE_ITEM_LIST_KEYS:
|
|
continue
|
|
value = cfg_get(config, *key.split("."))
|
|
if not isinstance(value, str) or not _looks_structured_value(value):
|
|
continue
|
|
try:
|
|
parsed = yaml.safe_load(value)
|
|
except yaml.YAMLError:
|
|
continue
|
|
if isinstance(parsed, (list, dict)):
|
|
_issue(issues, "warning",
|
|
f"{key} is the quoted string {value!r} — Hermes expects a YAML {kind} here "
|
|
"and every reader ignores the string",
|
|
f"Run: hermes config set {key} {shlex.quote(value)} (stores a real {kind}), "
|
|
"or remove the quotes in config.yaml")
|