mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
Based on three live test runs against 346 agent-created skills on the author's own setup (~6.5 min, opus-4.7, 86 API calls), the curator prompt needed three sharpenings before it consistently produced real umbrella consolidation instead of passive audit output: **Umbrella-first framing.** The original 'decide keep/patch/archive/ consolidate' framing lets opus default to 'keep' whenever two skills aren't byte-identical. The new prompt explicitly tells the reviewer that pairwise distinctness is the wrong bar — the right question is 'would a human maintainer write this as N separate skills, or one skill with N labeled subsections?' Expect 10-25 prefix clusters; merge each into an umbrella via one of three methods. **Three concrete consolidation methods.** (a) Merge into an existing umbrella (patch the broadest skill, archive siblings); (b) Create a new umbrella SKILL.md (skill_manage action=create); (c) Demote session-specific detail into references/, templates/, or scripts/ under the umbrella via skill_manage action=write_file, then archive the narrow sibling. This matches the support-file vocabulary the review-prompt side already uses (PR #17213). **Two observed bailouts pre-empted:** 'usage counters are zero so I can't judge' (rule 4: judge on content, not use_count) and 'each has a distinct trigger' (rule 5: pairwise distinctness is the wrong bar). **Config-aware parent inheritance.** _run_llm_review() was building AIAgent() without explicit provider/model, hitting an auto-resolve path that returned empty credentials → HTTP 400 'No models provided' against OpenRouter. Fork now inherits the user's main provider and model (via load_config + resolve_runtime_provider) before spawning — runs on whatever the user is currently on, OAuth-backed or pool-backed included. **Unbounded iteration ceiling.** max_iterations=8 was way too low for an umbrella-build pass over hundreds of skills. A live pass takes 50-100 API calls (scanning, clustering, skill_view'ing candidates, patching umbrellas, mv'ing siblings). Raised to 9999 — the natural stopping criterion is 'no more clusters worth processing', not an arbitrary tool-call budget. **Tests updated:** test_curator_review_prompt_has_invariants accepts DO NOT / MUST NOT and drops 'keep' from the required-verb set (the umbrella-first prompt correctly deemphasizes 'keep' as a first-class decision label since passive keep-everything is the failure mode being prevented). Added test_curator_review_prompt_is_umbrella_first asserting the umbrella framing, class-level thinking, references/ + templates/ + scripts/ support-file mentions, and the 'use_count is not evidence of value' pre-emption. Added test_curator_review_prompt_offers_support_file_actions asserting skill_manage action=create and action=write_file are both named. **Live validation on author's setup:** - Run 1 (old prompt): 3 archives, stopped after surveying — typical passive outcome - Run 2 (consolidation prompt): 44 archives, 3 patches, surfaced the 50-skill mlops reorg duplicate bug but didn't umbrella - Run 3 (this prompt): 249 archives + 18 new class-level umbrellas created, reducing agent-created skills from 346 → 118 with every archived skill's content preserved as references/ under its umbrella. Pinned skill untouched. Full report in PR description.
561 lines
22 KiB
Python
561 lines
22 KiB
Python
"""Curator — background skill maintenance orchestrator.
|
|
|
|
The curator is an auxiliary-model task that periodically reviews agent-created
|
|
skills and maintains the collection. It runs inactivity-triggered (no cron
|
|
daemon): when the agent is idle and the last curator run was longer than
|
|
``interval_hours`` ago, ``maybe_run_curator()`` spawns a forked AIAgent to do
|
|
the review.
|
|
|
|
Responsibilities:
|
|
- Auto-transition lifecycle states based on last_used_at timestamps
|
|
- Spawn a background review agent that can pin / archive / consolidate /
|
|
patch agent-created skills via skill_manage
|
|
- Persist curator state (last_run_at, paused, etc.) in .curator_state
|
|
|
|
Strict invariants:
|
|
- Only touches agent-created skills (see tools/skill_usage.is_agent_created)
|
|
- Never auto-deletes — only archives. Archive is recoverable.
|
|
- Pinned skills bypass all auto-transitions
|
|
- Uses the auxiliary client; never touches the main session's prompt cache
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
from hermes_constants import get_hermes_home
|
|
from tools import skill_usage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
|
DEFAULT_MIN_IDLE_HOURS = 2
|
|
DEFAULT_STALE_AFTER_DAYS = 30
|
|
DEFAULT_ARCHIVE_AFTER_DAYS = 90
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# .curator_state — persistent scheduler + status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _state_file() -> Path:
|
|
return get_hermes_home() / "skills" / ".curator_state"
|
|
|
|
|
|
def _default_state() -> Dict[str, Any]:
|
|
return {
|
|
"last_run_at": None,
|
|
"last_run_duration_seconds": None,
|
|
"last_run_summary": None,
|
|
"paused": False,
|
|
"run_count": 0,
|
|
}
|
|
|
|
|
|
def load_state() -> Dict[str, Any]:
|
|
path = _state_file()
|
|
if not path.exists():
|
|
return _default_state()
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict):
|
|
base = _default_state()
|
|
base.update({k: v for k, v in data.items() if k in base or k.startswith("_")})
|
|
return base
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logger.debug("Failed to read curator state: %s", e)
|
|
return _default_state()
|
|
|
|
|
|
def save_state(data: Dict[str, Any]) -> None:
|
|
path = _state_file()
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except Exception as e:
|
|
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
|
|
|
|
|
def set_paused(paused: bool) -> None:
|
|
state = load_state()
|
|
state["paused"] = bool(paused)
|
|
save_state(state)
|
|
|
|
|
|
def is_paused() -> bool:
|
|
return bool(load_state().get("paused"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config access
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_config() -> Dict[str, Any]:
|
|
"""Read curator.* config from ~/.hermes/config.yaml. Tolerates missing file."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config()
|
|
except Exception as e:
|
|
logger.debug("Failed to load config for curator: %s", e)
|
|
return {}
|
|
if not isinstance(cfg, dict):
|
|
return {}
|
|
cur = cfg.get("curator") or {}
|
|
if not isinstance(cur, dict):
|
|
return {}
|
|
return cur
|
|
|
|
|
|
def is_enabled() -> bool:
|
|
"""Default ON when no config says otherwise."""
|
|
cfg = _load_config()
|
|
return bool(cfg.get("enabled", True))
|
|
|
|
|
|
def get_interval_hours() -> int:
|
|
cfg = _load_config()
|
|
try:
|
|
return int(cfg.get("interval_hours", DEFAULT_INTERVAL_HOURS))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_INTERVAL_HOURS
|
|
|
|
|
|
def get_min_idle_hours() -> float:
|
|
cfg = _load_config()
|
|
try:
|
|
return float(cfg.get("min_idle_hours", DEFAULT_MIN_IDLE_HOURS))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_MIN_IDLE_HOURS
|
|
|
|
|
|
def get_stale_after_days() -> int:
|
|
cfg = _load_config()
|
|
try:
|
|
return int(cfg.get("stale_after_days", DEFAULT_STALE_AFTER_DAYS))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_STALE_AFTER_DAYS
|
|
|
|
|
|
def get_archive_after_days() -> int:
|
|
cfg = _load_config()
|
|
try:
|
|
return int(cfg.get("archive_after_days", DEFAULT_ARCHIVE_AFTER_DAYS))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_ARCHIVE_AFTER_DAYS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idle / interval check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_iso(ts: Optional[str]) -> Optional[datetime]:
|
|
if not ts:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(ts)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def should_run_now(now: Optional[datetime] = None) -> bool:
|
|
"""Return True if the curator should run immediately.
|
|
|
|
Gates:
|
|
- curator.enabled == True
|
|
- not paused
|
|
- last_run_at missing, OR older than interval_hours
|
|
|
|
The idle check (min_idle_hours) is applied at the call site where we know
|
|
whether an agent is actively running — here we only enforce the static
|
|
gates.
|
|
"""
|
|
if not is_enabled():
|
|
return False
|
|
if is_paused():
|
|
return False
|
|
|
|
state = load_state()
|
|
last = _parse_iso(state.get("last_run_at"))
|
|
if last is None:
|
|
return True
|
|
|
|
if now is None:
|
|
now = datetime.now(timezone.utc)
|
|
if last.tzinfo is None:
|
|
last = last.replace(tzinfo=timezone.utc)
|
|
interval = timedelta(hours=get_interval_hours())
|
|
return (now - last) >= interval
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automatic state transitions (pure function, no LLM)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
|
"""Walk every agent-created skill and move active/stale/archived based on
|
|
last_used_at. Pinned skills are never touched. 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}
|
|
|
|
for row in _u.agent_created_report():
|
|
counts["checked"] += 1
|
|
name = row["name"]
|
|
if row.get("pinned"):
|
|
continue
|
|
|
|
last_used = _parse_iso(row.get("last_used_at"))
|
|
# If never used, treat as using created_at as the anchor so new skills
|
|
# don't immediately archive themselves.
|
|
anchor = last_used or _parse_iso(row.get("created_at")) or now
|
|
if anchor.tzinfo is None:
|
|
anchor = anchor.replace(tzinfo=timezone.utc)
|
|
|
|
current = row.get("state", _u.STATE_ACTIVE)
|
|
|
|
if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:
|
|
ok, _msg = _u.archive_skill(name)
|
|
if ok:
|
|
counts["archived"] += 1
|
|
elif anchor <= stale_cutoff and current == _u.STATE_ACTIVE:
|
|
_u.set_state(name, _u.STATE_STALE)
|
|
counts["marked_stale"] += 1
|
|
elif anchor > stale_cutoff and current == _u.STATE_STALE:
|
|
# Skill got used again after being marked stale — reactivate.
|
|
_u.set_state(name, _u.STATE_ACTIVE)
|
|
counts["reactivated"] += 1
|
|
|
|
return counts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Review prompt for the forked agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CURATOR_REVIEW_PROMPT = (
|
|
"You are running as Hermes' background skill CURATOR. This is an "
|
|
"UMBRELLA-BUILDING consolidation pass, not a passive audit and not a "
|
|
"duplicate-finder.\n\n"
|
|
"The goal of the skill collection is a LIBRARY OF CLASS-LEVEL "
|
|
"INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of "
|
|
"narrow skills where each one captures one session's specific bug is "
|
|
"a FAILURE of the library — not a feature. An agent searching skills "
|
|
"matches on descriptions, not on exact names; one broad umbrella "
|
|
"skill with labeled subsections beats five narrow siblings for "
|
|
"discoverability, not the other way around.\n\n"
|
|
"The right target shape is CLASS-LEVEL skills with rich SKILL.md "
|
|
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
|
|
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
|
|
"Hard rules — do not violate:\n"
|
|
"1. DO NOT touch bundled or hub-installed skills. The candidate list "
|
|
"below is already filtered to agent-created skills only.\n"
|
|
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
|
|
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
|
|
"Archives are recoverable; deletion is not.\n"
|
|
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
|
|
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
|
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
|
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
|
|
"absence of evidence either way.\n"
|
|
"5. DO NOT reject consolidation on the grounds that 'each skill has "
|
|
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
|
|
"right bar is: 'would a human maintainer write this as N separate "
|
|
"skills, or as one skill with N labeled subsections?' When the "
|
|
"answer is the latter, merge.\n\n"
|
|
"How to work — not optional:\n"
|
|
"1. Scan the full candidate list. Identify PREFIX CLUSTERS (skills "
|
|
"sharing a first word or domain keyword). Examples you are likely "
|
|
"to find: hermes-config-*, hermes-dashboard-*, gateway-*, codex-*, "
|
|
"ollama-*, anthropic-*, gemini-*, mcp-*, salvage-*, pr-*, "
|
|
"competitor-*, python-*, security-*, etc. Expect 10-25 clusters.\n"
|
|
"2. For each cluster with 2+ members, do NOT ask 'are these pairs "
|
|
"overlapping?' — ask 'what is the UMBRELLA CLASS these skills all "
|
|
"serve? Would a maintainer name that class and write one skill for "
|
|
"it?' If yes, pick (or create) the umbrella and absorb the siblings "
|
|
"into it.\n"
|
|
"3. Three ways to consolidate — use the right one per cluster:\n"
|
|
" a. MERGE INTO EXISTING UMBRELLA — one skill in the cluster is "
|
|
"already broad enough to be the umbrella (example: `pr-triage-"
|
|
"salvage` for the PR review cluster). Patch it to add a labeled "
|
|
"section for each sibling's unique insight, then archive the "
|
|
"siblings.\n"
|
|
" b. CREATE A NEW UMBRELLA SKILL.md — no existing member is broad "
|
|
"enough. Use skill_manage action=create to write a new class-level "
|
|
"skill whose SKILL.md covers the shared workflow and has short "
|
|
"labeled subsections. Archive the now-absorbed narrow siblings.\n"
|
|
" c. DEMOTE TO REFERENCES/TEMPLATES/SCRIPTS — a sibling has "
|
|
"narrow-but-valuable session-specific content. Move it into the "
|
|
"umbrella's appropriate support directory:\n"
|
|
" • `references/<topic>.md` for session-specific detail OR "
|
|
"condensed knowledge banks (quoted research, API docs excerpts, "
|
|
"domain notes, provider quirks, reproduction recipes)\n"
|
|
" • `templates/<name>.<ext>` for starter files meant to be "
|
|
"copied and modified\n"
|
|
" • `scripts/<name>.<ext>` for statically re-runnable actions "
|
|
"(verification scripts, fixture generators, probes)\n"
|
|
" Then archive the old sibling. Use `terminal` with `mkdir -p "
|
|
"~/.hermes/skills/<umbrella>/references/ && mv ... <umbrella>/"
|
|
"references/<topic>.md` (or templates/ / scripts/).\n"
|
|
"4. Also flag skills whose NAME is too narrow (contains a PR number, "
|
|
"a feature codename, a specific error string, an 'audit' / "
|
|
"'diagnosis' / 'salvage' session artifact). These almost always "
|
|
"belong as a subsection or support file under a class-level umbrella.\n"
|
|
"5. Iterate. After one consolidation round, scan the remaining set "
|
|
"and look for the NEXT umbrella opportunity. Don't stop after 3 "
|
|
"merges.\n\n"
|
|
"Your toolset:\n"
|
|
" - skills_list, skill_view — read the current landscape\n"
|
|
" - skill_manage action=patch — add sections to the umbrella\n"
|
|
" - skill_manage action=create — create a new umbrella SKILL.md\n"
|
|
" - skill_manage action=write_file — add a references/, templates/, "
|
|
"or scripts/ file under an existing skill (the skill must already "
|
|
"exist)\n"
|
|
" - terminal — mv a sibling into the archive "
|
|
"OR move its content into a support subfile\n\n"
|
|
"'keep' is a legitimate decision ONLY when the skill is already a "
|
|
"class-level umbrella and none of the proposed merges would improve "
|
|
"discoverability. 'This is narrow but distinct from its siblings' "
|
|
"is NOT a reason to keep — it's a reason to move it under an "
|
|
"umbrella as a subsection or support file.\n\n"
|
|
"Expected output: real umbrella-ification. Process every obvious "
|
|
"cluster. If you end the pass with fewer than 10 archives, you "
|
|
"stopped too early — go back and look at the clusters you left "
|
|
"alone.\n\n"
|
|
"When done, write a summary with: clusters processed, skills "
|
|
"patched/absorbed, skills demoted to references/templates/scripts, "
|
|
"skills archived, new umbrellas created, and clusters you "
|
|
"deliberately left alone with one line each."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestrator — spawn a forked AIAgent for the LLM review pass
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _render_candidate_list() -> str:
|
|
"""Human/agent-readable list of agent-created skills with usage stats."""
|
|
rows = skill_usage.agent_created_report()
|
|
if not rows:
|
|
return "No agent-created skills to review."
|
|
lines = [f"Agent-created skills ({len(rows)}):\n"]
|
|
for r in rows:
|
|
lines.append(
|
|
f"- {r['name']} "
|
|
f"state={r['state']} "
|
|
f"pinned={'yes' if r.get('pinned') else 'no'} "
|
|
f"use={r.get('use_count', 0)} "
|
|
f"view={r.get('view_count', 0)} "
|
|
f"patches={r.get('patch_count', 0)} "
|
|
f"last_used={r.get('last_used_at') or 'never'}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def run_curator_review(
|
|
on_summary: Optional[Callable[[str], None]] = None,
|
|
synchronous: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""Execute a single curator review pass.
|
|
|
|
Steps:
|
|
1. Apply automatic state transitions (pure, no LLM).
|
|
2. If there are agent-created skills, spawn a forked AIAgent that runs
|
|
the LLM review prompt against the current candidate list.
|
|
3. Update .curator_state with last_run_at and a one-line summary.
|
|
4. Invoke *on_summary* with a user-visible description.
|
|
|
|
If *synchronous* is True, the LLM review runs in the calling thread; the
|
|
default is to spawn a daemon thread so the caller returns immediately.
|
|
"""
|
|
start = datetime.now(timezone.utc)
|
|
counts = apply_automatic_transitions(now=start)
|
|
|
|
auto_summary_parts = []
|
|
if counts["marked_stale"]:
|
|
auto_summary_parts.append(f"{counts['marked_stale']} marked stale")
|
|
if counts["archived"]:
|
|
auto_summary_parts.append(f"{counts['archived']} archived")
|
|
if counts["reactivated"]:
|
|
auto_summary_parts.append(f"{counts['reactivated']} reactivated")
|
|
auto_summary = ", ".join(auto_summary_parts) if auto_summary_parts else "no changes"
|
|
|
|
# Persist state before the LLM pass so a crash mid-review still records
|
|
# the run and doesn't immediately re-trigger.
|
|
state = load_state()
|
|
state["last_run_at"] = start.isoformat()
|
|
state["run_count"] = int(state.get("run_count", 0)) + 1
|
|
state["last_run_summary"] = f"auto: {auto_summary}"
|
|
save_state(state)
|
|
|
|
def _llm_pass():
|
|
nonlocal auto_summary
|
|
try:
|
|
candidate_list = _render_candidate_list()
|
|
if "No agent-created skills" in candidate_list:
|
|
final_summary = f"auto: {auto_summary}; llm: skipped (no candidates)"
|
|
else:
|
|
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
|
|
llm_summary = _run_llm_review(prompt)
|
|
final_summary = f"auto: {auto_summary}; llm: {llm_summary}"
|
|
except Exception as e:
|
|
logger.debug("Curator LLM pass failed: %s", e, exc_info=True)
|
|
final_summary = f"auto: {auto_summary}; llm: error ({e})"
|
|
|
|
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
|
state2 = load_state()
|
|
state2["last_run_duration_seconds"] = elapsed
|
|
state2["last_run_summary"] = final_summary
|
|
save_state(state2)
|
|
|
|
if on_summary:
|
|
try:
|
|
on_summary(f"curator: {final_summary}")
|
|
except Exception:
|
|
pass
|
|
|
|
if synchronous:
|
|
_llm_pass()
|
|
else:
|
|
t = threading.Thread(target=_llm_pass, daemon=True, name="curator-review")
|
|
t.start()
|
|
|
|
return {
|
|
"started_at": start.isoformat(),
|
|
"auto_transitions": counts,
|
|
"summary_so_far": auto_summary,
|
|
}
|
|
|
|
|
|
def _run_llm_review(prompt: str) -> str:
|
|
"""Spawn an AIAgent fork to run the curator review prompt. Returns a short
|
|
summary of what the model said in its final response."""
|
|
import contextlib
|
|
try:
|
|
from run_agent import AIAgent
|
|
except Exception as e:
|
|
return f"AIAgent import failed: {e}"
|
|
|
|
# Resolve provider + model the same way the CLI does, so the curator
|
|
# fork inherits the user's active main config rather than falling
|
|
# through to an empty provider/model pair (which sends HTTP 400
|
|
# "No models provided"). AIAgent() without explicit provider/model
|
|
# arguments hits an auto-resolution path that fails for OAuth-only
|
|
# providers and for pool-backed credentials.
|
|
_api_key = None
|
|
_base_url = None
|
|
_api_mode = None
|
|
_resolved_provider = None
|
|
_model_name = ""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
_cfg = load_config()
|
|
_m = _cfg.get("model", {}) if isinstance(_cfg.get("model"), dict) else {}
|
|
_provider = _m.get("provider") or "auto"
|
|
_model_name = _m.get("default") or _m.get("model") or ""
|
|
_rp = resolve_runtime_provider(
|
|
requested=_provider, target_model=_model_name
|
|
)
|
|
_api_key = _rp.get("api_key")
|
|
_base_url = _rp.get("base_url")
|
|
_api_mode = _rp.get("api_mode")
|
|
_resolved_provider = _rp.get("provider") or _provider
|
|
except Exception as e:
|
|
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
|
|
|
|
review_agent = None
|
|
try:
|
|
review_agent = AIAgent(
|
|
model=_model_name,
|
|
provider=_resolved_provider,
|
|
api_key=_api_key,
|
|
base_url=_base_url,
|
|
api_mode=_api_mode,
|
|
# Umbrella-building over a large skill collection is worth a
|
|
# high iteration ceiling — the pass typically takes 50-100
|
|
# API calls against hundreds of candidate skills. The
|
|
# single-session review path caps itself at a much smaller
|
|
# number because it's not doing a curation sweep.
|
|
max_iterations=9999,
|
|
quiet_mode=True,
|
|
platform="curator",
|
|
skip_context_files=True,
|
|
skip_memory=True,
|
|
)
|
|
# Disable recursive nudges — the curator must never spawn its own review.
|
|
review_agent._memory_nudge_interval = 0
|
|
review_agent._skill_nudge_interval = 0
|
|
|
|
# Redirect the forked agent's stdout/stderr to /dev/null while it
|
|
# runs so its tool-call chatter doesn't pollute the foreground
|
|
# terminal. The background-thread runner also hides it; this
|
|
# belt-and-suspenders path matters when a caller invokes
|
|
# run_curator_review(synchronous=True) from the CLI.
|
|
with open(os.devnull, "w") as _devnull, \
|
|
contextlib.redirect_stdout(_devnull), \
|
|
contextlib.redirect_stderr(_devnull):
|
|
result = review_agent.run_conversation(user_message=prompt)
|
|
|
|
final = ""
|
|
if isinstance(result, dict):
|
|
final = str(result.get("final_response") or "").strip()
|
|
return (final[:240] + "…") if len(final) > 240 else (final or "no change")
|
|
except Exception as e:
|
|
return f"error: {e}"
|
|
finally:
|
|
if review_agent is not None:
|
|
try:
|
|
review_agent.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public entrypoint for the session-start hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def maybe_run_curator(
|
|
*,
|
|
idle_for_seconds: Optional[float] = None,
|
|
on_summary: Optional[Callable[[str], None]] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Best-effort: run a curator pass if all gates pass. Returns the result
|
|
dict if a pass was started, else None. Never raises."""
|
|
try:
|
|
if not should_run_now():
|
|
return None
|
|
# Idle gating: only enforce when the caller provided a measurement.
|
|
if idle_for_seconds is not None:
|
|
min_idle_s = get_min_idle_hours() * 3600.0
|
|
if idle_for_seconds < min_idle_s:
|
|
return None
|
|
return run_curator_review(on_summary=on_summary)
|
|
except Exception as e:
|
|
logger.debug("maybe_run_curator failed: %s", e, exc_info=True)
|
|
return None
|