Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions agent/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ def get_archive_after_days() -> int:
return DEFAULT_ARCHIVE_AFTER_DAYS


def get_prune_builtins() -> bool:
"""Whether the curator may prune (archive) bundled built-in skills too.

ON by default. When on, built-ins become curation candidates and are
archived after the same inactivity period as agent-created skills, with a
suppression list keeping them archived across `hermes update` re-seeds.
Hub-installed skills are never pruned regardless of this flag.
"""
cfg = _load_config()
return bool(cfg.get("prune_builtins", True))


# ---------------------------------------------------------------------------
# Idle / interval check
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -254,24 +266,39 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
# ---------------------------------------------------------------------------

def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
"""Walk every agent-created skill and move active/stale/archived based on
"""Walk every curator-managed skill and move active/stale/archived based on
the latest real activity timestamp. Pinned skills are never touched.
Returns a counter dict describing what changed."""

Built-ins (eligible only when ``curator.prune_builtins`` is on) are seeded
with a baseline record the first time they're seen so their inactivity
clock starts NOW rather than at epoch — a long-unused built-in is therefore
archived only after a fresh ``archive_after_days`` of non-use, not on the
first pass after the flag flips on.

Returns a counter dict describing what changed.
"""
from tools import skill_usage as _u

if now is None:
now = datetime.now(timezone.utc)
stale_cutoff = now - timedelta(days=get_stale_after_days())
archive_cutoff = now - timedelta(days=get_archive_after_days())

counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0}
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}

for row in _u.agent_created_report():
counts["checked"] += 1
name = row["name"]
if row.get("pinned"):
continue

# First sight of a curation-eligible skill with no persisted record
# (e.g. a newly-eligible built-in): anchor its clock to now and defer.
if not row.get("_persisted", True):
_u.seed_record_if_missing(name)
counts["seeded"] += 1
continue

last_activity = _parse_iso(row.get("last_activity_at"))
# If never active, treat created_at as the anchor so new skills don't
# immediately archive themselves.
Expand Down Expand Up @@ -1484,14 +1511,30 @@ def _llm_pass():
"error": None,
}
else:
# When pruning built-ins is enabled, the candidate list now
# includes bundled skills. Override the default "don't touch
# bundled" rule for them — but only archiving is permitted, and
# hub-installed skills remain strictly off-limits.
builtins_note = ""
if get_prune_builtins():
builtins_note = (
"\n\nPRUNE-BUILTINS MODE IS ON: bundled built-in skills "
"ARE included in the candidate list below and MAY be "
"archived for staleness/irrelevance, overriding hard "
"rule #1 for bundled skills ONLY. Hub-installed skills "
"remain strictly off-limits. Treat a stale built-in the "
"same as a stale agent-created skill: archive it (never "
"delete). It will be restored on `hermes update` only if "
"the user explicitly restores it."
)
if dry_run:
prompt = (
f"{CURATOR_DRY_RUN_BANNER}\n\n"
f"{CURATOR_REVIEW_PROMPT}\n\n"
f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n"
f"{candidate_list}"
)
else:
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
prompt = f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n{candidate_list}"
llm_meta = _run_llm_review(prompt)
final_summary = (
f"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}"
Expand Down
2 changes: 2 additions & 0 deletions agent/curator_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
pointer — otherwise the curator would immediately re-fire on the next
tick)
- ``.bundled_manifest`` (so protection markers stay consistent)
- ``.curator_suppressed`` (so rollback restores the set of pruned built-ins
the re-seeder must leave archived)

Alongside the skills tarball, each snapshot also captures a copy of
``~/.hermes/cron/jobs.json`` as ``cron-jobs.json`` when it exists. Cron
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,17 @@ def _ensure_hermes_home_managed(home: Path):
# Archive a skill (move to skills/.archive/) after this many days
# without use. Archived skills are recoverable — no auto-deletion.
"archive_after_days": 90,
# Also prune (archive) bundled built-in skills after the inactivity
# period, not just agent-created ones. ON by default. Built-ins are
# normally restored on every `hermes update`, so pruning them only
# sticks because a suppression list tells the re-seeder to leave them
# archived. Hub-installed skills are NEVER pruned here — they have an
# external upstream owner. Built-ins accrue usage telemetry and their
# inactivity clock starts the first time the curator sees them, so a
# long-unused built-in is archived only after archive_after_days of
# genuine non-use (never a mass-prune on the first run). Set to false
# to keep all bundled built-ins permanently.
"prune_builtins": True,
# Pre-run backup: before every real curator pass (dry-run is
# skipped), snapshot ~/.hermes/skills/ into
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
Expand Down
125 changes: 125 additions & 0 deletions tests/agent/test_curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def curator_env(tmp_path, monkeypatch):

# Default: no config file → curator defaults. Tests can override.
monkeypatch.setattr(curator, "_load_config", lambda: {})
# Pin prune_builtins OFF by default so transition tests don't pick up
# built-ins unless they explicitly enable it. Both config-reading paths
# are pinned (curator reads via _load_config; skill_usage reads config
# directly). Tests opt in with _enable_prune_builtins(...).
monkeypatch.setattr(usage, "_prune_builtins_enabled", lambda: False)

return {"home": home, "curator": curator, "usage": usage}

Expand Down Expand Up @@ -285,6 +290,126 @@ def test_bundled_skill_not_touched_by_transitions(curator_env):
assert (skills_dir / "bundled").exists() # never moved


# ---------------------------------------------------------------------------
# prune_builtins: curator may archive bundled built-ins after inactivity
# ---------------------------------------------------------------------------

def _enable_prune_builtins(curator_env, monkeypatch):
"""Flip curator.prune_builtins on for both config-reading paths."""
c = curator_env["curator"]
u = curator_env["usage"]
monkeypatch.setattr(c, "_load_config", lambda: {"prune_builtins": True})
monkeypatch.setattr(u, "_prune_builtins_enabled", lambda: True)


def _disable_prune_builtins(curator_env, monkeypatch):
"""Flip curator.prune_builtins off for both config-reading paths."""
c = curator_env["curator"]
u = curator_env["usage"]
monkeypatch.setattr(c, "_load_config", lambda: {"prune_builtins": False})
monkeypatch.setattr(u, "_prune_builtins_enabled", lambda: False)


def test_prune_builtins_default_on(curator_env):
# Shipped default is ON: with no explicit config, built-ins are eligible.
c = curator_env["curator"]
# _load_config returns {} (fixture) → default True surfaces.
assert c.get_prune_builtins() is True


def test_prune_builtins_off_excludes_bundled(curator_env, monkeypatch):
c = curator_env["curator"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")

# Explicitly off → bundled is not a candidate (the opt-out path).
_disable_prune_builtins(curator_env, monkeypatch)
assert c.get_prune_builtins() is False
counts = c.apply_automatic_transitions()
assert counts["checked"] == 0
assert (skills_dir / "bundled").exists()


def test_prune_builtins_seeds_clock_on_first_sight(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)

# First pass: built-in has no record yet → it's seeded, NOT archived,
# even though it's "old" on disk. The inactivity clock starts now.
counts = c.apply_automatic_transitions()
assert counts["checked"] == 1
assert counts["seeded"] == 1
assert counts["archived"] == 0
assert (skills_dir / "bundled").exists()
# A record now exists with created_at ~ now.
assert isinstance(u.load_usage().get("bundled"), dict)


def test_prune_builtins_archives_stale_bundled_and_suppresses(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)

# Seed a record whose last activity is far past the archive cutoff.
super_old = (datetime.now(timezone.utc) - timedelta(days=500)).isoformat()
data = u.load_usage()
data["bundled"] = u._empty_record()
data["bundled"]["last_used_at"] = super_old
u.save_usage(data)

counts = c.apply_automatic_transitions()
assert counts["archived"] == 1
# Directory moved into .archive/, suppression recorded so update won't restore.
assert not (skills_dir / "bundled").exists()
assert (skills_dir / ".archive" / "bundled").exists()
assert "bundled" in u.read_suppressed_names()


def test_prune_builtins_restore_clears_suppression(curator_env, monkeypatch):
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "bundled")
(skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
_enable_prune_builtins(curator_env, monkeypatch)

ok, _ = u.archive_skill("bundled")
assert ok
assert "bundled" in u.read_suppressed_names()

ok, _ = u.restore_skill("bundled")
assert ok
assert (skills_dir / "bundled").exists()
assert "bundled" not in u.read_suppressed_names()


def test_prune_builtins_never_touches_hub_skills(curator_env, monkeypatch):
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "hubskill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir(parents=True, exist_ok=True)
(hub_dir / "lock.json").write_text(
'{"version": 1, "installed": {"hubskill": {"install_path": "hubskill"}}}',
encoding="utf-8",
)
_enable_prune_builtins(curator_env, monkeypatch)

# Even with prune_builtins on, hub-installed skills stay off-limits.
assert u.is_curation_eligible("hubskill") is False
ok, msg = u.archive_skill("hubskill")
assert ok is False
assert "hub-installed" in msg
assert (skills_dir / "hubskill").exists()


# ---------------------------------------------------------------------------
# run_curator_review orchestration
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading