Skip to content

Commit d8aa275

Browse files
joaomdmouraclaude
andcommitted
fix(skills): stop re-downloading pinned skills that declare no version
Review follow-ups: - A pinned ref hit the cache and was then re-validated against SKILL.md frontmatter. Skills aren't required to declare `metadata.version`, so for those every resolution missed and re-downloaded. The cache already records the version it stored and `get_cached_path` matches the pin against it, so drop the second check there; project-local copies have no such record and still fall back to frontmatter - Reject a whitespace-only version pin (`@org/name@ `) instead of forwarding it - Check `get_skill` is callable, not merely present, before using a client - Treat cache metadata that parses but isn't an object as a miss rather than raising AttributeError out of the cache lookup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4fc114a commit d8aa275

4 files changed

Lines changed: 88 additions & 11 deletions

File tree

lib/crewai/src/crewai/skills/cache.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ def _records_version(self, meta_file: Path, version: str) -> bool:
7272
except (OSError, json.JSONDecodeError):
7373
_logger.debug("Unreadable cache entry: %s", meta_file, exc_info=True)
7474
return False
75+
if not isinstance(meta, dict):
76+
_logger.debug("Malformed cache entry: %s", meta_file)
77+
return False
7578
return versions_match(version, meta.get("version"))
7679

7780
def store(

lib/crewai/src/crewai/skills/registry.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def parse_skill_ref(ref: str) -> SkillRef:
6767
org, remainder = without_at.split("/", 1)
6868
# Skill names cannot contain '@', so the first one starts the version pin.
6969
name, separator, version = remainder.partition("@")
70+
version = version.strip()
7071
if (
7172
not org
7273
or not name
@@ -133,20 +134,37 @@ def resolve_registry_ref(
133134

134135
local_path = Path.cwd() / "skills" / name
135136
if local_path.is_dir() and (local_path / "SKILL.md").exists():
137+
# A project-local copy has no installation metadata, so its frontmatter
138+
# is the only thing a pin can be checked against.
136139
skill = _load_matching_skill(local_path, version)
137140
if skill is not None:
138141
return activate_skill(skill, source=source)
139142

140143
cache = SkillCacheManager()
141144
cached_path = cache.get_cached_path(org, name, version=version)
142145
if cached_path is not None and (cached_path / "SKILL.md").exists():
143-
skill = _load_matching_skill(cached_path, version)
146+
# get_cached_path already matched the pin against the version the cache
147+
# recorded when it stored the archive, which is authoritative. Checking
148+
# the frontmatter as well would miss on every skill that doesn't declare
149+
# metadata.version and re-download it on each resolution.
150+
skill = _load_skill(cached_path)
144151
if skill is not None:
145152
return activate_skill(skill, source=source)
146153

147154
return download_skill(org, name, source=source, version=version)
148155

149156

157+
def _load_skill(path: Path) -> Skill | None: # type: ignore[name-defined] # noqa: F821
158+
"""Load the skill at *path*, or None when it can't be read."""
159+
from crewai.skills.parser import load_skill_metadata
160+
161+
try:
162+
return load_skill_metadata(path)
163+
except Exception:
164+
_logger.debug("Failed to load skill at %s", path, exc_info=True)
165+
return None
166+
167+
150168
def _load_matching_skill(
151169
path: Path,
152170
version: str | None,
@@ -156,15 +174,8 @@ def _load_matching_skill(
156174
Skills record their version in SKILL.md frontmatter under
157175
``metadata.version``; a skill that declares no version can't satisfy a pin.
158176
"""
159-
from crewai.skills.parser import load_skill_metadata
160-
161-
try:
162-
skill = load_skill_metadata(path)
163-
except Exception:
164-
_logger.debug("Failed to load skill at %s", path, exc_info=True)
165-
return None
166-
167-
if version is None:
177+
skill = _load_skill(path)
178+
if skill is None or version is None:
168179
return skill
169180

170181
declared = (skill.frontmatter.metadata or {}).get("version")
@@ -204,7 +215,7 @@ def build_default_client() -> Any:
204215
)
205216

206217
client = resolve_plus_client(build_default_client)
207-
if not hasattr(client, "get_skill"):
218+
if not callable(getattr(client, "get_skill", None)):
208219
# A runtime older than the Skills Repository provides a client that
209220
# can't fetch skills; fall back so behavior matches a plain install.
210221
_logger.warning(

lib/crewai/tests/skills/test_cache.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ def test_get_cached_path_misses_a_different_version(self, tmp_path: Path) -> Non
7878

7979
assert cache.get_cached_path("acme", "my-skill", version="2.0.0") is None
8080

81+
def test_get_cached_path_misses_when_metadata_is_not_an_object(
82+
self, tmp_path: Path
83+
) -> None:
84+
cache = SkillCacheManager(cache_root=tmp_path)
85+
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
86+
dest = cache.store("acme", "my-skill", "1.0.0", archive)
87+
(dest / ".crewai_meta.json").write_text("[]", encoding="utf-8")
88+
89+
assert cache.get_cached_path("acme", "my-skill", version="1.0.0") is None
90+
8191
def test_get_cached_path_misses_when_the_cached_version_is_unknown(
8292
self, tmp_path: Path
8393
) -> None:

lib/crewai/tests/skills/test_registry.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ def test_empty_version(self) -> None:
114114
with pytest.raises(ValueError, match="version must be non-empty"):
115115
parse_skill_ref("@acme/my-skill@")
116116

117+
def test_whitespace_only_version(self) -> None:
118+
with pytest.raises(ValueError, match="version must be non-empty"):
119+
parse_skill_ref("@acme/my-skill@ ")
120+
121+
def test_strips_surrounding_whitespace_from_the_version(self) -> None:
122+
assert parse_skill_ref("@acme/my-skill@ 1.2.0 ").version == "1.2.0"
123+
117124
def test_round_trips_through_str(self) -> None:
118125
assert str(parse_skill_ref("@acme/my-skill@1.2.0")) == "@acme/my-skill@1.2.0"
119126
assert str(parse_skill_ref("@acme/my-skill")) == "@acme/my-skill"
@@ -247,6 +254,31 @@ def test_caches_the_pinned_version_so_it_resolves_without_a_second_download(
247254

248255
assert api.get_skill.call_count == 1
249256

257+
def test_caches_a_pinned_skill_that_declares_no_version_of_its_own(
258+
self, tmp_path: Path
259+
) -> None:
260+
"""The cache records the version it stored, so a skill whose SKILL.md
261+
omits metadata.version must still resolve from the cache rather than
262+
re-downloading on every resolution."""
263+
cache = SkillCacheManager(cache_root=tmp_path / "cache")
264+
api = MagicMock()
265+
# No version in the archive's frontmatter, only in the API payload.
266+
api.get_skill.return_value = _mock_skill_response("unversioned-skill")
267+
268+
with (
269+
patch.object(Path, "cwd", return_value=tmp_path),
270+
patch("crewai.auth.token.get_auth_token", return_value="saved-login"),
271+
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
272+
patch("crewai_core.plus_api.PlusAPI", return_value=api),
273+
):
274+
from crewai.skills.registry import resolve_registry_ref
275+
276+
resolve_registry_ref("@acme/unversioned-skill@1.0.0")
277+
skill = resolve_registry_ref("@acme/unversioned-skill@1.0.0")
278+
279+
assert skill.name == "unversioned-skill"
280+
assert api.get_skill.call_count == 1
281+
250282
def test_redownloads_when_the_cached_version_is_not_the_pinned_one(
251283
self, tmp_path: Path
252284
) -> None:
@@ -407,6 +439,27 @@ def test_falls_back_when_the_installed_client_cannot_fetch_skills(
407439
assert skill.name == "my-skill"
408440
plus_api.assert_called_once_with(api_key="user-pat", organization_id=None)
409441

442+
def test_falls_back_when_get_skill_is_not_callable(
443+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
444+
) -> None:
445+
cache = SkillCacheManager(cache_root=tmp_path / "cache")
446+
api = MagicMock()
447+
api.get_skill.return_value = _mock_skill_response("my-skill")
448+
monkeypatch.setenv("CREWAI_USER_PAT", "user-pat")
449+
450+
odd_client = MagicMock(spec=["get_skill"])
451+
odd_client.get_skill = "not-callable"
452+
set_plus_client_factory(lambda: odd_client)
453+
454+
with (
455+
patch("crewai.skills.registry.SkillCacheManager", return_value=cache),
456+
patch("crewai_core.plus_api.PlusAPI", return_value=api) as plus_api,
457+
):
458+
skill = download_skill("acme", "my-skill")
459+
460+
assert skill.name == "my-skill"
461+
plus_api.assert_called_once_with(api_key="user-pat", organization_id=None)
462+
410463
def test_reports_the_pinned_ref_when_the_download_fails(
411464
self, tmp_path: Path
412465
) -> None:

0 commit comments

Comments
 (0)