From 4758da71e97f88ef48d64458cd8e669013b5c1d4 Mon Sep 17 00:00:00 2001 From: Greyson Lalonde Date: Thu, 5 Mar 2026 19:33:02 -0500 Subject: [PATCH] feat: integrate skill discovery and activation into agent execution --- lib/crewai/src/crewai/agent/core.py | 38 ++++ lib/crewai/src/crewai/agent/utils.py | 24 +++ .../crewai/agents/agent_builder/base_agent.py | 9 + lib/crewai/src/crewai/crew.py | 5 + lib/crewai/src/crewai/crews/utils.py | 1 + lib/crewai/src/crewai/skills/loader.py | 170 ++++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 lib/crewai/src/crewai/skills/loader.py diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 47eb841b4..ff97e093d 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Sequence +from pathlib import Path import shutil import subprocess import time @@ -26,6 +27,7 @@ from typing_extensions import Self from crewai.agent.utils import ( ahandle_knowledge_retrieval, + append_skill_context, apply_training_data, build_task_prompt_with_schema, format_task_with_context, @@ -74,6 +76,8 @@ from crewai.mcp.transports.stdio import StdioTransport from crewai.memory.contextual.contextual_memory import ContextualMemory 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 Skill as SkillModel from crewai.tools.agent_tools.agent_tools import AgentTools from crewai.utilities.agent_utils import ( get_tool_names, @@ -310,6 +314,36 @@ class Agent(BaseAgent): except (TypeError, ValueError) as e: raise ValueError(f"Invalid Knowledge Configuration: {e!s}") from e + def set_skills(self) -> None: + """Resolve skill paths into loaded Skill objects. + + Path entries trigger discovery and activation. Skill entries pass through. + Crew-level skill paths are merged in. + """ + crew_skills: list[Path] | None = ( + self.crew.skills + if self.crew and isinstance(self.crew.skills, list) + else None + ) + + if not self.skills and not crew_skills: + return + + 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) + resolved.extend(activate_skill(s, source=self) for s in discovered) + elif isinstance(item, SkillModel): + resolved.append(item) + + self.skills = resolved if resolved else None + def _is_any_available_memory(self) -> bool: """Check if any memory is available.""" if not self.crew: @@ -432,6 +466,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) @@ -666,6 +702,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) diff --git a/lib/crewai/src/crewai/agent/utils.py b/lib/crewai/src/crewai/agent/utils.py index fb9d2b75a..e36903f23 100644 --- a/lib/crewai/src/crewai/agent/utils.py +++ b/lib/crewai/src/crewai/agent/utils.py @@ -203,6 +203,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 c58837cba..c45dad6b1 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from copy import copy as shallow_copy from hashlib import md5 +from pathlib import Path from typing import Any, Literal import uuid @@ -28,6 +29,7 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource from crewai.mcp.config import MCPServerConfig 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.utilities.config import process_config from crewai.utilities.i18n import I18N, get_i18n @@ -199,6 +201,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): default=None, description="List of MCP server references. Supports 'https://server.com/path' for external servers and 'crewai-amp:mcp-name' for AMP marketplace. Use '#tool_name' suffix for specific tools.", ) + skills: list[Path | Skill] | None = Field( + default=None, + description="Agent Skills. Accepts Paths for discovery or pre-loaded Skill objects.", + ) @model_validator(mode="before") @classmethod @@ -469,3 +475,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None: pass + + def set_skills(self) -> None: + pass diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index 94868b830..2788170eb 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, @@ -311,6 +312,10 @@ class Crew(FlowTrackable, BaseModel): default=None, description="Knowledge for the crew.", ) + skills: list[Path] | None = Field( + default=None, + description="Skill search paths applied to all agents in the crew.", + ) security_config: SecurityConfig = Field( default_factory=SecurityConfig, description="Security configuration for the crew, including fingerprinting.", diff --git a/lib/crewai/src/crewai/crews/utils.py b/lib/crewai/src/crewai/crews/utils.py index a432d2fc2..3e683adeb 100644 --- a/lib/crewai/src/crewai/crews/utils.py +++ b/lib/crewai/src/crewai/crews/utils.py @@ -70,6 +70,7 @@ def setup_agents( for agent in agents: agent.crew = crew agent.set_knowledge(crew_embedder=embedder) + agent.set_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/skills/loader.py b/lib/crewai/src/crewai/skills/loader.py new file mode 100644 index 000000000..02887d95f --- /dev/null +++ b/lib/crewai/src/crewai/skills/loader.py @@ -0,0 +1,170 @@ +"""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 + +from pathlib import Path +from typing import Any + +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 DisclosureLevel, Skill +from crewai.skills.parser import ( + SKILL_FILENAME, + load_skill_instructions, + load_skill_metadata, + load_skill_resources, +) + + +def discover_skills( + search_path: Path, + source: Any | 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. + """ + skills: list[Skill] = [] + + if not search_path.is_dir(): + return skills + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillDiscoveryStartedEvent( + search_path=str(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( + skill_name=skill.name, + skill_path=str(skill.path), + disclosure_level=skill.disclosure_level.value, + ), + ) + except Exception as e: + if source is not None: + crewai_event_bus.emit( + source, + event=SkillLoadFailedEvent( + skill_name=child.name, + skill_path=str(child), + error=str(e), + ), + ) + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillDiscoveryCompletedEvent( + search_path=str(search_path), + skills_found=len(skills), + skill_names=[s.name for s in skills], + ), + ) + + return skills + + +def activate_skill( + skill: Skill, + source: Any | 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 >= DisclosureLevel.INSTRUCTIONS: + return skill + + activated = load_skill_instructions(skill) + + if source is not None: + crewai_event_bus.emit( + source, + event=SkillActivatedEvent( + skill_name=activated.name, + skill_path=str(activated.path), + disclosure_level=activated.disclosure_level.value, + ), + ) + + 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 >= DisclosureLevel.INSTRUCTIONS and skill.instructions: + parts = [ + f"## Skill: {skill.name}", + skill.description, + "", + skill.instructions, + ] + if skill.disclosure_level >= DisclosureLevel.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}"