Skip to content

Commit effbd17

Browse files
authored
Python: [BREAKING] Treat nested SKILL.md content as part of the parent skill (#6849)
* Python: Stop skill discovery at skill boundaries File-based skill discovery kept descending after finding a SKILL.md, which treated content nested beneath a skill boundary as an independent skill root. Return immediately after recording a directory that contains SKILL.md so everything below it stays part of that skill, and add a regression test with a nested SKILL.md. Fixes #6682 * Python: Attach nested skill content to the parent skill Removing the SKILL.md subdirectory skip in resource and script scanning so that content beneath a skill boundary is attached to that skill, and update the discovery docstring and the nested-skill test to match. Complements the discovery early-return so a nested SKILL.md is never treated as an independent skill root.
1 parent bc8dd4b commit effbd17

2 files changed

Lines changed: 35 additions & 23 deletions

File tree

python/packages/core/agent_framework/_skills.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2953,13 +2953,12 @@ def _scan_directory_for_resources(
29532953

29542954
resources.append(rel_path)
29552955

2956-
# Recurse into subdirectories if within depth limit
2956+
# Recurse into subdirectories if within depth limit.
2957+
# Subdirectories that contain their own SKILL.md are NOT skipped: a nested
2958+
# SKILL.md is not an independent skill (see _discover_skill_directories), so
2959+
# its contents belong to this skill.
29572960
if current_depth < self._search_depth:
29582961
for subdir in subdirectories:
2959-
# Skip subdirectories that contain their own SKILL.md — they are
2960-
# separate skills and their files should not be attached to this one.
2961-
if (subdir / SKILL_FILE_NAME).is_file():
2962-
continue
29632962
self._scan_directory_for_resources(
29642963
target_dir=subdir,
29652964
skill_dir=skill_dir,
@@ -3104,13 +3103,12 @@ def _scan_directory_for_scripts(
31043103

31053104
scripts.append(rel_path)
31063105

3107-
# Recurse into subdirectories if within depth limit
3106+
# Recurse into subdirectories if within depth limit.
3107+
# Subdirectories that contain their own SKILL.md are NOT skipped: a nested
3108+
# SKILL.md is not an independent skill (see _discover_skill_directories), so
3109+
# its contents belong to this skill.
31083110
if current_depth < self._search_depth:
31093111
for subdir in subdirectories:
3110-
# Skip subdirectories that contain their own SKILL.md — they are
3111-
# separate skills and their files should not be attached to this one.
3112-
if (subdir / SKILL_FILE_NAME).is_file():
3113-
continue
31143112
self._scan_directory_for_scripts(
31153113
target_dir=subdir,
31163114
skill_dir=skill_dir,
@@ -3327,7 +3325,10 @@ def _read_and_parse_skill_file(
33273325
def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]:
33283326
"""Return absolute paths of all directories that contain a ``SKILL.md`` file.
33293327
3330-
Recursively searches each root path up to :data:`MAX_SEARCH_DEPTH`.
3328+
Recursively searches each root path up to :data:`MAX_SEARCH_DEPTH`. Once a
3329+
``SKILL.md`` is found in a directory, that directory is the skill root and the
3330+
search does not descend into its subdirectories: everything beneath a skill
3331+
boundary is part of that skill, not an independent skill root.
33313332
33323333
Args:
33333334
skill_paths: Root directory paths to search.
@@ -3340,7 +3341,10 @@ def _discover_skill_directories(skill_paths: Sequence[str]) -> list[str]:
33403341
def _search(directory: str, current_depth: int) -> None:
33413342
dir_path = Path(directory)
33423343
if (dir_path / SKILL_FILE_NAME).is_file():
3344+
# This directory is a skill root. Subdirectories are part of this
3345+
# skill and must not be treated as independent skill roots.
33433346
discovered.append(str(dir_path.absolute()))
3347+
return
33443348

33453349
if current_depth >= MAX_SEARCH_DEPTH:
33463350
return

python/packages/core/tests/core/test_skills.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,8 +1807,8 @@ async def test_from_paths_passes_resource_filter(self, tmp_path: Path) -> None:
18071807
assert "keep.md" in resource_names
18081808
assert "drop.md" not in resource_names
18091809

1810-
async def test_nested_skill_directory_not_crossed(self, tmp_path: Path) -> None:
1811-
"""Files in a nested skill directory are NOT attached to the parent skill."""
1810+
async def test_nested_skill_directory_absorbed_into_parent(self, tmp_path: Path) -> None:
1811+
"""A nested SKILL.md is not an independent skill; its contents belong to the parent."""
18121812
parent_dir = tmp_path / "parent-skill"
18131813
child_dir = parent_dir / "child-skill"
18141814
child_dir.mkdir(parents=True)
@@ -1828,22 +1828,19 @@ async def test_nested_skill_directory_not_crossed(self, tmp_path: Path) -> None:
18281828
skills = await source.get_skills()
18291829
skills_dict = {s.frontmatter.name: s for s in skills}
18301830

1831-
# Both skills are discovered
1831+
# Only the parent skill is discovered; the nested SKILL.md is not its own skill.
18321832
assert "parent-skill" in skills_dict
1833-
assert "child-skill" in skills_dict
1833+
assert "child-skill" not in skills_dict
18341834

1835-
# Parent does NOT pick up child's files
1835+
# The parent absorbs the nested directory's resources and scripts.
18361836
parent_resources = [r.name for r in skills_dict["parent-skill"]._resources] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
18371837
parent_scripts = [s.name for s in skills_dict["parent-skill"]._scripts] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
18381838
assert "parent-resource.md" in parent_resources
1839-
assert "child-skill/child-resource.md" not in parent_resources
1840-
assert "child-skill/child-script.py" not in parent_scripts
1839+
assert "child-skill/child-resource.md" in parent_resources
1840+
assert "child-skill/child-script.py" in parent_scripts
18411841

1842-
# Child has its own files
1843-
child_resources = [r.name for r in skills_dict["child-skill"]._resources] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
1844-
child_scripts = [s.name for s in skills_dict["child-skill"]._scripts] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
1845-
assert "child-resource.md" in child_resources
1846-
assert "child-script.py" in child_scripts
1842+
# The nested SKILL.md file itself is never surfaced as a resource.
1843+
assert "child-skill/SKILL.md" not in parent_resources
18471844

18481845

18491846
# ---------------------------------------------------------------------------
@@ -1996,6 +1993,17 @@ def test_finds_nested_skill(self, tmp_path: Path) -> None:
19961993
assert len(dirs) == 1
19971994
assert str(sub.absolute()) in dirs[0]
19981995

1996+
def test_stops_searching_below_skill_boundary(self, tmp_path: Path) -> None:
1997+
skill_dir = tmp_path / "parent-skill"
1998+
nested_skill_dir = skill_dir / "nested-skill"
1999+
nested_skill_dir.mkdir(parents=True)
2000+
(skill_dir / "SKILL.md").write_text("---\nname: parent-skill\ndescription: d\n---\n", encoding="utf-8")
2001+
(nested_skill_dir / "SKILL.md").write_text("---\nname: nested-skill\ndescription: d\n---\n", encoding="utf-8")
2002+
2003+
dirs = FileSkillsSource._discover_skill_directories([str(tmp_path)])
2004+
2005+
assert dirs == [str(skill_dir.absolute())]
2006+
19992007
def test_skips_empty_path_string(self) -> None:
20002008
dirs = FileSkillsSource._discover_skill_directories(["", " "])
20012009
assert dirs == []

0 commit comments

Comments
 (0)