diff --git a/docs/docs.json b/docs/docs.json index 42cf18b10..87fa3182f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -155,6 +155,7 @@ "en/concepts/flows", "en/concepts/production-architecture", "en/concepts/knowledge", + "en/concepts/skills", "en/concepts/llms", "en/concepts/files", "en/concepts/processes", @@ -1556,6 +1557,7 @@ "en/concepts/flows", "en/concepts/production-architecture", "en/concepts/knowledge", + "en/concepts/skills", "en/concepts/llms", "en/concepts/files", "en/concepts/processes", @@ -2053,6 +2055,7 @@ "pt-BR/concepts/flows", "pt-BR/concepts/production-architecture", "pt-BR/concepts/knowledge", + "pt-BR/concepts/skills", "pt-BR/concepts/llms", "pt-BR/concepts/files", "pt-BR/concepts/processes", @@ -3412,6 +3415,7 @@ "pt-BR/concepts/flows", "pt-BR/concepts/production-architecture", "pt-BR/concepts/knowledge", + "pt-BR/concepts/skills", "pt-BR/concepts/llms", "pt-BR/concepts/files", "pt-BR/concepts/processes", @@ -3895,6 +3899,7 @@ "ko/concepts/flows", "ko/concepts/production-architecture", "ko/concepts/knowledge", + "ko/concepts/skills", "ko/concepts/llms", "ko/concepts/files", "ko/concepts/processes", @@ -5290,6 +5295,7 @@ "ko/concepts/flows", "ko/concepts/production-architecture", "ko/concepts/knowledge", + "ko/concepts/skills", "ko/concepts/llms", "ko/concepts/files", "ko/concepts/processes", diff --git a/docs/en/concepts/skills.mdx b/docs/en/concepts/skills.mdx new file mode 100644 index 000000000..90a7f822d --- /dev/null +++ b/docs/en/concepts/skills.mdx @@ -0,0 +1,115 @@ +--- +title: Skills +description: Filesystem-based skill packages that inject context into agent prompts. +icon: bolt +mode: "wide" +--- + +## Overview + +Skills are self-contained directories that provide agents with domain-specific instructions, references, and assets. Each skill is defined by a `SKILL.md` file with YAML frontmatter and a markdown body. + +Skills use **progressive disclosure** — metadata is loaded first, full instructions only when activated, and resource catalogs only when needed. + +## Directory Structure + +``` +my-skill/ +├── SKILL.md # Required — frontmatter + instructions +├── scripts/ # Optional — executable scripts +├── references/ # Optional — reference documents +└── assets/ # Optional — static files (configs, data) +``` + +The directory name must match the `name` field in `SKILL.md`. + +## SKILL.md Format + +```markdown +--- +name: my-skill +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: 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. +``` + +### Frontmatter Fields + +| 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 + +### Agent-level Skills + +Pass skill directory paths to an agent: + +```python +from crewai import Agent + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=["./skills"], # discovers all skills in this directory +) +``` + +### Crew-level Skills + +Skill paths on a crew are merged into every agent: + +```python +from crewai import Crew + +crew = Crew( + agents=[agent], + tasks=[task], + skills=["./skills"], +) +``` + +### Pre-loaded Skills + +You can also pass `Skill` objects directly: + +```python +from pathlib import Path +from crewai.skills import discover_skills, activate_skill + +skills = discover_skills(Path("./skills")) +activated = [activate_skill(s) for s in skills] + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=activated, +) +``` + +## How Skills Are Loaded + +Skills load progressively — only the data needed at each stage is read: + +| Stage | What's loaded | When | +| :--------------- | :------------------------------------------------ | :----------------- | +| Discovery | Name, description, frontmatter fields | `discover_skills()` | +| Activation | Full SKILL.md body text | `activate_skill()` | + +During normal agent execution, skills are automatically discovered and activated. The `scripts/`, `references/`, and `assets/` directories are available on the skill's `path` for agents that need to reference files directly. + diff --git a/docs/ko/concepts/skills.mdx b/docs/ko/concepts/skills.mdx new file mode 100644 index 000000000..a6361bce2 --- /dev/null +++ b/docs/ko/concepts/skills.mdx @@ -0,0 +1,114 @@ +--- +title: 스킬 +description: 에이전트 프롬프트에 컨텍스트를 주입하는 파일 시스템 기반 스킬 패키지. +icon: bolt +mode: "wide" +--- + +## 개요 + +스킬은 에이전트에게 도메인별 지침, 참조 자료, 에셋을 제공하는 자체 포함 디렉터리입니다. 각 스킬은 YAML 프론트매터와 마크다운 본문이 포함된 `SKILL.md` 파일로 정의됩니다. + +스킬은 **점진적 공개**를 사용합니다 — 메타데이터가 먼저 로드되고, 활성화 시에만 전체 지침이 로드되며, 필요할 때만 리소스 카탈로그가 로드됩니다. + +## 디렉터리 구조 + +``` +my-skill/ +├── SKILL.md # 필수 — 프론트매터 + 지침 +├── scripts/ # 선택 — 실행 가능한 스크립트 +├── references/ # 선택 — 참조 문서 +└── assets/ # 선택 — 정적 파일 (설정, 데이터) +``` + +디렉터리 이름은 `SKILL.md`의 `name` 필드와 일치해야 합니다. + +## SKILL.md 형식 + +```markdown +--- +name: my-skill +description: 이 스킬이 무엇을 하고 언제 사용하는지에 대한 간단한 설명. +license: Apache-2.0 # 선택 +compatibility: crewai>=0.1.0 # 선택 +metadata: # 선택 + author: your-name + version: "1.0" +allowed-tools: web-search file-read # 선택, 공백으로 구분 +--- + +에이전트를 위한 지침이 여기에 들어갑니다. 이 마크다운 본문은 +스킬이 활성화되면 에이전트의 프롬프트에 주입됩니다. +``` + +### 프론트매터 필드 + +| 필드 | 필수 | 제약 조건 | +| :-------------- | :----- | :----------------------------------------------------------------------- | +| `name` | 예 | 1–64자. 소문자 영숫자와 하이픈. 선행/후행/연속 하이픈 불가. 디렉터리 이름과 일치 필수. | +| `description` | 예 | 1–1024자. 스킬이 무엇을 하고 언제 사용하는지 설명. | +| `license` | 아니오 | 라이선스 이름 또는 번들된 라이선스 파일 참조. | +| `compatibility` | 아니오 | 최대 500자. 환경 요구 사항 (제품, 패키지, 네트워크). | +| `metadata` | 아니오 | 임의의 문자열 키-값 매핑. | +| `allowed-tools` | 아니오 | 공백으로 구분된 사전 승인 도구 목록. 실험적. | + +## 사용법 + +### 에이전트 레벨 스킬 + +에이전트에 스킬 디렉터리 경로를 전달합니다: + +```python +from crewai import Agent + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=["./skills"], # 이 디렉터리의 모든 스킬을 검색 +) +``` + +### 크루 레벨 스킬 + +크루의 스킬 경로는 모든 에이전트에 병합됩니다: + +```python +from crewai import Crew + +crew = Crew( + agents=[agent], + tasks=[task], + skills=["./skills"], +) +``` + +### 사전 로드된 스킬 + +`Skill` 객체를 직접 전달할 수도 있습니다: + +```python +from pathlib import Path +from crewai.skills import discover_skills, activate_skill + +skills = discover_skills(Path("./skills")) +activated = [activate_skill(s) for s in skills] + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=activated, +) +``` + +## 스킬 로드 방식 + +스킬은 점진적으로 로드됩니다 — 각 단계에서 필요한 데이터만 읽습니다: + +| 단계 | 로드되는 내용 | 시점 | +| :--------------- | :------------------------------------------------ | :----------------- | +| 검색 | 이름, 설명, 프론트매터 필드 | `discover_skills()` | +| 활성화 | 전체 SKILL.md 본문 텍스트 | `activate_skill()` | + +일반적인 에이전트 실행 중에 스킬은 자동으로 검색되고 활성화됩니다. `scripts/`, `references/`, `assets/` 디렉터리는 파일을 직접 참조해야 하는 에이전트를 위해 스킬의 `path`에서 사용할 수 있습니다. diff --git a/docs/pt-BR/concepts/skills.mdx b/docs/pt-BR/concepts/skills.mdx new file mode 100644 index 000000000..1af37f9e2 --- /dev/null +++ b/docs/pt-BR/concepts/skills.mdx @@ -0,0 +1,114 @@ +--- +title: Skills +description: Pacotes de skills baseados em sistema de arquivos que injetam contexto nos prompts dos agentes. +icon: bolt +mode: "wide" +--- + +## Visão Geral + +Skills são diretórios autocontidos que fornecem aos agentes instruções, referências e assets específicos de domínio. Cada skill é definida por um arquivo `SKILL.md` com frontmatter YAML e um corpo em markdown. + +Skills usam **divulgação progressiva** — metadados são carregados primeiro, instruções completas apenas quando ativadas, e catálogos de recursos apenas quando necessário. + +## Estrutura de Diretório + +``` +my-skill/ +├── SKILL.md # Obrigatório — frontmatter + instruções +├── scripts/ # Opcional — scripts executáveis +├── references/ # Opcional — documentos de referência +└── assets/ # Opcional — arquivos estáticos (configs, dados) +``` + +O nome do diretório deve corresponder ao campo `name` no `SKILL.md`. + +## Formato do SKILL.md + +```markdown +--- +name: my-skill +description: Descrição curta do que esta skill faz e quando usá-la. +license: Apache-2.0 # opcional +compatibility: crewai>=0.1.0 # opcional +metadata: # opcional + author: your-name + version: "1.0" +allowed-tools: web-search file-read # opcional, delimitado por espaços +--- + +Instruções para o agente vão aqui. Este corpo em markdown é injetado +no prompt do agente quando a skill é ativada. +``` + +### Campos do Frontmatter + +| Campo | Obrigatório | Restrições | +| :-------------- | :---------- | :----------------------------------------------------------------------- | +| `name` | Sim | 1–64 chars. Alfanumérico minúsculo e hifens. Sem hifens iniciais/finais/consecutivos. Deve corresponder ao nome do diretório. | +| `description` | Sim | 1–1024 chars. Descreve o que a skill faz e quando usá-la. | +| `license` | Não | Nome da licença ou referência a um arquivo de licença incluído. | +| `compatibility` | Não | Máx 500 chars. Requisitos de ambiente (produtos, pacotes, rede). | +| `metadata` | Não | Mapeamento arbitrário de chave-valor string. | +| `allowed-tools` | Não | Lista de ferramentas pré-aprovadas delimitada por espaços. Experimental. | + +## Uso + +### Skills no Nível do Agente + +Passe caminhos de diretório de skills para um agente: + +```python +from crewai import Agent + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=["./skills"], # descobre todas as skills neste diretório +) +``` + +### Skills no Nível do Crew + +Caminhos de skills no crew são mesclados em todos os agentes: + +```python +from crewai import Crew + +crew = Crew( + agents=[agent], + tasks=[task], + skills=["./skills"], +) +``` + +### Skills Pré-carregadas + +Você também pode passar objetos `Skill` diretamente: + +```python +from pathlib import Path +from crewai.skills import discover_skills, activate_skill + +skills = discover_skills(Path("./skills")) +activated = [activate_skill(s) for s in skills] + +agent = Agent( + role="Researcher", + goal="Find relevant information", + backstory="An expert researcher.", + skills=activated, +) +``` + +## Como as Skills São Carregadas + +Skills carregam progressivamente — apenas os dados necessários em cada etapa são lidos: + +| Etapa | O que é carregado | Quando | +| :--------------- | :------------------------------------------------ | :------------------ | +| Descoberta | Nome, descrição, campos do frontmatter | `discover_skills()` | +| Ativação | Texto completo do corpo do SKILL.md | `activate_skill()` | + +Durante a execução normal do agente, skills são automaticamente descobertas e ativadas. Os diretórios `scripts/`, `references/` e `assets/` estão disponíveis no `path` da skill para agentes que precisam referenciar arquivos diretamente. diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 80a4976bc..9c4b78ec5 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "mcp~=1.26.0", "uv~=0.9.13", "aiosqlite~=0.21.0", + "pyyaml~=6.0", "lancedb>=0.29.2", ] diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 3aa48137d..868c14344 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Sequence import contextvars +from pathlib import Path import shutil import subprocess import time @@ -26,6 +27,7 @@ from typing_extensions import Self from crewai.agent.planning_config import PlanningConfig from crewai.agent.utils import ( ahandle_knowledge_retrieval, + append_skill_context, apply_training_data, build_task_prompt_with_schema, format_task_with_context, @@ -65,6 +67,8 @@ from crewai.mcp import MCPServerConfig from crewai.mcp.tool_resolver import MCPToolResolver from crewai.rag.embeddings.types import EmbedderConfig from crewai.security.fingerprint import Fingerprint +from crewai.skills.loader import activate_skill, discover_skills +from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel from crewai.tools.agent_tools.agent_tools import AgentTools from crewai.types.callback import SerializableCallable from crewai.utilities.agent_utils import ( @@ -278,6 +282,8 @@ class Agent(BaseAgent): if self.allow_code_execution: self._validate_docker_installation() + self.set_skills() + # Handle backward compatibility: convert reasoning=True to planning_config if self.reasoning and self.planning_config is None: import warnings @@ -321,6 +327,76 @@ class Agent(BaseAgent): except (TypeError, ValueError) as e: raise ValueError(f"Invalid Knowledge Configuration: {e!s}") from e + def set_skills( + self, + resolved_crew_skills: list[SkillModel] | None = None, + ) -> None: + """Resolve skill paths and activate skills to INSTRUCTIONS level. + + Path entries trigger discovery and activation. Pre-loaded Skill objects + below INSTRUCTIONS level are activated. Crew-level skills are merged in + with event emission so observability is consistent regardless of origin. + + Args: + resolved_crew_skills: Pre-resolved crew skills (already discovered + and activated). When provided, avoids redundant discovery per agent. + """ + from crewai.crew import Crew + from crewai.events.event_bus import crewai_event_bus + from crewai.events.types.skill_events import SkillActivatedEvent + + if resolved_crew_skills is None: + crew_skills: list[Path | SkillModel] | None = ( + self.crew.skills + if isinstance(self.crew, Crew) and isinstance(self.crew.skills, list) + else None + ) + else: + crew_skills = list(resolved_crew_skills) + + if not self.skills and not crew_skills: + return + + needs_work = self.skills and any( + isinstance(s, Path) + or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS) + for s in self.skills + ) + if not needs_work and not crew_skills: + return + + seen: set[str] = set() + resolved: list[Path | SkillModel] = [] + items: list[Path | SkillModel] = list(self.skills) if self.skills else [] + + if crew_skills: + items.extend(crew_skills) + + for item in items: + if isinstance(item, Path): + discovered = discover_skills(item, source=self) + for skill in discovered: + if skill.name not in seen: + seen.add(skill.name) + resolved.append(activate_skill(skill, source=self)) + elif isinstance(item, SkillModel): + if item.name not in seen: + seen.add(item.name) + activated = activate_skill(item, source=self) + if activated is item and item.disclosure_level >= INSTRUCTIONS: + crewai_event_bus.emit( + self, + event=SkillActivatedEvent( + from_agent=self, + skill_name=item.name, + skill_path=item.path, + disclosure_level=item.disclosure_level, + ), + ) + resolved.append(activated) + + self.skills = resolved if resolved else None + def _is_any_available_memory(self) -> bool: """Check if unified memory is available (agent or crew).""" if getattr(self, "memory", None): @@ -442,6 +518,8 @@ class Agent(BaseAgent): self.crew.query_knowledge if self.crew else lambda *a, **k: None, ) + task_prompt = append_skill_context(self, task_prompt) + prepare_tools(self, tools, task) task_prompt = apply_training_data(self, task_prompt) @@ -682,6 +760,8 @@ class Agent(BaseAgent): self, task, task_prompt, knowledge_config ) + task_prompt = append_skill_context(self, task_prompt) + prepare_tools(self, tools, task) task_prompt = apply_training_data(self, task_prompt) @@ -1343,6 +1423,8 @@ class Agent(BaseAgent): ), ) + formatted_messages = append_skill_context(self, formatted_messages) + # Build the input dict for the executor inputs: dict[str, Any] = { "input": formatted_messages, diff --git a/lib/crewai/src/crewai/agent/utils.py b/lib/crewai/src/crewai/agent/utils.py index fc74db433..88accddf3 100644 --- a/lib/crewai/src/crewai/agent/utils.py +++ b/lib/crewai/src/crewai/agent/utils.py @@ -210,6 +210,30 @@ def _combine_knowledge_context(agent: Agent) -> str: return agent_ctx + separator + crew_ctx +def append_skill_context(agent: Agent, task_prompt: str) -> str: + """Append activated skill context sections to the task prompt. + + Args: + agent: The agent with optional skills. + task_prompt: The current task prompt. + + Returns: + The task prompt with skill context appended. + """ + if not agent.skills: + return task_prompt + + from crewai.skills.loader import format_skill_context + from crewai.skills.models import Skill + + skill_sections = [ + format_skill_context(s) for s in agent.skills if isinstance(s, Skill) + ] + if skill_sections: + task_prompt += "\n\n" + "\n\n".join(skill_sections) + return task_prompt + + def apply_training_data(agent: Agent, task_prompt: str) -> str: """Apply training data to the task prompt. diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py index 674b15fa8..9949343e2 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from copy import copy as shallow_copy from hashlib import md5 +from pathlib import Path import re from typing import Any, Final, Literal import uuid @@ -32,6 +33,7 @@ from crewai.memory.memory_scope import MemoryScope, MemorySlice from crewai.memory.unified_memory import Memory from crewai.rag.embeddings.types import EmbedderConfig from crewai.security.security_config import SecurityConfig +from crewai.skills.models import Skill from crewai.tools.base_tool import BaseTool, Tool from crewai.types.callback import SerializableCallable from crewai.utilities.config import process_config @@ -217,6 +219,11 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): "If not set, falls back to crew memory." ), ) + skills: list[Path | Skill] | None = Field( + default=None, + description="Agent Skills. Accepts paths for discovery or pre-loaded Skill objects.", + min_length=1, + ) @model_validator(mode="before") @classmethod @@ -500,3 +507,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None: pass + + def set_skills(self, resolved_crew_skills: list[Any] | None = None) -> None: + pass diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index e130dce7b..00fbae78f 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -6,6 +6,7 @@ from concurrent.futures import Future from copy import copy as shallow_copy from hashlib import md5 import json +from pathlib import Path import re from typing import ( TYPE_CHECKING, @@ -91,6 +92,7 @@ from crewai.rag.embeddings.types import EmbedderConfig from crewai.rag.types import SearchResult from crewai.security.fingerprint import Fingerprint from crewai.security.security_config import SecurityConfig +from crewai.skills.models import Skill from crewai.task import Task from crewai.tasks.conditional_task import ConditionalTask from crewai.tasks.task_output import TaskOutput @@ -294,6 +296,11 @@ class Crew(FlowTrackable, BaseModel): default=None, description="Knowledge for the crew.", ) + skills: list[Path | Skill] | None = Field( + default=None, + description="Skill search paths or pre-loaded Skill objects applied to all agents in the crew.", + ) + security_config: SecurityConfig = Field( default_factory=SecurityConfig, description="Security configuration for the crew, including fingerprinting.", @@ -376,7 +383,7 @@ class Crew(FlowTrackable, BaseModel): if self.embedder is not None: from crewai.rag.embeddings.factory import build_embedder - embedder = build_embedder(self.embedder) # type: ignore[arg-type] + embedder = build_embedder(cast(dict[str, Any], self.embedder)) self._memory = Memory(embedder=embedder, root_scope=crew_root_scope) elif self.memory: # User passed a Memory / MemoryScope / MemorySlice instance diff --git a/lib/crewai/src/crewai/crews/utils.py b/lib/crewai/src/crewai/crews/utils.py index a432d2fc2..0b50e60bb 100644 --- a/lib/crewai/src/crewai/crews/utils.py +++ b/lib/crewai/src/crewai/crews/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable, Mapping +from pathlib import Path from typing import TYPE_CHECKING, Any from opentelemetry import baggage @@ -11,6 +12,8 @@ from opentelemetry import baggage from crewai.agents.agent_builder.base_agent import BaseAgent from crewai.crews.crew_output import CrewOutput from crewai.rag.embeddings.types import EmbedderConfig +from crewai.skills.loader import activate_skill, discover_skills +from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput from crewai.utilities.file_store import store_files from crewai.utilities.streaming import ( @@ -51,6 +54,30 @@ def enable_agent_streaming(agents: Iterable[BaseAgent]) -> None: agent.llm.stream = True +def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None: + """Resolve crew-level skill paths once so agents don't repeat the work.""" + if not isinstance(crew.skills, list) or not crew.skills: + return None + + resolved: list[SkillModel] = [] + seen: set[str] = set() + for item in crew.skills: + if isinstance(item, Path): + for skill in discover_skills(item): + if skill.name not in seen: + seen.add(skill.name) + resolved.append(activate_skill(skill)) + elif isinstance(item, SkillModel): + if item.name not in seen: + seen.add(item.name) + resolved.append( + activate_skill(item) + if item.disclosure_level < INSTRUCTIONS + else item + ) + return resolved + + def setup_agents( crew: Crew, agents: Iterable[BaseAgent], @@ -67,9 +94,12 @@ def setup_agents( function_calling_llm: Default function calling LLM for agents. step_callback: Default step callback for agents. """ + resolved_crew_skills = _resolve_crew_skills(crew) + for agent in agents: agent.crew = crew agent.set_knowledge(crew_embedder=embedder) + agent.set_skills(resolved_crew_skills=resolved_crew_skills) if not agent.function_calling_llm: # type: ignore[attr-defined] agent.function_calling_llm = function_calling_llm # type: ignore[attr-defined] if not agent.step_callback: # type: ignore[attr-defined] diff --git a/lib/crewai/src/crewai/events/__init__.py b/lib/crewai/src/crewai/events/__init__.py index 36933fc45..bcdafe49a 100644 --- a/lib/crewai/src/crewai/events/__init__.py +++ b/lib/crewai/src/crewai/events/__init__.py @@ -88,6 +88,14 @@ from crewai.events.types.reasoning_events import ( AgentReasoningStartedEvent, ReasoningEvent, ) +from crewai.events.types.skill_events import ( + SkillActivatedEvent, + SkillDiscoveryCompletedEvent, + SkillDiscoveryStartedEvent, + SkillEvent, + SkillLoadFailedEvent, + SkillLoadedEvent, +) from crewai.events.types.task_events import ( TaskCompletedEvent, TaskEvaluationEvent, @@ -186,6 +194,12 @@ __all__ = [ "MethodExecutionFinishedEvent", "MethodExecutionStartedEvent", "ReasoningEvent", + "SkillActivatedEvent", + "SkillDiscoveryCompletedEvent", + "SkillDiscoveryStartedEvent", + "SkillEvent", + "SkillLoadFailedEvent", + "SkillLoadedEvent", "TaskCompletedEvent", "TaskEvaluationEvent", "TaskFailedEvent", diff --git a/lib/crewai/src/crewai/events/types/skill_events.py b/lib/crewai/src/crewai/events/types/skill_events.py new file mode 100644 index 000000000..f99d6bd70 --- /dev/null +++ b/lib/crewai/src/crewai/events/types/skill_events.py @@ -0,0 +1,62 @@ +"""Skill lifecycle events for the Agent Skills standard. + +Events emitted during skill discovery, loading, and activation. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from crewai.events.base_events import BaseEvent + + +class SkillEvent(BaseEvent): + """Base event for skill operations.""" + + skill_name: str = "" + skill_path: Path | None = None + from_agent: Any | None = None + from_task: Any | None = None + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + self._set_agent_params(data) + self._set_task_params(data) + + +class SkillDiscoveryStartedEvent(SkillEvent): + """Event emitted when skill discovery begins.""" + + type: str = "skill_discovery_started" + search_path: Path + + +class SkillDiscoveryCompletedEvent(SkillEvent): + """Event emitted when skill discovery completes.""" + + type: str = "skill_discovery_completed" + search_path: Path + skills_found: int + skill_names: list[str] + + +class SkillLoadedEvent(SkillEvent): + """Event emitted when a skill is loaded at metadata level.""" + + type: str = "skill_loaded" + disclosure_level: int = 1 + + +class SkillActivatedEvent(SkillEvent): + """Event emitted when a skill is activated (promoted to instructions level).""" + + type: str = "skill_activated" + disclosure_level: int = 2 + + +class SkillLoadFailedEvent(SkillEvent): + """Event emitted when skill loading fails.""" + + type: str = "skill_load_failed" + error: str diff --git a/lib/crewai/src/crewai/skills/__init__.py b/lib/crewai/src/crewai/skills/__init__.py new file mode 100644 index 000000000..e33e98570 --- /dev/null +++ b/lib/crewai/src/crewai/skills/__init__.py @@ -0,0 +1,17 @@ +"""Agent Skills standard implementation for crewAI. + +Provides filesystem-based skill packaging with progressive disclosure. +""" + +from crewai.skills.loader import activate_skill, discover_skills +from crewai.skills.models import Skill, SkillFrontmatter +from crewai.skills.parser import SkillParseError + + +__all__ = [ + "Skill", + "SkillFrontmatter", + "SkillParseError", + "activate_skill", + "discover_skills", +] diff --git a/lib/crewai/src/crewai/skills/loader.py b/lib/crewai/src/crewai/skills/loader.py new file mode 100644 index 000000000..78e244f90 --- /dev/null +++ b/lib/crewai/src/crewai/skills/loader.py @@ -0,0 +1,184 @@ +"""Filesystem discovery and progressive loading for Agent Skills. + +Provides functions to discover skills in directories, activate them +for agent use, and format skill context for prompt injection. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.skill_events import ( + SkillActivatedEvent, + SkillDiscoveryCompletedEvent, + SkillDiscoveryStartedEvent, + SkillLoadFailedEvent, + SkillLoadedEvent, +) +from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill +from crewai.skills.parser import ( + SKILL_FILENAME, + load_skill_instructions, + load_skill_metadata, + load_skill_resources, +) + + +if TYPE_CHECKING: + from crewai.agents.agent_builder.base_agent import BaseAgent + +_logger = logging.getLogger(__name__) + + +def discover_skills( + search_path: Path, + source: BaseAgent | None = None, +) -> list[Skill]: + """Scan a directory for skill directories containing SKILL.md. + + Loads each discovered skill at METADATA disclosure level. + + Args: + search_path: Directory to scan for skill subdirectories. + source: Optional event source (agent or crew) for event emission. + + Returns: + List of Skill instances at METADATA level. + """ + if not search_path.is_dir(): + msg = f"Skill search path does not exist or is not a directory: {search_path}" + raise FileNotFoundError(msg) + + skills: list[Skill] = [] + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillDiscoveryStartedEvent( + from_agent=source, + search_path=search_path, + ), + ) + + for child in sorted(search_path.iterdir()): + if not child.is_dir(): + continue + skill_md = child / SKILL_FILENAME + if not skill_md.is_file(): + continue + try: + skill = load_skill_metadata(child) + skills.append(skill) + if source is not None: + crewai_event_bus.emit( + source, + event=SkillLoadedEvent( + from_agent=source, + skill_name=skill.name, + skill_path=skill.path, + disclosure_level=skill.disclosure_level, + ), + ) + except Exception as e: + _logger.warning("Failed to load skill from %s: %s", child, e) + if source is not None: + crewai_event_bus.emit( + source, + event=SkillLoadFailedEvent( + from_agent=source, + skill_name=child.name, + skill_path=child, + error=str(e), + ), + ) + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillDiscoveryCompletedEvent( + from_agent=source, + search_path=search_path, + skills_found=len(skills), + skill_names=[s.name for s in skills], + ), + ) + + return skills + + +def activate_skill( + skill: Skill, + source: BaseAgent | None = None, +) -> Skill: + """Promote a skill to INSTRUCTIONS disclosure level. + + Idempotent: returns the skill unchanged if already at or above INSTRUCTIONS. + + Args: + skill: Skill to activate. + source: Optional event source for event emission. + + Returns: + Skill at INSTRUCTIONS level or higher. + """ + if skill.disclosure_level >= INSTRUCTIONS: + return skill + + activated = load_skill_instructions(skill) + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillActivatedEvent( + from_agent=source, + skill_name=activated.name, + skill_path=activated.path, + disclosure_level=activated.disclosure_level, + ), + ) + + return activated + + +def load_resources(skill: Skill) -> Skill: + """Promote a skill to RESOURCES disclosure level. + + Args: + skill: Skill to promote. + + Returns: + Skill at RESOURCES level. + """ + return load_skill_resources(skill) + + +def format_skill_context(skill: Skill) -> str: + """Format skill information for agent prompt injection. + + At METADATA level: returns name and description only. + At INSTRUCTIONS level or above: returns full SKILL.md body. + + Args: + skill: The skill to format. + + Returns: + Formatted skill context string. + """ + if skill.disclosure_level >= INSTRUCTIONS and skill.instructions: + parts = [ + f"## Skill: {skill.name}", + skill.description, + "", + skill.instructions, + ] + if skill.disclosure_level >= RESOURCES and skill.resource_files: + parts.append("") + parts.append("### Available Resources") + for dir_name, files in sorted(skill.resource_files.items()): + if files: + parts.append(f"- **{dir_name}/**: {', '.join(files)}") + return "\n".join(parts) + return f"## Skill: {skill.name}\n{skill.description}" diff --git a/lib/crewai/src/crewai/skills/models.py b/lib/crewai/src/crewai/skills/models.py new file mode 100644 index 000000000..cde2b4f3b --- /dev/null +++ b/lib/crewai/src/crewai/skills/models.py @@ -0,0 +1,175 @@ +"""Pydantic data models for the Agent Skills standard. + +Defines DisclosureLevel, SkillFrontmatter, and Skill models for +progressive disclosure of skill information. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from crewai.skills.validation import ( + MAX_SKILL_NAME_LENGTH, + MIN_SKILL_NAME_LENGTH, + SKILL_NAME_PATTERN, +) + + +MAX_DESCRIPTION_LENGTH: Final[int] = 1024 +ResourceDirName = Literal["scripts", "references", "assets"] + + +DisclosureLevel = Annotated[ + Literal[1, 2, 3], "Progressive disclosure levels for skill loading." +] + +METADATA: Final[ + Annotated[ + DisclosureLevel, "Only frontmatter metadata is loaded (name, description)." + ] +] = 1 +INSTRUCTIONS: Final[Annotated[DisclosureLevel, "Full SKILL.md body is loaded."]] = 2 +RESOURCES: Final[ + Annotated[ + DisclosureLevel, + "Resource directories (scripts, references, assets) are cataloged.", + ] +] = 3 + + +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 (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 space-delimited list of pre-approved tools. + """ + + model_config = ConfigDict(frozen=True, populate_by_name=True) + + 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 = Field( + default=None, + description="SPDX license identifier or free-text license reference, e.g. 'MIT', 'Apache-2.0'.", + ) + compatibility: str | None = Field( + default=None, + max_length=500, + description="Version or platform constraints for the skill, e.g. 'crewai >= 0.80'.", + ) + metadata: dict[str, str] | None = Field( + default=None, + description="Arbitrary string key-value pairs for custom skill metadata.", + ) + allowed_tools: list[str] | None = Field( + default=None, + alias="allowed-tools", + description="Pre-approved tool names the skill may use, parsed from a space-delimited string in frontmatter.", + ) + + @model_validator(mode="before") + @classmethod + 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): + """A loaded Agent Skill with progressive disclosure support. + + Attributes: + frontmatter: Parsed YAML frontmatter. + instructions: Full SKILL.md body text (populated at INSTRUCTIONS level). + path: Filesystem path to the skill directory. + disclosure_level: Current disclosure level of the skill. + resource_files: Cataloged resource files (populated at RESOURCES level). + """ + + frontmatter: SkillFrontmatter = Field( + description="Parsed YAML frontmatter from SKILL.md.", + ) + instructions: str | None = Field( + default=None, + description="Full SKILL.md body text, populated at INSTRUCTIONS level.", + ) + path: Path = Field( + description="Filesystem path to the skill directory.", + ) + disclosure_level: DisclosureLevel = Field( + default=METADATA, + description="Current progressive disclosure level of the skill.", + ) + resource_files: dict[ResourceDirName, list[str]] | None = Field( + default=None, + description="Cataloged resource files by directory, populated at RESOURCES level.", + ) + + @property + def name(self) -> str: + """Skill name from frontmatter.""" + return self.frontmatter.name + + @property + def description(self) -> str: + """Skill description from frontmatter.""" + return self.frontmatter.description + + @property + def scripts_dir(self) -> Path: + """Path to the scripts directory.""" + return self.path / "scripts" + + @property + def references_dir(self) -> Path: + """Path to the references directory.""" + return self.path / "references" + + @property + def assets_dir(self) -> Path: + """Path to the assets directory.""" + return self.path / "assets" + + def with_disclosure_level( + self, + level: DisclosureLevel, + instructions: str | None = None, + resource_files: dict[ResourceDirName, list[str]] | None = None, + ) -> Skill: + """Create a new Skill at a different disclosure level. + + Args: + level: The new disclosure level. + instructions: Optional instructions body text. + resource_files: Optional cataloged resource files. + + Returns: + A new Skill instance at the specified disclosure level. + """ + return Skill( + frontmatter=self.frontmatter, + instructions=instructions + if instructions is not None + else self.instructions, + path=self.path, + disclosure_level=level, + resource_files=( + resource_files if resource_files is not None else self.resource_files + ), + ) diff --git a/lib/crewai/src/crewai/skills/parser.py b/lib/crewai/src/crewai/skills/parser.py new file mode 100644 index 000000000..d935e6ad1 --- /dev/null +++ b/lib/crewai/src/crewai/skills/parser.py @@ -0,0 +1,194 @@ +"""SKILL.md file parsing for the Agent Skills standard. + +Parses YAML frontmatter and markdown body from SKILL.md files, +and provides progressive loading functions for skill data. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import re +from typing import Any, Final + +import yaml + +from crewai.skills.models import ( + INSTRUCTIONS, + METADATA, + RESOURCES, + ResourceDirName, + Skill, + SkillFrontmatter, +) +from crewai.skills.validation import validate_directory_name + + +_logger = logging.getLogger(__name__) + + +SKILL_FILENAME: Final[str] = "SKILL.md" +_CLOSING_DELIMITER: Final[re.Pattern[str]] = re.compile(r"\n---[ \t]*(?:\n|$)") +_MAX_BODY_CHARS: Final[int] = 50_000 + + +class SkillParseError(ValueError): + """Error raised when SKILL.md parsing fails.""" + + +def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: + """Split SKILL.md content into frontmatter dict and body text. + + Args: + content: Raw SKILL.md file content. + + Returns: + Tuple of (frontmatter dict, body text). + + Raises: + SkillParseError: If frontmatter delimiters are missing or YAML is invalid. + """ + if not content.startswith("---"): + msg = "SKILL.md must start with '---' frontmatter delimiter" + raise SkillParseError(msg) + + match = _CLOSING_DELIMITER.search(content, pos=3) + if match is None: + msg = "SKILL.md missing closing '---' frontmatter delimiter" + raise SkillParseError(msg) + + yaml_content = content[3 : match.start()].strip() + body = content[match.end() :].strip() + + try: + frontmatter = yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + msg = f"Invalid YAML in frontmatter: {e}" + raise SkillParseError(msg) from e + + if not isinstance(frontmatter, dict): + msg = "Frontmatter must be a YAML mapping" + raise SkillParseError(msg) + + return frontmatter, body + + +def parse_skill_md(path: Path) -> tuple[SkillFrontmatter, str]: + """Read and parse a SKILL.md file. + + Args: + path: Path to the SKILL.md file. + + Returns: + Tuple of (SkillFrontmatter, body text). + + Raises: + FileNotFoundError: If the file does not exist. + SkillParseError: If parsing fails. + """ + content = path.read_text(encoding="utf-8") + frontmatter_dict, body = parse_frontmatter(content) + frontmatter = SkillFrontmatter(**frontmatter_dict) + return frontmatter, body + + +def load_skill_metadata(skill_dir: Path) -> Skill: + """Load a skill at METADATA disclosure level. + + Parses SKILL.md frontmatter only and validates directory name. + + Args: + skill_dir: Path to the skill directory. + + Returns: + Skill instance at METADATA level. + + Raises: + FileNotFoundError: If SKILL.md is missing. + SkillParseError: If parsing fails. + ValueError: If directory name doesn't match skill name. + """ + skill_md_path = skill_dir / SKILL_FILENAME + frontmatter, body = parse_skill_md(skill_md_path) + validate_directory_name(skill_dir, frontmatter.name) + if len(body) > _MAX_BODY_CHARS: + _logger.warning( + "SKILL.md body for '%s' is %d chars (threshold: %d). " + "Large bodies may consume significant context window when injected into prompts.", + frontmatter.name, + len(body), + _MAX_BODY_CHARS, + ) + return Skill( + frontmatter=frontmatter, + path=skill_dir, + disclosure_level=METADATA, + ) + + +def load_skill_instructions(skill: Skill) -> Skill: + """Promote a skill to INSTRUCTIONS disclosure level. + + Reads the full SKILL.md body text. + + Args: + skill: Skill at METADATA level. + + Returns: + New Skill instance at INSTRUCTIONS level. + """ + if skill.disclosure_level >= INSTRUCTIONS: + return skill + + skill_md_path = skill.path / SKILL_FILENAME + _, body = parse_skill_md(skill_md_path) + if len(body) > _MAX_BODY_CHARS: + _logger.warning( + "SKILL.md body for '%s' is %d chars (threshold: %d). " + "Large bodies may consume significant context window when injected into prompts.", + skill.name, + len(body), + _MAX_BODY_CHARS, + ) + return skill.with_disclosure_level( + level=INSTRUCTIONS, + instructions=body, + ) + + +def load_skill_resources(skill: Skill) -> Skill: + """Promote a skill to RESOURCES disclosure level. + + Catalogs available resource directories (scripts, references, assets). + + Args: + skill: Skill at any level. + + Returns: + New Skill instance at RESOURCES level. + """ + if skill.disclosure_level >= RESOURCES: + return skill + + if skill.disclosure_level < INSTRUCTIONS: + skill = load_skill_instructions(skill) + + resource_dirs: list[tuple[ResourceDirName, Path]] = [ + ("scripts", skill.scripts_dir), + ("references", skill.references_dir), + ("assets", skill.assets_dir), + ] + resource_files: dict[ResourceDirName, list[str]] = {} + for dir_name, resource_dir in resource_dirs: + if resource_dir.is_dir(): + resource_files[dir_name] = sorted( + str(f.relative_to(resource_dir)) + for f in resource_dir.rglob("*") + if f.is_file() + ) + + return skill.with_disclosure_level( + level=RESOURCES, + instructions=skill.instructions, + resource_files=resource_files, + ) diff --git a/lib/crewai/src/crewai/skills/validation.py b/lib/crewai/src/crewai/skills/validation.py new file mode 100644 index 000000000..78acd7b76 --- /dev/null +++ b/lib/crewai/src/crewai/skills/validation.py @@ -0,0 +1,31 @@ +"""Validation functions for Agent Skills specification constraints. + +Validates skill names and directory structures per the Agent Skills standard. +""" + +from __future__ import annotations + +from pathlib import Path +import re +from typing import Final + + +MAX_SKILL_NAME_LENGTH: Final[int] = 64 +MIN_SKILL_NAME_LENGTH: Final[int] = 1 +SKILL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def validate_directory_name(skill_dir: Path, skill_name: str) -> None: + """Validate that a directory name matches the skill name. + + Args: + skill_dir: Path to the skill directory. + skill_name: The declared skill name from frontmatter. + + Raises: + ValueError: If the directory name does not match the skill name. + """ + dir_name = skill_dir.name + if dir_name != skill_name: + msg = f"Directory name '{dir_name}' does not match skill name '{skill_name}'" + raise ValueError(msg) diff --git a/lib/crewai/tests/skills/__init__.py b/lib/crewai/tests/skills/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md b/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md new file mode 100644 index 000000000..ce075ee0a --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/invalid-name/SKILL.md @@ -0,0 +1,4 @@ +--- +name: Invalid--Name +description: This skill has an invalid name. +--- diff --git a/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md b/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md new file mode 100644 index 000000000..2efe9b9ea --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/minimal-skill/SKILL.md @@ -0,0 +1,4 @@ +--- +name: minimal-skill +description: A minimal skill with only required fields. +--- diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md new file mode 100644 index 000000000..f69e7b463 --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/SKILL.md @@ -0,0 +1,22 @@ +--- +name: valid-skill +description: A complete test skill with all optional directories. +license: Apache-2.0 +compatibility: crewai>=0.1.0 +metadata: + author: test + version: "1.0" +allowed-tools: web-search file-read +--- + +## Instructions + +This skill provides comprehensive instructions for the agent. + +### Usage + +Follow these steps to use the skill effectively. + +### Notes + +Additional context for the agent. diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json b/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json new file mode 100644 index 000000000..76519fa8c --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/assets/config.json @@ -0,0 +1 @@ +{"key": "value"} diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md b/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md new file mode 100644 index 000000000..8ef68aeb6 --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/references/guide.md @@ -0,0 +1,3 @@ +# Reference Guide + +This is a reference document for the skill. diff --git a/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh b/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh new file mode 100644 index 000000000..1178a039d --- /dev/null +++ b/lib/crewai/tests/skills/fixtures/valid-skill/scripts/setup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "setup" diff --git a/lib/crewai/tests/skills/test_integration.py b/lib/crewai/tests/skills/test_integration.py new file mode 100644 index 000000000..23004d79e --- /dev/null +++ b/lib/crewai/tests/skills/test_integration.py @@ -0,0 +1,78 @@ +"""Integration tests for the skills system.""" + +from pathlib import Path + +import pytest + +from crewai.skills.loader import activate_skill, discover_skills, format_skill_context +from crewai.skills.models import INSTRUCTIONS, METADATA + + +def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path: + """Helper to create a skill directory with SKILL.md.""" + skill_dir = parent / name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}" + ) + return skill_dir + + +class TestSkillDiscoveryAndActivation: + """End-to-end tests for discover + activate workflow.""" + + def test_discover_and_activate(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill", body="Use this skill.") + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].disclosure_level == METADATA + + activated = activate_skill(skills[0]) + assert activated.disclosure_level == INSTRUCTIONS + assert activated.instructions == "Use this skill." + + context = format_skill_context(activated) + assert "## Skill: my-skill" in context + assert "Use this skill." in context + + def test_filter_by_skill_names(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "alpha") + _create_skill_dir(tmp_path, "beta") + _create_skill_dir(tmp_path, "gamma") + + all_skills = discover_skills(tmp_path) + wanted = {"alpha", "gamma"} + filtered = [s for s in all_skills if s.name in wanted] + assert {s.name for s in filtered} == {"alpha", "gamma"} + + def test_full_fixture_skill(self) -> None: + fixtures = Path(__file__).parent / "fixtures" + valid_dir = fixtures / "valid-skill" + if not valid_dir.exists(): + pytest.skip("Fixture not found") + + skills = discover_skills(fixtures) + valid_skills = [s for s in skills if s.name == "valid-skill"] + assert len(valid_skills) == 1 + + skill = valid_skills[0] + assert skill.frontmatter.license == "Apache-2.0" + assert skill.frontmatter.allowed_tools == ["web-search", "file-read"] + + activated = activate_skill(skill) + assert "Instructions" in (activated.instructions or "") + + def test_multiple_search_paths(self, tmp_path: Path) -> None: + path_a = tmp_path / "a" + path_a.mkdir() + _create_skill_dir(path_a, "skill-a") + + path_b = tmp_path / "b" + path_b.mkdir() + _create_skill_dir(path_b, "skill-b") + + all_skills = [] + for search_path in [path_a, path_b]: + all_skills.extend(discover_skills(search_path)) + names = {s.name for s in all_skills} + assert names == {"skill-a", "skill-b"} diff --git a/lib/crewai/tests/skills/test_loader.py b/lib/crewai/tests/skills/test_loader.py new file mode 100644 index 000000000..8303e19df --- /dev/null +++ b/lib/crewai/tests/skills/test_loader.py @@ -0,0 +1,161 @@ +"""Tests for skills/loader.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.loader import ( + activate_skill, + discover_skills, + format_skill_context, + load_resources, +) +from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES, Skill, SkillFrontmatter +from crewai.skills.parser import load_skill_metadata + + +def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path: + """Helper to create a skill directory with SKILL.md.""" + skill_dir = parent / name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}" + ) + return skill_dir + + +class TestDiscoverSkills: + """Tests for discover_skills.""" + + def test_finds_valid_skills(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "alpha") + _create_skill_dir(tmp_path, "beta") + skills = discover_skills(tmp_path) + names = {s.name for s in skills} + assert names == {"alpha", "beta"} + + def test_skips_dirs_without_skill_md(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "valid") + (tmp_path / "no-skill").mkdir() + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].name == "valid" + + def test_skips_invalid_skills(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "good-skill") + bad_dir = tmp_path / "bad-skill" + bad_dir.mkdir() + (bad_dir / "SKILL.md").write_text( + "---\nname: Wrong-Name\ndescription: bad\n---\n" + ) + skills = discover_skills(tmp_path) + assert len(skills) == 1 + + def test_empty_directory(self, tmp_path: Path) -> None: + skills = discover_skills(tmp_path) + assert skills == [] + + def test_nonexistent_path(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + discover_skills(tmp_path / "nonexistent") + + def test_sorted_by_name(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "zebra") + _create_skill_dir(tmp_path, "alpha") + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["alpha", "zebra"] + + +class TestActivateSkill: + """Tests for activate_skill.""" + + def test_promotes_to_instructions(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill", body="Instructions.") + skill = load_skill_metadata(tmp_path / "my-skill") + activated = activate_skill(skill) + assert activated.disclosure_level == INSTRUCTIONS + assert activated.instructions == "Instructions." + + def test_idempotent(self, tmp_path: Path) -> None: + _create_skill_dir(tmp_path, "my-skill") + skill = load_skill_metadata(tmp_path / "my-skill") + activated = activate_skill(skill) + again = activate_skill(activated) + assert again is activated + + +class TestLoadResources: + """Tests for load_resources.""" + + def test_promotes_to_resources(self, tmp_path: Path) -> None: + skill_dir = _create_skill_dir(tmp_path, "my-skill") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash") + skill = load_skill_metadata(skill_dir) + full = load_resources(skill) + assert full.disclosure_level == RESOURCES + + +class TestFormatSkillContext: + """Tests for format_skill_context.""" + + def test_metadata_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, path=tmp_path, disclosure_level=METADATA + ) + ctx = format_skill_context(skill) + assert "## Skill: test-skill" in ctx + assert "A skill" in ctx + + def test_instructions_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=INSTRUCTIONS, + instructions="Do these things.", + ) + ctx = format_skill_context(skill) + assert "## Skill: test-skill" in ctx + assert "Do these things." in ctx + + def test_no_instructions_at_instructions_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=INSTRUCTIONS, + instructions=None, + ) + ctx = format_skill_context(skill) + assert ctx == "## Skill: test-skill\nA skill" + + def test_resources_level(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=RESOURCES, + instructions="Do things.", + resource_files={ + "scripts": ["run.sh"], + "assets": ["data.json", "config.yaml"], + }, + ) + ctx = format_skill_context(skill) + assert "### Available Resources" in ctx + assert "**assets/**: data.json, config.yaml" in ctx + assert "**scripts/**: run.sh" in ctx + + def test_resources_level_empty_files(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="A skill") + skill = Skill( + frontmatter=fm, + path=tmp_path, + disclosure_level=RESOURCES, + instructions="Do things.", + resource_files={}, + ) + ctx = format_skill_context(skill) + assert "### Available Resources" not in ctx diff --git a/lib/crewai/tests/skills/test_models.py b/lib/crewai/tests/skills/test_models.py new file mode 100644 index 000000000..57f15d763 --- /dev/null +++ b/lib/crewai/tests/skills/test_models.py @@ -0,0 +1,91 @@ +"""Tests for skills/models.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.models import ( + INSTRUCTIONS, + METADATA, + RESOURCES, + Skill, + SkillFrontmatter, +) + + +class TestDisclosureLevel: + """Tests for DisclosureLevel constants.""" + + def test_ordering(self) -> None: + assert METADATA < INSTRUCTIONS + assert INSTRUCTIONS < RESOURCES + + def test_values(self) -> None: + assert METADATA == 1 + assert INSTRUCTIONS == 2 + assert RESOURCES == 3 + + +class TestSkillFrontmatter: + """Tests for SkillFrontmatter model.""" + + def test_required_fields(self) -> None: + fm = SkillFrontmatter(name="my-skill", description="A test skill") + assert fm.name == "my-skill" + assert fm.description == "A test skill" + assert fm.license is None + assert fm.metadata is None + assert fm.allowed_tools is None + + def test_all_fields(self) -> None: + fm = SkillFrontmatter( + name="web-search", + description="Search the web", + license="Apache-2.0", + compatibility="crewai>=0.1.0", + metadata={"author": "test"}, + allowed_tools=["browser"], + ) + assert fm.license == "Apache-2.0" + assert fm.metadata == {"author": "test"} + assert fm.allowed_tools == ["browser"] + + def test_frozen(self) -> None: + fm = SkillFrontmatter(name="my-skill", description="desc") + with pytest.raises(Exception): + fm.name = "other" # type: ignore[misc] + + def test_invalid_name_rejected(self) -> None: + with pytest.raises(ValueError): + SkillFrontmatter(name="Invalid--Name", description="bad") + + +class TestSkill: + """Tests for Skill model.""" + + def test_properties(self, tmp_path: Path) -> None: + fm = SkillFrontmatter(name="test-skill", description="desc") + skill = Skill(frontmatter=fm, path=tmp_path / "test-skill") + assert skill.name == "test-skill" + assert skill.description == "desc" + assert skill.disclosure_level == METADATA + + def test_resource_dirs(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 skill.scripts_dir == skill_dir / "scripts" + assert skill.references_dir == skill_dir / "references" + assert skill.assets_dir == skill_dir / "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) + promoted = skill.with_disclosure_level( + INSTRUCTIONS, + instructions="Do this.", + ) + assert promoted.disclosure_level == INSTRUCTIONS + assert promoted.instructions == "Do this." + assert skill.disclosure_level == METADATA diff --git a/lib/crewai/tests/skills/test_parser.py b/lib/crewai/tests/skills/test_parser.py new file mode 100644 index 000000000..dab15d175 --- /dev/null +++ b/lib/crewai/tests/skills/test_parser.py @@ -0,0 +1,167 @@ +"""Tests for skills/parser.py.""" + +from pathlib import Path + +import pytest + +from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES +from crewai.skills.parser import ( + SkillParseError, + load_skill_instructions, + load_skill_metadata, + load_skill_resources, + parse_frontmatter, + parse_skill_md, +) + + +class TestParseFrontmatter: + """Tests for parse_frontmatter.""" + + def test_valid_frontmatter_and_body(self) -> None: + content = "---\nname: test\ndescription: A test\n---\n\nBody text here." + fm, body = parse_frontmatter(content) + assert fm["name"] == "test" + assert fm["description"] == "A test" + assert body == "Body text here." + + def test_empty_body(self) -> None: + content = "---\nname: test\ndescription: A test\n---" + fm, body = parse_frontmatter(content) + assert fm["name"] == "test" + assert body == "" + + def test_missing_opening_delimiter(self) -> None: + with pytest.raises(SkillParseError, match="must start with"): + parse_frontmatter("name: test\n---\nBody") + + def test_missing_closing_delimiter(self) -> None: + with pytest.raises(SkillParseError, match="missing closing"): + parse_frontmatter("---\nname: test\n") + + def test_invalid_yaml(self) -> None: + with pytest.raises(SkillParseError, match="Invalid YAML"): + parse_frontmatter("---\n: :\n bad: [yaml\n---\nBody") + + def test_triple_dash_in_body(self) -> None: + content = "---\nname: test\ndescription: desc\n---\n\nBody with --- inside." + fm, body = parse_frontmatter(content) + assert "---" in body + + def test_inline_triple_dash_in_yaml_value(self) -> None: + content = '---\nname: test\ndescription: "Use---carefully"\n---\n\nBody.' + fm, body = parse_frontmatter(content) + assert fm["description"] == "Use---carefully" + assert body == "Body." + + def test_unicode_content(self) -> None: + content = "---\nname: test\ndescription: Beschreibung\n---\n\nUnicode: \u00e4\u00f6\u00fc\u00df" + fm, body = parse_frontmatter(content) + assert fm["description"] == "Beschreibung" + assert "\u00e4\u00f6\u00fc\u00df" in body + + def test_non_mapping_frontmatter(self) -> None: + with pytest.raises(SkillParseError, match="must be a YAML mapping"): + parse_frontmatter("---\n- item1\n- item2\n---\nBody") + + +class TestParseSkillMd: + """Tests for parse_skill_md.""" + + def test_valid_file(self, tmp_path: Path) -> None: + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: my-skill\ndescription: desc\n---\nInstructions here." + ) + fm, body = parse_skill_md(skill_md) + assert fm.name == "my-skill" + assert body == "Instructions here." + + def test_file_not_found(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + parse_skill_md(tmp_path / "nonexistent" / "SKILL.md") + + +class TestLoadSkillMetadata: + """Tests for load_skill_metadata.""" + + def test_valid_skill(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\n---\nBody" + ) + skill = load_skill_metadata(skill_dir) + assert skill.name == "my-skill" + assert skill.disclosure_level == METADATA + assert skill.instructions is None + + def test_directory_name_mismatch(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "wrong-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\n---\n" + ) + with pytest.raises(ValueError, match="does not match"): + load_skill_metadata(skill_dir) + + +class TestLoadSkillInstructions: + """Tests for load_skill_instructions.""" + + def test_promotes_to_instructions(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nFull body." + ) + skill = load_skill_metadata(skill_dir) + promoted = load_skill_instructions(skill) + assert promoted.disclosure_level == INSTRUCTIONS + assert promoted.instructions == "Full body." + + def test_idempotent(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + skill = load_skill_metadata(skill_dir) + promoted = load_skill_instructions(skill) + again = load_skill_instructions(promoted) + assert again is promoted + + +class TestLoadSkillResources: + """Tests for load_skill_resources.""" + + def test_catalogs_resources(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash") + (skill_dir / "assets").mkdir() + (skill_dir / "assets" / "data.json").write_text("{}") + + skill = load_skill_metadata(skill_dir) + full = load_skill_resources(skill) + assert full.disclosure_level == RESOURCES + assert full.instructions == "Body." + assert full.resource_files is not None + assert "scripts" in full.resource_files + assert "run.sh" in full.resource_files["scripts"] + assert "assets" in full.resource_files + assert "data.json" in full.resource_files["assets"] + + def test_no_resource_dirs(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test\n---\nBody." + ) + skill = load_skill_metadata(skill_dir) + full = load_skill_resources(skill) + assert full.resource_files == {} diff --git a/lib/crewai/tests/skills/test_validation.py b/lib/crewai/tests/skills/test_validation.py new file mode 100644 index 000000000..982a9d534 --- /dev/null +++ b/lib/crewai/tests/skills/test_validation.py @@ -0,0 +1,93 @@ +"""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, +) + + +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 _make("web-search").name == "web-search" + + def test_single_word(self) -> None: + assert _make("search").name == "search" + + def test_numeric(self) -> None: + assert _make("tool3").name == "tool3" + + def test_all_digits(self) -> None: + assert _make("123").name == "123" + + def test_single_char(self) -> None: + assert _make("a").name == "a" + + def test_max_length(self) -> None: + name = "a" * MAX_SKILL_NAME_LENGTH + assert _make(name).name == name + + def test_multi_hyphen_segments(self) -> None: + assert _make("my-cool-skill").name == "my-cool-skill" + + def test_empty_raises(self) -> None: + with pytest.raises(ValueError): + _make("") + + def test_too_long_raises(self) -> None: + with pytest.raises(ValueError): + _make("a" * (MAX_SKILL_NAME_LENGTH + 1)) + + def test_uppercase_raises(self) -> None: + with pytest.raises(ValueError): + _make("MySkill") + + def test_leading_hyphen_raises(self) -> None: + with pytest.raises(ValueError): + _make("-skill") + + def test_trailing_hyphen_raises(self) -> None: + with pytest.raises(ValueError): + _make("skill-") + + def test_consecutive_hyphens_raises(self) -> None: + with pytest.raises(ValueError): + _make("my--skill") + + def test_underscore_raises(self) -> None: + with pytest.raises(ValueError): + _make("my_skill") + + def test_space_raises(self) -> None: + with pytest.raises(ValueError): + _make("my skill") + + def test_special_chars_raises(self) -> None: + with pytest.raises(ValueError): + _make("skill@v1") + + +class TestValidateDirectoryName: + """Tests for validate_directory_name.""" + + def test_matching_names(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + validate_directory_name(skill_dir, "my-skill") + + def test_mismatched_names(self, tmp_path: Path) -> None: + skill_dir = tmp_path / "other-name" + skill_dir.mkdir() + with pytest.raises(ValueError, match="does not match"): + validate_directory_name(skill_dir, "my-skill") diff --git a/uv.lock b/uv.lock index b9d63dd3d..3ff84ee57 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,6 +1115,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "regex" }, { name = "textual" }, { name = "tokenizers" }, @@ -1222,6 +1223,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = "~=2.10.1" }, { name = "pyjwt", specifier = ">=2.9.0,<3" }, { name = "python-dotenv", specifier = "~=1.1.1" }, + { name = "pyyaml", specifier = "~=6.0" }, { name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = "~=1.14.3" }, { name = "regex", specifier = "~=2026.1.15" }, { name = "textual", specifier = ">=7.5.0" },