From e8e5985ce6ad35c5a418feb4e2237024997e29b6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:52:28 -0700 Subject: [PATCH] fix(curator): seed defaults on update, create logs/curator dir, defer fire import (#17927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes bundled for curator reliability on existing installs and broken/partial installs: 1. run_agent.py: defer `import fire` into the __main__ block. `fire` is only used by `fire.Fire(main)` when running run_agent.py directly as a CLI — it is NOT needed for library usage. Importing it at module top made `from run_agent import AIAgent` from a daemon thread (e.g. the curator's forked review agent) crash with ModuleNotFoundError on broken/partial installs where `fire` isn't present. 2. hermes_cli/config.py: add version 22 → 23 migration that writes the `curator` + `auxiliary.curator` sections to config.yaml with their defaults, only filling keys the user hasn't overridden. Existing configs from before PR #16049 / the April 2026 `auxiliary.curator` unification had neither section on disk, so users couldn't see or edit the settings in their config.yaml (runtime deep-merge papered over it at read time, but the file never reflected reality). 3. hermes_cli/config.py: `ensure_hermes_home()` now pre-creates `~/.hermes/logs/curator/` alongside cron/sessions/logs/memories on every CLI launch. Managed-mode (NixOS) variant mkdir's it defensively after the activation-script existence checks, since the activation script may not know about this subpath. 4. agent/curator.py: `_reports_root()` mkdir's the dir at call time as belt-and-suspenders for entry paths that bypass both ensure_hermes_home() and the v23 migration (gateway-only installs, bare library use). E2E validated in isolated HERMES_HOME: fresh install gets full defaults seeded; partial-override config keeps user's `enabled: false` and custom `interval_hours` while filling the missing keys; re-running the migration is a no-op. --- agent/curator.py | 13 ++++++- hermes_cli/config.py | 92 +++++++++++++++++++++++++++++++++++++++++++- run_agent.py | 8 +++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/agent/curator.py b/agent/curator.py index 044f9904c18..b1def04b741 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -365,8 +365,19 @@ def _reports_root() -> Path: alongside ``agent.log`` and ``gateway.log`` so it's found by anyone looking for operational telemetry, not mixed in with the user's authored skill data in ``~/.hermes/skills/``. + + ``ensure_hermes_home()`` pre-creates this dir on every CLI launch and + the v22→v23 migration backfills it for existing profiles, but we + still mkdir here as a belt-and-suspenders so the curator works even + from an odd entry path (e.g. gateway-only install, bare library use) + that bypasses both. """ - return get_hermes_home() / "logs" / "curator" + root = get_hermes_home() / "logs" / "curator" + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as e: + logger.debug("Curator reports dir create failed: %s", e) + return root def _write_run_report( diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d1a8c2e35d5..e880e936ab4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -350,7 +350,7 @@ def ensure_hermes_home(): else: home.mkdir(parents=True, exist_ok=True) _secure_dir(home) - for subdir in ("cron", "sessions", "logs", "memories"): + for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"): d = home / subdir d.mkdir(parents=True, exist_ok=True) _secure_dir(d) @@ -371,6 +371,10 @@ def _ensure_hermes_home_managed(home: Path): f"{d} does not exist. " "Run 'sudo nixos-rebuild switch' first." ) + # Curator reports dir is a sub-path of logs/; create it if missing. + # In managed mode the activation script may not know about this subdir, + # so we mkdir it ourselves (it's inside an already-secured logs/ dir). + (home / "logs" / "curator").mkdir(parents=True, exist_ok=True) # Inside umask(0o007) scope — SOUL.md will be created as 0660 _ensure_default_soul_md(home) @@ -1201,7 +1205,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 22, + "_config_version": 23, } # ============================================================================= @@ -3274,6 +3278,90 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A "Use `hermes plugins enable ` to activate." ) + # ── Version 22 → 23: seed curator defaults + create logs/curator/ ── + # The curator (background skill maintenance) was added in PR #16049, but + # existing configs from before that PR (or before the April 2026 + # unification under `auxiliary.curator`) never wrote the curator section + # to disk. The runtime deep-merge in `load_config()` fills defaults at + # read time, so the curator *functions*; but users can't see/edit the + # settings in their `config.yaml`, and `hermes curator status` has no + # stable logs dir to point at until the first run mkdir's it. + # + # This migration: + # 1. Writes the `curator` top-level section to config.yaml (enabled, + # interval_hours, min_idle_hours, stale_after_days, archive_after_days) + # — only keys the user hasn't already overridden. + # 2. Writes the `auxiliary.curator` aux-task slot (provider, model, + # base_url, api_key, timeout, extra_body) — canonical slot for + # routing the curator fork to a cheaper aux model. + # 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders + # on top of ensure_hermes_home() — old profiles that predate this + # migration still benefit). + if current_ver < 23: + try: + curator_dir = get_hermes_home() / "logs" / "curator" + curator_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + results["warnings"].append(f"Could not create {curator_dir}: {e}") + + config = read_raw_config() + touched = False + + # (1) Top-level curator section — only add missing keys + _curator_defaults = DEFAULT_CONFIG.get("curator", {}) + raw_curator = config.get("curator") + if not isinstance(raw_curator, dict): + raw_curator = {} + added_curator: List[str] = [] + for k, v in _curator_defaults.items(): + if k not in raw_curator: + raw_curator[k] = copy.deepcopy(v) + added_curator.append(k) + if added_curator: + config["curator"] = raw_curator + touched = True + + # (2) auxiliary.curator task slot + _aux_curator_defaults = ( + DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {}) + ) + raw_aux = config.get("auxiliary") + if not isinstance(raw_aux, dict): + raw_aux = {} + raw_aux_curator = raw_aux.get("curator") + if not isinstance(raw_aux_curator, dict): + raw_aux_curator = {} + added_aux: List[str] = [] + for k, v in _aux_curator_defaults.items(): + if k not in raw_aux_curator: + raw_aux_curator[k] = copy.deepcopy(v) + added_aux.append(k) + if added_aux: + raw_aux["curator"] = raw_aux_curator + config["auxiliary"] = raw_aux + touched = True + + if touched: + save_config(config) + if added_curator: + results["config_added"].append( + f"curator ({len(added_curator)} default key(s))" + ) + if not quiet: + print( + " ✓ Seeded curator defaults in config.yaml: " + f"{', '.join(added_curator)}" + ) + if added_aux: + results["config_added"].append( + f"auxiliary.curator ({len(added_aux)} default key(s))" + ) + if not quiet: + print( + " ✓ Seeded auxiliary.curator defaults in config.yaml: " + f"{', '.join(added_aux)}" + ) + if current_ver < latest_ver and not quiet: print(f"Config version: {current_ver} → {latest_ver}") diff --git a/run_agent.py b/run_agent.py index e37e1bb596e..c9801b67957 100644 --- a/run_agent.py +++ b/run_agent.py @@ -47,7 +47,12 @@ from urllib.parse import urlparse, parse_qs, urlunparse # (a) the single in-module `OpenAI(**client_kwargs)` call site at # _create_openai_client, and # (b) `patch("run_agent.OpenAI", ...)` test patterns used by ~28 test files. -import fire +# +# NOTE: `fire` is ONLY used in the `__main__` block below (for running +# run_agent.py directly as a CLI) — it is NOT needed for library usage. +# It is imported there, not here, so that importing run_agent from a +# daemon thread (e.g. curator's forked review agent) never fails with +# ModuleNotFoundError on broken/partial installs where `fire` isn't present. from datetime import datetime from pathlib import Path @@ -13844,4 +13849,5 @@ def main( if __name__ == "__main__": + import fire fire.Fire(main)