feat: integrate skill discovery and activation into agent execution

This commit is contained in:
Greyson Lalonde
2026-03-05 19:33:02 -05:00
parent 4200d94161
commit 4758da71e9
6 changed files with 247 additions and 0 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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.",

View File

@@ -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]

View File

@@ -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}"