fix(skills): keep built-in provenance for skills the catalog dropped

Co-authored-by: fangliquanflq <fangliquan@qq.com>
This commit is contained in:
Hermes Agent
2026-09-24 23:45:27 -05:00
committed by brooklyn!
parent 1b57acf94a
commit 0cf900ea17
5 changed files with 69 additions and 12 deletions

View File

@@ -345,7 +345,7 @@ async def get_skills(profile: Optional[str] = None):
from tools.skills_tool import _find_all_skills
from hermes_cli.skills_config import get_disabled_skills
from tools.skill_usage import (
_read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage)
_read_bundled_names, _read_hub_installed_names, activity_count, load_usage)
def _run():
with _profile_scope(profile):
@@ -357,7 +357,7 @@ async def get_skills(profile: Optional[str] = None):
# without a per-skill manifest read): hub > bundled > agent, where
# "agent" covers agent-authored AND local hand-made skills — the ones
# the user may edit/delete from the UI.
bundled_names = _read_bundled_manifest_names()
bundled_names = _read_bundled_names()
hub_names = _read_hub_installed_names()
for s in skills:
s["enabled"] = s["name"] not in disabled

View File

@@ -513,6 +513,52 @@ class TestSyncSkills:
assert (skills_dir / "category" / "new-skill" / "SKILL.md").exists()
class TestDroppedBuiltinProvenance:
"""#95415: a built-in the catalog dropped keeps built-in provenance while any copy of it is left;
otherwise /api/skills badges it "Learned" and the Desktop offers edit/archive for it."""
def _bundled(self, tmp_path, *names):
bundled = tmp_path / "bundled_skills"
shutil.rmtree(bundled, ignore_errors=True)
for name in names:
(bundled / "cat" / name).mkdir(parents=True)
(bundled / "cat" / name / "SKILL.md").write_text(f"---\nname: {name}\n---\n# {name}\n")
return bundled
def _sync(self, bundled):
with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \
patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"):
return sync_skills(quiet=True)
def test_catalog_drop_keeps_builtin_provenance_while_a_copy_exists(self, tmp_path):
from hermes_constants import get_hermes_home
from tools.skill_usage import provenance
skills = get_hermes_home() / "skills"
self._sync(self._bundled(tmp_path, "kept", "dropped", "dropped-archived", "dropped-deleted"))
(skills / ".archive").mkdir()
shutil.move(str(skills / "cat" / "dropped-archived"), str(skills / ".archive" / "dropped-archived"))
shutil.rmtree(skills / "cat" / "dropped-deleted")
result = self._sync(self._bundled(tmp_path, "kept"))
assert result["cleaned"] == ["dropped-deleted"]
assert {"dropped", "dropped-archived"} <= set(_read_manifest())
assert provenance("dropped") == "bundled" and provenance("dropped-archived") == "bundled"
def test_suppressed_builtin_is_not_agent_authored_after_manifest_cleanup(self, tmp_path):
"""Profiles an older sync already cleaned: the curator suppression list only records built-ins."""
from hermes_constants import get_hermes_home
from tools.skill_usage import provenance
skills = get_hermes_home() / "skills"
(skills / "resurrected").mkdir(parents=True)
(skills / "resurrected" / "SKILL.md").write_text("---\nname: resurrected\n---\n")
(skills / ".curator_suppressed").write_text("resurrected\n")
assert provenance("resurrected") == "bundled"
class TestGetBundledDir:
def test_env_var_override_with_default_fallback(self, tmp_path, monkeypatch):
custom_dir = tmp_path / "custom_skills"

View File

@@ -143,10 +143,12 @@ def activity_count(record: Dict[str, Any]) -> int:
# --- Provenance — which skills are agent-created (and thus eligible for curation) ---
def _read_bundled_manifest_names() -> Set[str]:
"""Names from ``.bundled_manifest`` ("name:hash" per line); empty if missing/unreadable."""
def _read_bundled_names() -> Set[str]:
"""Built-in names: ``.bundled_manifest`` ("name:hash" per line) plus the curator suppression list, which
only ever records built-ins; a pruned built-in whose manifest entry an older sync cleaned after the
catalog dropped it is still not agent-authored (#95415). Empty if both are missing/unreadable."""
lines = _read_lines(_skills_dir() / ".bundled_manifest", "Failed to read bundled manifest: %s")
return {n for n in (line.split(":", 1)[0].strip() for line in lines) if n}
return {n for n in (line.split(":", 1)[0].strip() for line in lines) if n} | read_suppressed_names()
def _read_hub_installed_names() -> Set[str]:
@@ -221,7 +223,7 @@ def _scan_local_skills(keep: Callable[[str, Path, Set[str], Dict[str, Any]], boo
"""Sorted local skill names passing *keep(name, skill_md, bundled, usage)*; hub/protected names never reach it."""
if not (base := _skills_dir()).exists():
return []
hub, bundled, usage = _read_hub_installed_names(), _read_bundled_manifest_names(), load_usage()
hub, bundled, usage = _read_hub_installed_names(), _read_bundled_names(), load_usage()
return sorted({name for name, skill_md in _iter_skill_mds(base, local_only=True)
if name not in hub and not is_protected_builtin(name) and keep(name, skill_md, bundled, usage)})
@@ -265,7 +267,7 @@ def is_hub_installed(skill_name: str) -> bool:
def is_bundled(skill_name: str) -> bool:
return skill_name in _read_bundled_manifest_names()
return skill_name in _read_bundled_names()
def _external_read_only_message(skill_name: str) -> str:

View File

@@ -3,7 +3,8 @@
~/.hermes/skills/, tracking each synced skill's origin hash in .bundled_manifest (v2 "name:hash"
lines; v1 plain names auto-migrate). NEW skills are copied and recorded; EXISTING skills update
only when bundled changed AND the user copy still matches the origin hash (else user-customized
-> SKIP); user-DELETED skills are not re-added; upstream-REMOVED ones leave the manifest."""
-> SKIP); user-DELETED skills are not re-added; upstream-REMOVED ones leave the manifest once no active or
archived copy remains (the entry is that copy's only built-in provenance record)."""
import hashlib
import logging
@@ -430,9 +431,17 @@ def sync_skills(quiet: bool = False) -> dict:
_update_existing_skill(st, skill_name, skill_src, dest, bundled_hash)
else:
st.skipped += 1 # in manifest but not on disk — user deleted it
# Clean manifest entries for skills removed upstream. Skipped when opted out: bundled_skills
# Clean manifest entries for skills removed upstream once no copy is left. A dropped built-in still
# on disk (active or archived) keeps its entry: it is the only provenance record, and without it the
# copy reads as agent-authored ("Learned", editable) (#95415). Skipped when opted out: bundled_skills
# is only the essential set there, so cleaning would drop tracking for everything else.
cleaned = [] if essential_only else sorted(set(st.manifest) - {name for name, _ in bundled_skills})
removed = [] if essential_only else sorted(set(st.manifest) - {name for name, _ in bundled_skills})
present = set()
if removed: # curator archive is flat: directory name == skill name
archive = _skills_dir() / ".archive"
present = {_read_skill_name(md, md.parent.name) for md in _iter_active_skill_mds()} | (
{p.name for p in archive.iterdir() if p.is_dir()} if archive.is_dir() else set())
cleaned = [name for name in removed if name not in present]
for name in cleaned:
del st.manifest[name]
_seed_category_descriptions(

View File

@@ -3330,8 +3330,8 @@ def _skill_usage_lookup():
"hub" / "bundled" / "local" (``/api/skills`` ``provenance``, "local" spelled "agent"). Failure → 0 / "local"."""
try:
from tools.skill_usage import (
_read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage)
records, bundled, hub = load_usage(), _read_bundled_manifest_names(), _read_hub_installed_names()
_read_bundled_names, _read_hub_installed_names, activity_count, load_usage)
records, bundled, hub = load_usage(), _read_bundled_names(), _read_hub_installed_names()
except Exception as e:
logger.debug("skill usage lookup unavailable: %s", e)
return (lambda _name: 0), (lambda _name: "local")