fix(tools): refuse skill_view name collisions instead of guessing

skill_view ran the direct-path strategy across every skill dir before
the recursive strategy, so a top-level skill in an external dir could
silently shadow a same-named nested local skill. /skills correctly
listed the local version (deduped local-first by _find_all_skills) but
skill_view loaded the external one — confusing, and a real bug class
for users with skills.external_dirs registered alongside categorized
local skills.

Pick a louder fix than @polkn's PR #6136 proposed: collect every match
across all dirs (direct path, recursive by parent dir name, legacy
flat <name>.md), and if there's more than one, refuse with an error
that surfaces every matching path plus a hint to load by the
categorized form. Local-first precedence would have replaced silent
external-shadowing with silent same-name collisions between two
externals, or made an externally-shadowed-by-local skill unreachable
by bare name with no signal. Refusing forces the user to disambiguate
once and never wonder which skill ran.

Recovery: pass the full categorized path
("foundations/runtime/explore-codebase" instead of
"explore-codebase"), or rename one of the colliding skills.

Co-authored-by: pol <pol.kuijken@gmail.com>
This commit is contained in:
teknium1 2026-05-13 09:01:22 -07:00 committed by Teknium
parent 256bedb632
commit 59da8ec4ec
2 changed files with 231 additions and 32 deletions

View file

@ -956,49 +956,83 @@ def skill_view(
skill_dir = None
skill_md = None
# Search all dirs: local first, then external (first match wins)
# Collision detection: collect ALL candidates across every dir using
# every lookup strategy (direct path, recursive by parent dir name,
# legacy flat <name>.md). If more than one matches, refuse and tell
# the caller — silent shadowing of a local skill by a same-named
# external skill is a real bug class (`/skills` shows one, agent
# loaded the other) so we surface it loudly instead of guessing.
from agent.skill_utils import iter_skill_index_files
candidates: List[Tuple[Optional[Path], Path]] = [] # (skill_dir, skill_md)
seen_md: set = set()
def _record(sd: Optional[Path], smd: Path) -> None:
try:
key = smd.resolve()
except Exception:
key = smd
if key in seen_md:
return
seen_md.add(key)
candidates.append((sd, smd))
for search_dir in all_dirs:
# Try direct path first (e.g., "mlops/axolotl")
# Strategy 1: direct path (e.g., "mlops/axolotl" or bare "axolotl"
# at the top of the dir).
direct_path = search_dir / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
break
_record(direct_path, direct_path / "SKILL.md")
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
break
_record(None, direct_path.with_suffix(".md"))
# Strategy 1b: categorized form for plugin namespace fall-through
# (e.g., a "myplugin:explore" name with no plugin registered also
# tries the on-disk path "myplugin/explore").
if local_category_name:
categorized_path = search_dir / local_category_name
if categorized_path.is_dir() and (categorized_path / "SKILL.md").exists():
skill_dir = categorized_path
skill_md = categorized_path / "SKILL.md"
break
_record(categorized_path, categorized_path / "SKILL.md")
elif categorized_path.with_suffix(".md").exists():
skill_md = categorized_path.with_suffix(".md")
break
_record(None, categorized_path.with_suffix(".md"))
# Search by directory name across all dirs
if not skill_md:
for search_dir in all_dirs:
from agent.skill_utils import iter_skill_index_files
# Strategy 2: recursive by directory name (catches nested skills
# like "foundations/runtime/explore-codebase" called by bare name).
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
if found_skill_md.parent.name == name:
_record(found_skill_md.parent, found_skill_md)
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
break
if skill_md:
break
# Strategy 3: legacy flat <name>.md files anywhere under the dir.
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
_record(None, found_md)
# Legacy: flat .md files
if not skill_md:
for search_dir in all_dirs:
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
if skill_md:
break
if len(candidates) > 1:
paths = [str(smd) for _, smd in candidates]
logging.getLogger(__name__).warning(
"Skill name collision for '%s': %d candidates — %s",
name, len(candidates), "; ".join(paths),
)
return json.dumps(
{
"success": False,
"error": (
f"Ambiguous skill name '{name}': {len(candidates)} skills "
"match across your local skills dir and external_dirs. "
"Refusing to guess — load one explicitly by its categorized path."
),
"matches": paths,
"hint": (
"Pass the full relative path instead of the bare name "
"(e.g., 'category/skill-name'), or rename one of the "
"colliding skills so each name is unique."
),
},
ensure_ascii=False,
)
if candidates:
skill_dir, skill_md = candidates[0]
if not skill_md or not skill_md.exists():
available = [s["name"] for s in _sort_skills(_find_all_skills())[:20]]