fix(skills): skip shadowing when external_dirs provides the skill

Fixes #28126. sync_skills() was unconditionally writing bundled skills
into the local <profile_home>/skills/ tree even when the profile's
config.yaml delegated skill resolution to an external directory
via skills.external_dirs. The skill loader then saw two candidates
for the same name (local shadow + external canonical), refused to
resolve on collision, and every worker that auto-loaded such a skill
crashed with 'Unknown skill(s): <name>'.

Changes:
- _build_external_skill_index() indexes skills available in external
  dirs (by directory name and frontmatter name)
- sync_skills() skips writing a bundled skill when it finds the same
  name in the external index; records the hash in the manifest so
  subsequent syncs treat it as already handled
- Self-healing: removes stale local shadows left by prior buggy syncs
  (only when origin_hash == bundled_hash == user_hash, i.e. we wrote
  it and user didn't touch it)
- New 'shadowed_by_external' key in sync_skills() return dict

3 new tests in TestExternalDirsIndexing (all passing).
All 48 tests in test_skills_sync.py pass.

Closes #28126
This commit is contained in:
zccyman 2026-05-28 10:55:29 +08:00 committed by Teknium
parent a8c862900b
commit db11849c9d
2 changed files with 182 additions and 1 deletions

View file

@ -272,6 +272,129 @@ class TestRmtreeWritableScopeGuard:
assert not sub.exists()
class TestExternalDirsIndexing:
"""Tests for external_dirs awareness in sync_skills (#28126)."""
def _setup_bundled(self, tmp_path):
"""Create a fake bundled skills directory."""
bundled = tmp_path / "bundled_skills"
(bundled / "devops" / "clair-qa").mkdir(parents=True)
(bundled / "devops" / "clair-qa" / "SKILL.md").write_text("# bundled clair")
(bundled / "creative" / "ascii-art").mkdir(parents=True)
(bundled / "creative" / "ascii-art" / "SKILL.md").write_text("# bundled ascii")
return bundled
def _setup_external(self, tmp_path):
"""Create a fake external skills directory."""
ext_dir = tmp_path / "external_skills"
(ext_dir / "devops" / "clair-qa").mkdir(parents=True)
(ext_dir / "devops" / "clair-qa" / "SKILL.md").write_text("# external clair")
(ext_dir / "devops" / "clair-qa" / "main.py").write_text("print('ext')")
return ext_dir
def _patches(self, bundled, skills_dir, manifest_file):
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(patch("tools.skills_sync._get_bundled_dir", return_value=bundled))
stack.enter_context(patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"))
stack.enter_context(patch("tools.skills_sync.SKILLS_DIR", skills_dir))
stack.enter_context(patch("tools.skills_sync.MANIFEST_FILE", manifest_file))
return stack
def test_shadowed_skill_skipped_and_deferred(self, tmp_path):
"""When external dir provides the skill, sync_skills should not write it locally."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
ext_dir = self._setup_external(tmp_path)
with self._patches(bundled, skills_dir, manifest_file):
with patch("agent.skill_utils.get_external_skills_dirs", return_value=[ext_dir]):
result = sync_skills(quiet=True)
assert "clair-qa" in result["shadowed_by_external"]
assert "clair-qa" not in result["copied"]
assert "ascii-art" in result["copied"]
assert not (skills_dir / "devops" / "clair-qa").exists()
def test_shadowed_skill_not_recorded_in_manifest(self, tmp_path):
"""A skill we never wrote locally must NOT be baselined in the manifest.
Recording bundled_hash for a deferred skill would later make the
loader misclassify the external copy as a user-deleted bundled skill
and poison update detection. The shadowed name stays out of the
manifest entirely.
"""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
ext_dir = self._setup_external(tmp_path)
with self._patches(bundled, skills_dir, manifest_file):
with patch("agent.skill_utils.get_external_skills_dirs", return_value=[ext_dir]):
sync_skills(quiet=True)
manifest = _read_manifest()
assert "clair-qa" not in manifest
# The non-shadowed skill is still synced and baselined normally.
assert "ascii-art" in manifest
def test_stale_shadow_self_healed(self, tmp_path):
"""A byte-identical-to-bundled local shadow is removed when the same
skill is now provided by external_dirs (heals profiles broken by an
earlier sync that ran before external_dirs was configured)."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
ext_dir = self._setup_external(tmp_path)
# Pre-seed a shadow identical to the bundled source.
shadow = skills_dir / "devops" / "clair-qa"
shadow.mkdir(parents=True)
(shadow / "SKILL.md").write_text("# bundled clair")
with self._patches(bundled, skills_dir, manifest_file):
with patch("agent.skill_utils.get_external_skills_dirs", return_value=[ext_dir]):
result = sync_skills(quiet=True)
assert "clair-qa" in result["shadowed_by_external"]
assert not shadow.exists()
def test_user_customized_shadow_preserved(self, tmp_path):
"""A local skill that DIFFERS from bundled is the user's own — it must
never be deleted even when external_dirs provides the same name."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
ext_dir = self._setup_external(tmp_path)
custom = skills_dir / "devops" / "clair-qa"
custom.mkdir(parents=True)
(custom / "SKILL.md").write_text("# my own customized clair")
with self._patches(bundled, skills_dir, manifest_file):
with patch("agent.skill_utils.get_external_skills_dirs", return_value=[ext_dir]):
result = sync_skills(quiet=True)
assert "clair-qa" in result["shadowed_by_external"]
assert custom.exists()
assert (custom / "SKILL.md").read_text() == "# my own customized clair"
def test_no_external_dirs_unchanged(self, tmp_path):
"""Without external_dirs, all bundled skills should be copied normally."""
bundled = self._setup_bundled(tmp_path)
skills_dir = tmp_path / "user_skills"
manifest_file = skills_dir / ".bundled_manifest"
with self._patches(bundled, skills_dir, manifest_file):
with patch("agent.skill_utils.get_external_skills_dirs", return_value=[]):
result = sync_skills(quiet=True)
assert "clair-qa" in result["copied"]
assert "ascii-art" in result["copied"]
assert result["shadowed_by_external"] == []
class TestSyncSkills:
def _setup_bundled(self, tmp_path):
"""Create a fake bundled skills directory."""

View file

@ -30,7 +30,7 @@ from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from hermes_constants import get_bundled_skills_dir, get_hermes_home, get_optional_skills_dir
from agent.skill_utils import is_excluded_skill_path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from utils import atomic_replace
logger = logging.getLogger(__name__)
@ -65,6 +65,35 @@ def _get_optional_dir() -> Path:
return get_optional_skills_dir(Path(__file__).parent.parent / "optional-skills")
def _build_external_skill_index() -> Set[str]:
"""Index every skill available in external_dirs by name and frontmatter name.
Returns a set of skill names that are already provided by external dirs.
Used to prevent sync_skills from shadowing externally-delegated skills.
"""
try:
from agent.skill_utils import get_external_skills_dirs, _external_dirs_cache_clear
except ImportError:
return set()
# Clear the external dirs cache so a config edit (or a test patch) is seen.
_external_dirs_cache_clear()
external_names: Set[str] = set()
for ext_dir in get_external_skills_dirs():
for skill_md in ext_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
skill_dir = skill_md.parent
# Index by directory name (how _find_skill resolves skills)
external_names.add(skill_dir.name)
# Also index by frontmatter name (alternate identifier)
frontmatter_name = _read_skill_name(skill_md, "")
if frontmatter_name:
external_names.add(frontmatter_name)
return external_names
def _read_manifest() -> Dict[str, str]:
"""
Read the manifest as a dict of {skill_name: origin_hash}.
@ -486,6 +515,9 @@ def sync_skills(quiet: bool = False) -> dict:
bundled_skills = _discover_bundled_skills(bundled_dir)
bundled_names = {name for name, _ in bundled_skills}
suppressed = _read_suppressed_names()
# Index of skills already provided by external_dirs (skip writing them)
external_index = _build_external_skill_index()
shadowed_by_external: List[str] = []
copied = []
updated = []
@ -524,6 +556,31 @@ def sync_skills(quiet: bool = False) -> dict:
exc_info=True,
)
if skill_name in external_index:
# An external_dirs source already provides this skill. Writing it
# into the profile-local tree would create a name collision the
# loader refuses to resolve (#28126). Defer to the external copy
# for ALL manifest states (new, previously-synced, user-deleted).
shadowed_by_external.append(skill_name)
skipped += 1
if not quiet:
print(
f"{skill_name} (deferred to external_dirs, "
"not written to local tree)"
)
# Self-healing: a prior sync (before external_dirs was configured,
# or an older buggy sync) may have left a local shadow that now
# collides. We own that shadow only when it is byte-identical to
# the bundled source — a user's own customized skill by the same
# name differs, so never delete or re-baseline it. Drop the stale
# manifest entry so the skill isn't later misread as user-deleted.
if dest.exists() and _dir_hash(dest) == bundled_hash:
_rmtree_writable(dest)
if not quiet:
print(f" ✓ removed stale shadow of {skill_name}")
manifest.pop(skill_name, None)
continue
if skill_name not in manifest:
# ── New skill — never offered before ──
try:
@ -659,6 +716,7 @@ def sync_skills(quiet: bool = False) -> dict:
"suppressed": suppressed_skipped,
"total_bundled": len(bundled_skills),
"optional_provenance_backfilled": optional_provenance_backfilled,
"shadowed_by_external": shadowed_by_external,
}