mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
Every curator pass now emits a dated report directory under
`~/.hermes/logs/curator/{YYYYMMDD-HHMMSS}/` with two files:
- `run.json` — machine-readable full record (before/after snapshot,
state transitions, all tool calls, model/provider, timing, full LLM
final response untruncated, error if any)
- `REPORT.md` — human-readable markdown: model + duration header,
auto-transition counts, LLM consolidation stats, archived-this-run
list, new-skills-this-run list, state transitions, the full LLM
final summary, and a recovery footer pointing at the archive + the
`hermes curator restore` command
Reports live under `logs/curator/`, not inside `skills/` — they're
operational telemetry, not user-authored skill data, and belong
alongside `agent.log` / `gateway.log`.
Internals:
- `_run_llm_review()` now returns a dict (final, summary, model,
provider, tool_calls, error) instead of a bare truncated string so
the reporter has full fidelity
- Report writer is fully best-effort — any failure logs at DEBUG and
never breaks the curator itself. Same-second rerun gets a numeric
suffix so reports can't clobber each other
- Report path stamped into `.curator_state` as `last_report_path`
- `hermes curator status` surfaces a "last report:" line so users
can immediately open the latest run
Tests (all green):
- 7 new tests in tests/agent/test_curator_reports.py covering: report
location (logs not skills), both files written, run.json shape and
diff accuracy, markdown structure, error path still writes, state
transitions captured, same-second runs get unique dirs
- Existing test_run_review_synchronous_invokes_llm_stub updated to
stub the new dict-returning _run_llm_review signature
Live E2E: ran a synchronous pass against a 1-skill test collection
with a stubbed LLM; report written correctly, state stamped with
last_report_path, markdown human-readable, run.json machine-parseable.
235 lines
7.3 KiB
Python
235 lines
7.3 KiB
Python
"""CLI subcommand: `hermes curator <subcommand>`.
|
|
|
|
Thin shell around agent/curator.py and tools/skill_usage.py. Renders a status
|
|
table, triggers a run, pauses/resumes, and pins/unpins skills.
|
|
|
|
This module intentionally has no side effects at import time — main.py wires
|
|
the argparse subparsers on demand.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
|
|
def _fmt_ts(ts: Optional[str]) -> str:
|
|
if not ts:
|
|
return "never"
|
|
try:
|
|
dt = datetime.fromisoformat(ts)
|
|
except (TypeError, ValueError):
|
|
return str(ts)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
delta = datetime.now(timezone.utc) - dt
|
|
secs = int(delta.total_seconds())
|
|
if secs < 60:
|
|
return f"{secs}s ago"
|
|
if secs < 3600:
|
|
return f"{secs // 60}m ago"
|
|
if secs < 86400:
|
|
return f"{secs // 3600}h ago"
|
|
return f"{secs // 86400}d ago"
|
|
|
|
|
|
def _cmd_status(args) -> int:
|
|
from agent import curator
|
|
from tools import skill_usage
|
|
|
|
state = curator.load_state()
|
|
enabled = curator.is_enabled()
|
|
paused = state.get("paused", False)
|
|
last_run = state.get("last_run_at")
|
|
summary = state.get("last_run_summary") or "(none)"
|
|
runs = state.get("run_count", 0)
|
|
|
|
status_line = (
|
|
"ENABLED" if enabled and not paused else
|
|
"PAUSED" if paused else
|
|
"DISABLED"
|
|
)
|
|
print(f"curator: {status_line}")
|
|
print(f" runs: {runs}")
|
|
print(f" last run: {_fmt_ts(last_run)}")
|
|
print(f" last summary: {summary}")
|
|
_report = state.get("last_report_path")
|
|
if _report:
|
|
print(f" last report: {_report}")
|
|
_ih = curator.get_interval_hours()
|
|
_interval_label = (
|
|
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
|
else f"{_ih}h"
|
|
)
|
|
print(f" interval: every {_interval_label}")
|
|
print(f" stale after: {curator.get_stale_after_days()}d unused")
|
|
print(f" archive after: {curator.get_archive_after_days()}d unused")
|
|
|
|
rows = skill_usage.agent_created_report()
|
|
if not rows:
|
|
print("\nno agent-created skills")
|
|
return 0
|
|
|
|
by_state = {"active": [], "stale": [], "archived": []}
|
|
pinned = []
|
|
for r in rows:
|
|
state_name = r.get("state", "active")
|
|
by_state.setdefault(state_name, []).append(r)
|
|
if r.get("pinned"):
|
|
pinned.append(r["name"])
|
|
|
|
print(f"\nagent-created skills: {len(rows)} total")
|
|
for state_name in ("active", "stale", "archived"):
|
|
bucket = by_state.get(state_name, [])
|
|
print(f" {state_name:10s} {len(bucket)}")
|
|
|
|
if pinned:
|
|
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
|
|
|
|
# Show top 5 least-recently-used active skills
|
|
active = sorted(
|
|
by_state.get("active", []),
|
|
key=lambda r: r.get("last_used_at") or r.get("created_at") or "",
|
|
)[:5]
|
|
if active:
|
|
print("\nleast recently used (top 5):")
|
|
for r in active:
|
|
last = _fmt_ts(r.get("last_used_at"))
|
|
print(f" {r['name']:40s} use={r.get('use_count', 0):3d} last_used={last}")
|
|
|
|
return 0
|
|
|
|
|
|
def _cmd_run(args) -> int:
|
|
from agent import curator
|
|
if not curator.is_enabled():
|
|
print("curator: disabled via config; enable with `curator.enabled: true`")
|
|
return 1
|
|
|
|
print("curator: running review pass...")
|
|
|
|
def _on_summary(msg: str) -> None:
|
|
print(msg)
|
|
|
|
result = curator.run_curator_review(
|
|
on_summary=_on_summary,
|
|
synchronous=bool(args.synchronous),
|
|
)
|
|
auto = result.get("auto_transitions", {})
|
|
if auto:
|
|
print(
|
|
f"auto: checked={auto.get('checked', 0)} "
|
|
f"stale={auto.get('marked_stale', 0)} "
|
|
f"archived={auto.get('archived', 0)} "
|
|
f"reactivated={auto.get('reactivated', 0)}"
|
|
)
|
|
if not args.synchronous:
|
|
print("llm pass running in background — check `hermes curator status` later")
|
|
return 0
|
|
|
|
|
|
def _cmd_pause(args) -> int:
|
|
from agent import curator
|
|
curator.set_paused(True)
|
|
print("curator: paused")
|
|
return 0
|
|
|
|
|
|
def _cmd_resume(args) -> int:
|
|
from agent import curator
|
|
curator.set_paused(False)
|
|
print("curator: resumed")
|
|
return 0
|
|
|
|
|
|
def _cmd_pin(args) -> int:
|
|
from tools import skill_usage
|
|
if not skill_usage.is_agent_created(args.skill):
|
|
print(
|
|
f"curator: '{args.skill}' is bundled or hub-installed — cannot pin "
|
|
"(only agent-created skills participate in curation)"
|
|
)
|
|
return 1
|
|
skill_usage.set_pinned(args.skill, True)
|
|
print(f"curator: pinned '{args.skill}' (will bypass auto-transitions)")
|
|
return 0
|
|
|
|
|
|
def _cmd_unpin(args) -> int:
|
|
from tools import skill_usage
|
|
if not skill_usage.is_agent_created(args.skill):
|
|
print(
|
|
f"curator: '{args.skill}' is bundled or hub-installed — "
|
|
"there's nothing to unpin (curator only tracks agent-created skills)"
|
|
)
|
|
return 1
|
|
skill_usage.set_pinned(args.skill, False)
|
|
print(f"curator: unpinned '{args.skill}'")
|
|
return 0
|
|
|
|
|
|
def _cmd_restore(args) -> int:
|
|
from tools import skill_usage
|
|
ok, msg = skill_usage.restore_skill(args.skill)
|
|
print(f"curator: {msg}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argparse wiring (called from hermes_cli.main)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register_cli(parent: argparse.ArgumentParser) -> None:
|
|
"""Attach `curator` subcommands to *parent*.
|
|
|
|
main.py calls this with the ArgumentParser returned by
|
|
``subparsers.add_parser("curator", ...)``.
|
|
"""
|
|
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
|
subs = parent.add_subparsers(dest="curator_command")
|
|
|
|
p_status = subs.add_parser("status", help="Show curator status and skill stats")
|
|
p_status.set_defaults(func=_cmd_status)
|
|
|
|
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
|
p_run.add_argument(
|
|
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
|
help="Wait for the LLM review pass to finish (default: background thread)",
|
|
)
|
|
p_run.set_defaults(func=_cmd_run)
|
|
|
|
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")
|
|
p_pause.set_defaults(func=_cmd_pause)
|
|
|
|
p_resume = subs.add_parser("resume", help="Resume a paused curator")
|
|
p_resume.set_defaults(func=_cmd_resume)
|
|
|
|
p_pin = subs.add_parser("pin", help="Pin a skill so the curator never auto-transitions it")
|
|
p_pin.add_argument("skill", help="Skill name")
|
|
p_pin.set_defaults(func=_cmd_pin)
|
|
|
|
p_unpin = subs.add_parser("unpin", help="Unpin a skill")
|
|
p_unpin.add_argument("skill", help="Skill name")
|
|
p_unpin.set_defaults(func=_cmd_unpin)
|
|
|
|
p_restore = subs.add_parser("restore", help="Restore an archived skill")
|
|
p_restore.add_argument("skill", help="Skill name")
|
|
p_restore.set_defaults(func=_cmd_restore)
|
|
|
|
|
|
def cli_main(argv=None) -> int:
|
|
"""Standalone entry (also usable by hermes_cli.main fallthrough)."""
|
|
parser = argparse.ArgumentParser(prog="hermes curator")
|
|
register_cli(parser)
|
|
args = parser.parse_args(argv)
|
|
fn = getattr(args, "func", None)
|
|
if fn is None:
|
|
parser.print_help()
|
|
return 0
|
|
return int(fn(args) or 0)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
sys.exit(cli_main())
|