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 import asyncio
from collections.abc import Callable, Coroutine, Sequence from collections.abc import Callable, Coroutine, Sequence
from pathlib import Path
import shutil import shutil
import subprocess import subprocess
import time import time
@@ -26,6 +27,7 @@ from typing_extensions import Self
from crewai.agent.utils import ( from crewai.agent.utils import (
ahandle_knowledge_retrieval, ahandle_knowledge_retrieval,
append_skill_context,
apply_training_data, apply_training_data,
build_task_prompt_with_schema, build_task_prompt_with_schema,
format_task_with_context, 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.memory.contextual.contextual_memory import ContextualMemory
from crewai.rag.embeddings.types import EmbedderConfig from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint 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.tools.agent_tools.agent_tools import AgentTools
from crewai.utilities.agent_utils import ( from crewai.utilities.agent_utils import (
get_tool_names, get_tool_names,
@@ -310,6 +314,36 @@ class Agent(BaseAgent):
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValueError(f"Invalid Knowledge Configuration: {e!s}") from 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: def _is_any_available_memory(self) -> bool:
"""Check if any memory is available.""" """Check if any memory is available."""
if not self.crew: if not self.crew:
@@ -432,6 +466,8 @@ class Agent(BaseAgent):
self.crew.query_knowledge if self.crew else lambda *a, **k: None, 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) prepare_tools(self, tools, task)
task_prompt = apply_training_data(self, task_prompt) task_prompt = apply_training_data(self, task_prompt)
@@ -666,6 +702,8 @@ class Agent(BaseAgent):
self, task, task_prompt, knowledge_config self, task, task_prompt, knowledge_config
) )
task_prompt = append_skill_context(self, task_prompt)
prepare_tools(self, tools, task) prepare_tools(self, tools, task)
task_prompt = apply_training_data(self, task_prompt) 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 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: def apply_training_data(agent: Agent, task_prompt: str) -> str:
"""Apply training data to the task prompt. """Apply training data to the task prompt.

View File

@@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable
from copy import copy as shallow_copy from copy import copy as shallow_copy
from hashlib import md5 from hashlib import md5
from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import uuid import uuid
@@ -28,6 +29,7 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.mcp.config import MCPServerConfig from crewai.mcp.config import MCPServerConfig
from crewai.rag.embeddings.types import EmbedderConfig from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.security_config import SecurityConfig from crewai.security.security_config import SecurityConfig
from crewai.skills.models import Skill
from crewai.tools.base_tool import BaseTool, Tool from crewai.tools.base_tool import BaseTool, Tool
from crewai.utilities.config import process_config from crewai.utilities.config import process_config
from crewai.utilities.i18n import I18N, get_i18n from crewai.utilities.i18n import I18N, get_i18n
@@ -199,6 +201,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
default=None, 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.", 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") @model_validator(mode="before")
@classmethod @classmethod
@@ -469,3 +475,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None: def set_knowledge(self, crew_embedder: EmbedderConfig | None = None) -> None:
pass 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 copy import copy as shallow_copy
from hashlib import md5 from hashlib import md5
import json import json
from pathlib import Path
import re import re
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -311,6 +312,10 @@ class Crew(FlowTrackable, BaseModel):
default=None, default=None,
description="Knowledge for the crew.", 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( security_config: SecurityConfig = Field(
default_factory=SecurityConfig, default_factory=SecurityConfig,
description="Security configuration for the crew, including fingerprinting.", description="Security configuration for the crew, including fingerprinting.",

View File

@@ -70,6 +70,7 @@ def setup_agents(
for agent in agents: for agent in agents:
agent.crew = crew agent.crew = crew
agent.set_knowledge(crew_embedder=embedder) agent.set_knowledge(crew_embedder=embedder)
agent.set_skills()
if not agent.function_calling_llm: # type: ignore[attr-defined] if not agent.function_calling_llm: # type: ignore[attr-defined]
agent.function_calling_llm = 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] 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}"