diff --git a/docs/docs.json b/docs/docs.json index 84eed2947..05c4cdece 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -148,6 +148,7 @@ "en/concepts/flows", "en/concepts/production-architecture", "en/concepts/knowledge", + "en/concepts/skills", "en/concepts/llms", "en/concepts/files", "en/concepts/processes", @@ -607,6 +608,7 @@ "en/concepts/flows", "en/concepts/production-architecture", "en/concepts/knowledge", + "en/concepts/skills", "en/concepts/llms", "en/concepts/files", "en/concepts/processes", diff --git a/docs/en/concepts/skills.mdx b/docs/en/concepts/skills.mdx index cfd7425c5..f509c66ea 100644 --- a/docs/en/concepts/skills.mdx +++ b/docs/en/concepts/skills.mdx @@ -28,26 +28,29 @@ The directory name must match the `name` field in `SKILL.md`. ```markdown --- name: my-skill -description: Short description of what this skill does. +description: Short description of what this skill does and when to use it. license: Apache-2.0 # optional compatibility: crewai>=0.1.0 # optional metadata: # optional author: your-name version: "1.0" -allowed_tools: # optional - - web-search - - file-read +allowed-tools: web-search file-read # optional, space-delimited --- Instructions for the agent go here. This markdown body is injected into the agent's prompt when the skill is activated. ``` -### Name Constraints +### Frontmatter Fields -- 1–64 characters -- Lowercase alphanumeric and hyphens only -- No leading, trailing, or consecutive hyphens +| Field | Required | Constraints | +| :-------------- | :------- | :----------------------------------------------------------------------- | +| `name` | Yes | 1–64 chars. Lowercase alphanumeric and hyphens. No leading/trailing/consecutive hyphens. Must match directory name. | +| `description` | Yes | 1–1024 chars. Describes what the skill does and when to use it. | +| `license` | No | License name or reference to a bundled license file. | +| `compatibility` | No | Max 500 chars. Environment requirements (products, packages, network). | +| `metadata` | No | Arbitrary string key-value mapping. | +| `allowed-tools` | No | Space-delimited list of pre-approved tools. Experimental. | ## Usage @@ -103,32 +106,11 @@ agent = Agent( Skills load progressively through three levels: -| Level | What's loaded | When | -|---|---|---| -| `METADATA` | Name, description, frontmatter fields | `discover_skills()` | -| `INSTRUCTIONS` | Full SKILL.md body text | `activate_skill()` | -| `RESOURCES` | File listings from scripts/, references/, assets/ | `load_resources()` | +| Level | What's loaded | When | +| :--------------- | :------------------------------------------------ | :----------------- | +| `METADATA` | Name, description, frontmatter fields | `discover_skills()` | +| `INSTRUCTIONS` | Full SKILL.md body text | `activate_skill()` | +| `RESOURCES` | File listings from scripts/, references/, assets/ | `load_resources()` | During normal agent execution, skills are automatically discovered and activated (promoted to `INSTRUCTIONS`). Use `load_resources()` only when you need to inspect available files programmatically. -## Events - -Skill operations emit events via the CrewAI event bus: - -| Event | When | -|---|---| -| `SkillDiscoveryStartedEvent` | Discovery scan begins | -| `SkillDiscoveryCompletedEvent` | Discovery scan finishes | -| `SkillLoadedEvent` | A skill is loaded at metadata level | -| `SkillActivatedEvent` | A skill is promoted to instructions level | -| `SkillLoadFailedEvent` | A skill fails to load | - -```python -from crewai.events import BaseEventListener, SkillActivatedEvent - -class SkillLogger(BaseEventListener): - def setup_listeners(self, crewai_event_bus): - @crewai_event_bus.on(SkillActivatedEvent) - def on_activated(source, event): - print(f"Activated: {event.skill_name}") -``` diff --git a/lib/crewai/src/crewai/skills/models.py b/lib/crewai/src/crewai/skills/models.py index 4749ab4b5..098015bbc 100644 --- a/lib/crewai/src/crewai/skills/models.py +++ b/lib/crewai/src/crewai/skills/models.py @@ -8,10 +8,18 @@ from __future__ import annotations from enum import IntEnum from pathlib import Path +from typing import Any -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, model_validator -from crewai.skills.validation import validate_skill_name +from crewai.skills.validation import ( + MAX_SKILL_NAME_LENGTH, + MIN_SKILL_NAME_LENGTH, + SKILL_NAME_PATTERN, +) + + +MAX_DESCRIPTION_LENGTH: int = 1024 class DisclosureLevel(IntEnum): @@ -28,30 +36,41 @@ class DisclosureLevel(IntEnum): RESOURCES = 3 -class SkillFrontmatter(BaseModel, frozen=True): +class SkillFrontmatter(BaseModel): """YAML frontmatter from a SKILL.md file. Attributes: name: Unique skill identifier (1-64 chars, lowercase alphanumeric + hyphens). - description: Human-readable description of the skill. - license: Optional SPDX license identifier. - compatibility: Optional compatibility information. + description: Human-readable description (1-1024 chars). + license: Optional license name or reference. + compatibility: Optional compatibility information (max 500 chars). metadata: Optional additional metadata as string key-value pairs. - allowed_tools: Optional list of tools the skill may use. + allowed_tools: Optional space-delimited list of pre-approved tools. """ - name: str - description: str - license: str | None = None - compatibility: str | None = None - metadata: dict[str, str] | None = None - allowed_tools: list[str] | None = None + model_config = {"frozen": True, "populate_by_name": True} - @field_validator("name") + name: str = Field( + min_length=MIN_SKILL_NAME_LENGTH, + max_length=MAX_SKILL_NAME_LENGTH, + pattern=SKILL_NAME_PATTERN, + ) + description: str = Field(min_length=1, max_length=MAX_DESCRIPTION_LENGTH) + license: str | None = None + compatibility: str | None = Field(default=None, max_length=500) + metadata: dict[str, str] | None = None + allowed_tools: list[str] | None = Field(default=None, alias="allowed-tools") + + @model_validator(mode="before") @classmethod - def check_name(cls, v: str) -> str: - """Validate skill name against spec constraints.""" - return validate_skill_name(v) + def parse_allowed_tools(cls, values: dict[str, Any]) -> dict[str, Any]: + """Parse space-delimited allowed-tools string into a list.""" + key = "allowed-tools" + alt_key = "allowed_tools" + raw = values.get(key) or values.get(alt_key) + if isinstance(raw, str): + values[key] = raw.split() + return values class Skill(BaseModel): @@ -96,18 +115,6 @@ class Skill(BaseModel): """Path to the assets directory.""" return self.path / "assets" - def has_scripts(self) -> bool: - """Check if the skill has a scripts directory.""" - return self.scripts_dir.is_dir() - - def has_references(self) -> bool: - """Check if the skill has a references directory.""" - return self.references_dir.is_dir() - - def has_assets(self) -> bool: - """Check if the skill has an assets directory.""" - return self.assets_dir.is_dir() - def with_disclosure_level( self, level: DisclosureLevel, diff --git a/lib/crewai/src/crewai/skills/parser.py b/lib/crewai/src/crewai/skills/parser.py index b4f257948..32dc037c8 100644 --- a/lib/crewai/src/crewai/skills/parser.py +++ b/lib/crewai/src/crewai/skills/parser.py @@ -146,8 +146,11 @@ def load_skill_resources(skill: Skill) -> Skill: skill = load_skill_instructions(skill) resource_files: dict[str, list[str]] = {} - for dir_name in ("scripts", "references", "assets"): - resource_dir = skill.path / dir_name + for dir_name, resource_dir in ( + ("scripts", skill.scripts_dir), + ("references", skill.references_dir), + ("assets", skill.assets_dir), + ): if resource_dir.is_dir(): resource_files[dir_name] = sorted( str(f.relative_to(resource_dir)) diff --git a/lib/crewai/src/crewai/skills/validation.py b/lib/crewai/src/crewai/skills/validation.py index 681f3ba1e..9c8ac07a0 100644 --- a/lib/crewai/src/crewai/skills/validation.py +++ b/lib/crewai/src/crewai/skills/validation.py @@ -6,49 +6,11 @@ Validates skill names and directory structures per the Agent Skills standard. from __future__ import annotations from pathlib import Path -import re MAX_SKILL_NAME_LENGTH: int = 64 MIN_SKILL_NAME_LENGTH: int = 1 - -_SKILL_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def validate_skill_name(name: str) -> str: - """Validate a skill name against the Agent Skills specification. - - Names must be 1-64 characters, lowercase alphanumeric with hyphens, - no leading/trailing hyphens, and no consecutive hyphens. - - Args: - name: The skill name to validate. - - Returns: - The validated skill name. - - Raises: - ValueError: If the name violates any constraint. - """ - if len(name) < MIN_SKILL_NAME_LENGTH: - msg = "Skill name must not be empty" - raise ValueError(msg) - - if len(name) > MAX_SKILL_NAME_LENGTH: - msg = ( - f"Skill name must be at most {MAX_SKILL_NAME_LENGTH} characters, " - f"got {len(name)}" - ) - raise ValueError(msg) - - if not _SKILL_NAME_PATTERN.match(name): - msg = ( - f"Invalid skill name '{name}'. Names must be lowercase alphanumeric " - f"with single hyphens, no leading/trailing hyphens." - ) - raise ValueError(msg) - - return name +SKILL_NAME_PATTERN: str = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" def validate_directory_name(skill_dir: Path, skill_name: str) -> None: diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md index bc262184d..f69e7b463 100644 --- a/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md +++ b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md @@ -6,9 +6,7 @@ compatibility: crewai>=0.1.0 metadata: author: test version: "1.0" -allowed_tools: - - web-search - - file-read +allowed-tools: web-search file-read --- ## Instructions diff --git a/lib/crewai/tests/skills/test_models.py b/lib/crewai/tests/skills/test_models.py index 1433e708e..8de07b7b5 100644 --- a/lib/crewai/tests/skills/test_models.py +++ b/lib/crewai/tests/skills/test_models.py @@ -73,27 +73,6 @@ class TestSkill: assert skill.references_dir == skill_dir / "references" assert skill.assets_dir == skill_dir / "assets" - def test_has_dirs_false(self, tmp_path: Path) -> None: - skill_dir = tmp_path / "test-skill" - skill_dir.mkdir() - fm = SkillFrontmatter(name="test-skill", description="desc") - skill = Skill(frontmatter=fm, path=skill_dir) - assert not skill.has_scripts() - assert not skill.has_references() - assert not skill.has_assets() - - def test_has_dirs_true(self, tmp_path: Path) -> None: - skill_dir = tmp_path / "test-skill" - skill_dir.mkdir() - (skill_dir / "scripts").mkdir() - (skill_dir / "references").mkdir() - (skill_dir / "assets").mkdir() - fm = SkillFrontmatter(name="test-skill", description="desc") - skill = Skill(frontmatter=fm, path=skill_dir) - assert skill.has_scripts() - assert skill.has_references() - assert skill.has_assets() - def test_with_disclosure_level(self, tmp_path: Path) -> None: fm = SkillFrontmatter(name="test-skill", description="desc") skill = Skill(frontmatter=fm, path=tmp_path) diff --git a/lib/crewai/tests/skills/test_validation.py b/lib/crewai/tests/skills/test_validation.py index 95cc3135f..982a9d534 100644 --- a/lib/crewai/tests/skills/test_validation.py +++ b/lib/crewai/tests/skills/test_validation.py @@ -1,77 +1,81 @@ -"""Tests for skills/validation.py.""" +"""Tests for skills validation.""" from pathlib import Path import pytest +from crewai.skills.models import SkillFrontmatter from crewai.skills.validation import ( MAX_SKILL_NAME_LENGTH, validate_directory_name, - validate_skill_name, ) -class TestValidateSkillName: - """Tests for validate_skill_name.""" +def _make(name: str) -> SkillFrontmatter: + """Create a SkillFrontmatter with the given name.""" + return SkillFrontmatter(name=name, description="desc") + + +class TestSkillNameValidation: + """Tests for skill name constraints via SkillFrontmatter.""" def test_simple_name(self) -> None: - assert validate_skill_name("web-search") == "web-search" + assert _make("web-search").name == "web-search" def test_single_word(self) -> None: - assert validate_skill_name("search") == "search" + assert _make("search").name == "search" def test_numeric(self) -> None: - assert validate_skill_name("tool3") == "tool3" + assert _make("tool3").name == "tool3" def test_all_digits(self) -> None: - assert validate_skill_name("123") == "123" + assert _make("123").name == "123" def test_single_char(self) -> None: - assert validate_skill_name("a") == "a" + assert _make("a").name == "a" def test_max_length(self) -> None: name = "a" * MAX_SKILL_NAME_LENGTH - assert validate_skill_name(name) == name + assert _make(name).name == name def test_multi_hyphen_segments(self) -> None: - assert validate_skill_name("my-cool-skill") == "my-cool-skill" + assert _make("my-cool-skill").name == "my-cool-skill" def test_empty_raises(self) -> None: - with pytest.raises(ValueError, match="must not be empty"): - validate_skill_name("") + with pytest.raises(ValueError): + _make("") def test_too_long_raises(self) -> None: - name = "a" * (MAX_SKILL_NAME_LENGTH + 1) - with pytest.raises(ValueError, match="at most"): - validate_skill_name(name) + with pytest.raises(ValueError): + _make("a" * (MAX_SKILL_NAME_LENGTH + 1)) def test_uppercase_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("MySkill") + with pytest.raises(ValueError): + _make("MySkill") def test_leading_hyphen_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("-skill") + with pytest.raises(ValueError): + _make("-skill") def test_trailing_hyphen_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("skill-") + with pytest.raises(ValueError): + _make("skill-") def test_consecutive_hyphens_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("my--skill") + with pytest.raises(ValueError): + _make("my--skill") def test_underscore_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("my_skill") + with pytest.raises(ValueError): + _make("my_skill") def test_space_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("my skill") + with pytest.raises(ValueError): + _make("my skill") def test_special_chars_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid skill name"): - validate_skill_name("skill@v1") + with pytest.raises(ValueError): + _make("skill@v1") class TestValidateDirectoryName: