diff --git a/docs/edge/en/concepts/skills.mdx b/docs/edge/en/concepts/skills.mdx index 05d8aef2d..d51fe8187 100644 --- a/docs/edge/en/concepts/skills.mdx +++ b/docs/edge/en/concepts/skills.mdx @@ -9,7 +9,10 @@ mode: "wide" Skills are self-contained directories that provide agents with **domain-specific instructions, guidelines, and reference material**. Each skill is defined by a `SKILL.md` file with YAML frontmatter and a markdown body. -When activated, a skill's instructions are injected directly into the agent's task prompt — giving the agent expertise without requiring any code changes. +Agents first receive each configured skill's name and description. When a +description applies to the current request, the agent loads that skill's full +instructions for that execution. This keeps unrelated instructions out of the +context while giving the agent the relevant expertise without code changes. **Skills are NOT tools.** This is the most common point of confusion. @@ -79,12 +82,13 @@ reviewer = Agent( role="Senior Code Reviewer", goal="Review pull requests for quality and security issues", backstory="Staff engineer with expertise in secure coding practices.", - skills=["./skills"], # Injects review guidelines + skills=["./skills"], # Discovers review skills tools=[GithubSearchTool(), FileReadTool()], # Lets agent read code ) ``` -The agent now has both **expertise** (from the skill) and **capabilities** (from the tools). +The agent now has both **expertise** (loaded from the relevant skill when +needed) and **capabilities** (from the tools). --- @@ -324,7 +328,8 @@ The directory name must match the `name` field in `SKILL.md`. The `scripts/`, `r ## Pre-loading Skills -For more control, you can discover and activate skills programmatically: +For more control, you can discover and activate skills programmatically. +Passing an activated skill makes its instructions always-on: ```python from pathlib import Path @@ -351,12 +356,21 @@ agent = Agent( Skills use **progressive disclosure** — only loading what's needed at each stage: -| Stage | What's loaded | When | -| :--------- | :------------------------------------ | :------------------ | -| Discovery | Name, description, frontmatter fields | `discover_skills()` | -| Activation | Full SKILL.md body text | `activate_skill()` | +| Stage | What's loaded | When | +| :--------- | :------------------------------------ | :---------------------------------------- | +| Discovery | Name, description, frontmatter fields | Agent setup or `discover_skills()` | +| Activation | Full SKILL.md body text | Relevant runtime request or `activate_skill()` | +| Resources | Resource directory catalog | Explicit `load_resources()` call | -During normal agent execution (passing directory paths via `skills=["./skills"]`), skills are automatically discovered and activated. The progressive loading only matters when using the programmatic API. +With `skills=["./skills"]`, the directory is discovered at setup but the full +instructions are not placed in every prompt. The agent reviews the metadata on +each execution and loads only a skill that applies. The loaded instructions are +scoped to that execution, so skills selected for earlier calls do not accumulate +on the agent. + +Inline skill strings and `Skill` objects already activated with +`activate_skill()` remain always-on. This provides an explicit opt-in when the +instructions should apply to every request. --- diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index eb7d7834b..ab58400e8 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -83,7 +83,7 @@ from crewai.mcp.config import MCPServerConfig from crewai.rag.embeddings.types import EmbedderConfig from crewai.security.fingerprint import Fingerprint from crewai.skills.loader import load_skills -from crewai.skills.models import Skill as SkillModel +from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint from crewai.tools.agent_tools.agent_tools import AgentTools from crewai.types.callback import SerializableCallable @@ -480,9 +480,27 @@ class Agent(BaseAgent): self.skills = cast( list[Path | SkillModel | str] | None, - load_skills(items, source=self) or None, + load_skills(items, source=self, activate=False) or None, ) + def _add_skill_loader_tool( + self, + tools: list[BaseTool], + task: Task | None = None, + ) -> list[BaseTool]: + """Add the internal loader used for request-scoped skill disclosure.""" + from crewai.skills.tool import LoadSkillTool, create_skill_loader_tool + + tools = [tool for tool in tools if not isinstance(tool, LoadSkillTool)] + + skill_models = [ + skill for skill in self.skills or [] if isinstance(skill, SkillModel) + ] + loader = create_skill_loader_tool(skill_models, source=self, task=task) + if loader is None: + return tools + return [*tools, loader] + def _is_any_available_memory(self) -> bool: """Check if unified memory is available (agent or crew).""" if getattr(self, "memory", None): @@ -557,11 +575,11 @@ class Agent(BaseAgent): return apply_training_data(self, task_prompt) def _emit_skill_usage(self, task: Task) -> None: - """Emit one SkillUsedEvent per skill injected into this task's prompt. + """Emit usage for always-on skills injected into this task's prompt. - Skills are agent-scoped and rendered into the prompt on every execution, - so this is the runtime usage signal traces need — attributing each skill - to the agent and task that used it. + Metadata-only skills emit from ``LoadSkillTool`` if the model selects + them. This method covers explicitly activated and inline skills, whose + instructions are rendered on every execution. Args: task: The task whose prompt the skills are being applied to. @@ -570,7 +588,10 @@ class Agent(BaseAgent): return for skill in self.skills: - if not isinstance(skill, SkillModel): + if ( + not isinstance(skill, SkillModel) + or skill.disclosure_level < INSTRUCTIONS + ): continue crewai_event_bus.emit( self, @@ -1082,7 +1103,8 @@ class Agent(BaseAgent): Returns: An instance of the CrewAgentExecutor class. """ - raw_tools: list[BaseTool] = tools or self.tools or [] + configured_tools = tools if tools is not None else self.tools or [] + raw_tools = self._add_skill_loader_tool(list(configured_tools), task=task) parsed_tools = parse_tools(raw_tools) prompt, stop_words, rpm_limit_fn = self._build_execution_prompt(raw_tools) @@ -1421,6 +1443,9 @@ class Agent(BaseAgent): Returns: Tuple of (executor, inputs, agent_info, parsed_tools) ready for execution. """ + if self.tools_handler: + self.tools_handler.last_used_tool = None + if self.apps: platform_tools = self.get_platform_tools(self.apps) if platform_tools: @@ -1434,7 +1459,7 @@ class Agent(BaseAgent): self.tools = [] self.tools.extend(mcps) - raw_tools: list[BaseTool] = self.tools or [] + raw_tools = list(self.tools or []) agent_memory = getattr(self, "memory", None) if agent_memory is not None: @@ -1447,6 +1472,7 @@ class Agent(BaseAgent): if sanitize_tool_name(mt.name) not in existing_names ) + raw_tools = self._add_skill_loader_tool(raw_tools) parsed_tools = parse_tools(raw_tools) agent_info = { diff --git a/lib/crewai/src/crewai/crews/utils.py b/lib/crewai/src/crewai/crews/utils.py index cc155806a..706c61e49 100644 --- a/lib/crewai/src/crewai/crews/utils.py +++ b/lib/crewai/src/crewai/crews/utils.py @@ -12,8 +12,8 @@ from crewai.agents.agent_builder.base_agent import BaseAgent from crewai.crews.crew_output import CrewOutput from crewai.llms.base_llm import BaseLLM from crewai.rag.embeddings.types import EmbedderConfig -from crewai.skills.loader import activate_skill, load_skills -from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel +from crewai.skills.loader import load_skills +from crewai.skills.models import Skill as SkillModel from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput from crewai.utilities.file_store import store_files from crewai.utilities.streaming import ( @@ -59,13 +59,7 @@ def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None: if not isinstance(crew.skills, list) or not crew.skills: return None - resolved = load_skills(crew.skills) - if not resolved: - return None - return [ - activate_skill(skill) if skill.disclosure_level < INSTRUCTIONS else skill - for skill in resolved - ] + return load_skills(crew.skills, activate=False) or None def setup_agents( diff --git a/lib/crewai/src/crewai/events/types/skill_events.py b/lib/crewai/src/crewai/events/types/skill_events.py index 820ff862c..b3a377399 100644 --- a/lib/crewai/src/crewai/events/types/skill_events.py +++ b/lib/crewai/src/crewai/events/types/skill_events.py @@ -66,8 +66,8 @@ class SkillUsedEvent(SkillEvent): """Event emitted when an agent uses a skill during task execution. Discovery/load/activation events describe setup. This one is the runtime - signal: it fires each time a skill's context is injected into an agent's - prompt for a task, so traces can attribute skill usage to an agent and task. + signal: it fires when a metadata skill is selected or an always-on skill's + context is injected, so traces can attribute usage to an agent and task. """ type: Literal["skill_used"] = "skill_used" diff --git a/lib/crewai/src/crewai/skills/loader.py b/lib/crewai/src/crewai/skills/loader.py index 5e9ed7e81..3ad7f0853 100644 --- a/lib/crewai/src/crewai/skills/loader.py +++ b/lib/crewai/src/crewai/skills/loader.py @@ -19,7 +19,12 @@ from crewai.events.types.skill_events import ( SkillLoadFailedEvent, SkillLoadedEvent, ) -from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill, SkillFrontmatter +from crewai.skills.models import ( + INSTRUCTIONS, + RESOURCES, + Skill, + SkillFrontmatter, +) from crewai.skills.parser import ( SKILL_FILENAME, load_skill_instructions, @@ -148,25 +153,39 @@ def activate_skill( def load_skill( skill: Path | Skill | str, source: BaseAgent | None = None, + *, + activate: bool = True, ) -> list[Skill]: """Load one skill input into Skill objects. Accepts a pre-loaded Skill object, skill search path, inline SKILL.md string, or '@org/name' registry reference. Path inputs can expand to many - skills. Path and inline inputs are activated immediately; pre-loaded Skill - objects keep their disclosure level. + skills. Pre-loaded Skill objects keep their disclosure level. Path and + registry inputs are activated by default; callers performing runtime + progressive disclosure can leave them at metadata level. + + Args: + skill: Skill input to resolve. + source: Optional event source for event emission. + activate: Whether discovered path and registry skills should load their + full instructions immediately. + + Returns: + Resolved Skill objects. """ if isinstance(skill, Skill): return [skill] if isinstance(skill, Path): - return [ - activate_skill(s, source=source) - for s in discover_skills(skill, source=source) - ] + discovered = discover_skills(skill, source=source) + if not activate: + return discovered + return [activate_skill(s, source=source) for s in discovered] if isinstance(skill, str) and skill.startswith("@"): from crewai.skills.registry import resolve_registry_ref - return [resolve_registry_ref(skill, source=source)] + if activate: + return [resolve_registry_ref(skill, source=source)] + return [resolve_registry_ref(skill, source=source, activate=False)] if isinstance(skill, str) and skill.lstrip().startswith("---\n"): frontmatter_dict, body = parse_frontmatter(skill.strip()) return [ @@ -178,10 +197,10 @@ def load_skill( ) ] if isinstance(skill, str): - return [ - activate_skill(s, source=source) - for s in discover_skills(Path(skill), source=source) - ] + discovered = discover_skills(Path(skill), source=source) + if not activate: + return discovered + return [activate_skill(s, source=source) for s in discovered] msg = f"Unsupported skill input: {skill!r}" raise TypeError(msg) @@ -190,16 +209,27 @@ def load_skill( def load_skills( skills: Iterable[Path | Skill | str], source: BaseAgent | None = None, + *, + activate: bool = True, ) -> list[Skill]: """Load skill inputs into de-duplicated Skill objects. Preserves first-seen order when multiple inputs resolve to the same skill name. Registry refs are scoped by org so different orgs can publish skills that share a frontmatter name. + + Args: + skills: Skill inputs to resolve. + source: Optional event source for event emission. + activate: Whether discovered path and registry skills should load their + full instructions immediately. + + Returns: + De-duplicated Skill objects. """ loaded: dict[str, Skill] = {} for skill_input in skills: - for skill in load_skill(skill_input, source=source): + for skill in load_skill(skill_input, source=source, activate=activate): dedup_key = skill.name if isinstance(skill_input, str) and skill_input.startswith("@"): from crewai.skills.registry import parse_registry_ref diff --git a/lib/crewai/src/crewai/skills/registry.py b/lib/crewai/src/crewai/skills/registry.py index a20600bac..f6d43e1e8 100644 --- a/lib/crewai/src/crewai/skills/registry.py +++ b/lib/crewai/src/crewai/skills/registry.py @@ -114,6 +114,8 @@ def parse_registry_ref(ref: str) -> tuple[str, str]: def resolve_registry_ref( ref: str, source: Any = None, + *, + activate: bool = True, ) -> Skill: # type: ignore[name-defined] # noqa: F821 """Resolve a registry reference to a Skill object. @@ -129,9 +131,11 @@ def resolve_registry_ref( Args: ref: A registry reference, e.g. '@acme/my-skill' or '@acme/my-skill@1.2.0'. source: Optional source object passed through to skill loaders (for events). + activate: Whether to load the full SKILL.md instructions. Returns: - A Skill loaded at INSTRUCTIONS disclosure level. + A Skill loaded at INSTRUCTIONS level by default, or METADATA level + when ``activate`` is false. """ from crewai.skills.loader import activate_skill @@ -143,7 +147,7 @@ def resolve_registry_ref( # is the only thing a pin can be checked against. skill = _load_matching_skill(local_path, version) if skill is not None: - return activate_skill(skill, source=source) + return activate_skill(skill, source=source) if activate else skill cache = SkillCacheManager() cached_path = cache.get_cached_path(org, name, version=version) @@ -154,9 +158,15 @@ def resolve_registry_ref( # metadata.version and re-download it on each resolution. skill = _load_skill(cached_path) if skill is not None: - return activate_skill(skill, source=source) + return activate_skill(skill, source=source) if activate else skill - return download_skill(org, name, source=source, version=version) + return download_skill( + org, + name, + source=source, + version=version, + activate=activate, + ) def _load_skill(path: Path) -> Skill | None: # type: ignore[name-defined] # noqa: F821 @@ -268,6 +278,7 @@ def download_skill( source: Any = None, *, version: str | None = None, + activate: bool = True, ) -> Skill: # type: ignore[name-defined] # noqa: F821 """Download a skill from the registry and store it in the cache. @@ -276,9 +287,11 @@ def download_skill( name: Skill name. source: Optional source for event emission. version: Optional pinned version; the latest is fetched when omitted. + activate: Whether to load the full SKILL.md instructions. Returns: - The downloaded Skill at INSTRUCTIONS level. + The downloaded Skill at INSTRUCTIONS level by default, or METADATA + level when ``activate`` is false. Raises: ValueError: If *version* is given but blank. @@ -376,4 +389,4 @@ def download_skill( f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}" ) skill = load_skill_metadata(skill_dir) - return activate_skill(skill, source=source) + return activate_skill(skill, source=source) if activate else skill diff --git a/lib/crewai/src/crewai/skills/tool.py b/lib/crewai/src/crewai/skills/tool.py new file mode 100644 index 000000000..30d6fa3ec --- /dev/null +++ b/lib/crewai/src/crewai/skills/tool.py @@ -0,0 +1,74 @@ +"""Runtime tool for progressively disclosing Agent Skill instructions.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.skill_events import SkillUsedEvent +from crewai.skills.loader import activate_skill, format_skill_context +from crewai.skills.models import INSTRUCTIONS, Skill +from crewai.tools.base_tool import BaseTool + + +class LoadSkillSchema(BaseModel): + """Arguments accepted by the runtime skill loader.""" + + skill_name: str = Field( + ..., + description="Exact name of the available skill whose instructions are needed.", + ) + + +class LoadSkillTool(BaseTool): + """Load one relevant skill's instructions for the current execution.""" + + name: str = "load_skill" + description: str = ( + "Load the full instructions for one available skill. Use this before " + "working on a request when an available skill's description applies." + ) + args_schema: type[BaseModel] = LoadSkillSchema + skills: list[Skill] = Field(default_factory=list, exclude=True) + source: Any = Field(default=None, exclude=True) + task: Any = Field(default=None, exclude=True) + + def _run(self, skill_name: str, **kwargs: Any) -> str: + """Load and return one skill's instruction block.""" + skill = next((item for item in self.skills if item.name == skill_name), None) + if skill is None: + available = ", ".join(item.name for item in self.skills) + return ( + f"Skill {skill_name!r} is not available. " + f"Available skills: {available or 'none'}." + ) + + activated = activate_skill(skill, source=self.source) + crewai_event_bus.emit( + self.source, + event=SkillUsedEvent( + from_agent=self.source, + from_task=self.task, + skill_name=activated.name, + skill_path=activated.path, + disclosure_level=activated.disclosure_level, + ), + ) + return format_skill_context(activated) + + +def create_skill_loader_tool( + skills: list[Skill] | None, + *, + source: Any = None, + task: Any = None, +) -> LoadSkillTool | None: + """Create a loader tool when metadata-only skills are available.""" + available = [ + skill for skill in skills or [] if skill.disclosure_level < INSTRUCTIONS + ] + if not available: + return None + return LoadSkillTool(skills=available, source=source, task=task) diff --git a/lib/crewai/src/crewai/utilities/prompts.py b/lib/crewai/src/crewai/utilities/prompts.py index 03b221c99..467ebe6f7 100644 --- a/lib/crewai/src/crewai/utilities/prompts.py +++ b/lib/crewai/src/crewai/utilities/prompts.py @@ -115,22 +115,49 @@ class Prompts(BaseModel): ) def _build_skill_block(self) -> str: - """Render the agent's activated skills as a stable XML block. + """Render always-on instructions and the available skill catalog. - Skills are agent-scoped (do not change per task), so they live in the - system prompt where prompt-cache prefixes can survive across calls. + Metadata-only skills remain a stable prompt-cache anchor. Their full + instructions are disclosed through the runtime ``load_skill`` tool only + when the model determines that a skill applies to the current request. """ skills = getattr(self.agent, "skills", None) if not skills: return "" from crewai.skills.loader import format_skill_context - from crewai.skills.models import Skill + from crewai.skills.models import INSTRUCTIONS, Skill - sections = [format_skill_context(s) for s in skills if isinstance(s, Skill)] - if not sections: + active = [ + format_skill_context(skill) + for skill in skills + if isinstance(skill, Skill) + and skill.disclosure_level >= INSTRUCTIONS + ] + available = [ + format_skill_context(skill) + for skill in skills + if isinstance(skill, Skill) + and skill.disclosure_level < INSTRUCTIONS + ] + + blocks: list[str] = [] + if active: + blocks.append("\n" + "\n\n".join(active) + "\n") + if available: + catalog = "\n\n".join(available) + blocks.append( + "\n" + "Review these skill descriptions before answering. When one " + "clearly applies to the current request, call `load_skill` with " + "its exact name to load its full instructions. Do not use a " + "skill's instructions without loading it first.\n\n" + f"{catalog}\n" + "" + ) + if not blocks: return "" - return "\n\n\n" + "\n\n".join(sections) + "\n" + return "\n\n" + "\n\n".join(blocks) def _build_prompt( self, diff --git a/lib/crewai/tests/events/test_skill_usage_events.py b/lib/crewai/tests/events/test_skill_usage_events.py index 04044c19e..64fee3472 100644 --- a/lib/crewai/tests/events/test_skill_usage_events.py +++ b/lib/crewai/tests/events/test_skill_usage_events.py @@ -12,6 +12,8 @@ import pytest from crewai import Agent, Task from crewai.events import crewai_event_bus from crewai.events.types.skill_events import SkillUsedEvent +from crewai.skills.loader import activate_skill, discover_skills +from crewai.skills.tool import LoadSkillTool # Handlers run on the event bus thread pool, so tests must flush before @@ -29,12 +31,13 @@ def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path: def _agent_with_skills(search_path: Path) -> Agent: - # discover_skills scans the SUBdirectories of the given path. + """Create an agent with explicitly activated, always-on skills.""" + skills = [activate_skill(skill) for skill in discover_skills(search_path)] return Agent( role="Analyst", goal="Analyze things", backstory="Experienced", - skills=[str(search_path)], + skills=skills, llm="gpt-4o-mini", ) @@ -44,6 +47,43 @@ def _task_for(agent: Agent) -> Task: class TestSkillUsedEvent: + def test_metadata_skill_emits_only_after_runtime_selection( + self, tmp_path: Path + ) -> None: + _create_skill_dir(tmp_path, "alpha") + agent = Agent( + role="Analyst", + goal="Analyze things", + backstory="Experienced", + skills=[tmp_path], + llm="gpt-4o-mini", + ) + task = _task_for(agent) + agent.create_agent_executor(task=task) + assert agent.agent_executor is not None + loader = next( + tool + for tool in agent.agent_executor.original_tools + if isinstance(tool, LoadSkillTool) + ) + + received: list[SkillUsedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(SkillUsedEvent) + def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001 + received.append(event) + + agent._emit_skill_usage(task) + assert crewai_event_bus.flush(timeout=10) + assert received == [] + + loader.run(skill_name="alpha") + assert crewai_event_bus.flush(timeout=10) + + assert [event.skill_name for event in received] == ["alpha"] + assert received[0].task_id == str(task.id) + def test_emits_one_event_per_skill_with_agent_and_task_attribution( self, tmp_path: Path ) -> None: @@ -109,7 +149,7 @@ class TestSkillUsedEvent: assert len(received) == 2 def test_reports_disclosure_level(self, tmp_path: Path) -> None: - """Path-loaded skills are activated, so they report INSTRUCTIONS level.""" + """Explicitly activated skills report INSTRUCTIONS level.""" _create_skill_dir(tmp_path, "alpha") agent = _agent_with_skills(tmp_path) task = _task_for(agent) diff --git a/lib/crewai/tests/skills/test_integration.py b/lib/crewai/tests/skills/test_integration.py index 3f8d49a46..4ada4ab8b 100644 --- a/lib/crewai/tests/skills/test_integration.py +++ b/lib/crewai/tests/skills/test_integration.py @@ -144,7 +144,8 @@ class TestSkillDiscoveryAndActivation: assert agent.skills is not None assert [skill.name for skill in agent.skills] == ["path-skill"] - assert [skill.instructions for skill in agent.skills] == ["Use the path skill."] + assert [skill.disclosure_level for skill in agent.skills] == [METADATA] + assert [skill.instructions for skill in agent.skills] == [None] def test_crew_resolves_inline_skill_string(self) -> None: agent = Agent( @@ -175,7 +176,7 @@ class TestSkillDiscoveryAndActivation: assert [skill.name for skill in skills] == ["crew-inline-review"] assert [skill.instructions for skill in skills] == ["Apply this to every agent."] - def test_crew_activates_preloaded_metadata_skill(self, tmp_path: Path) -> None: + def test_crew_preserves_preloaded_metadata_skill(self, tmp_path: Path) -> None: _create_skill_dir( tmp_path, "crew-preloaded", @@ -202,7 +203,5 @@ class TestSkillDiscoveryAndActivation: assert skills is not None assert [skill.name for skill in skills] == ["crew-preloaded"] - assert [skill.disclosure_level for skill in skills] == [INSTRUCTIONS] - assert [skill.instructions for skill in skills] == [ - "Apply this crew-level guidance to every agent." - ] + assert [skill.disclosure_level for skill in skills] == [METADATA] + assert [skill.instructions for skill in skills] == [None] diff --git a/lib/crewai/tests/skills/test_progressive_disclosure.py b/lib/crewai/tests/skills/test_progressive_disclosure.py new file mode 100644 index 000000000..487b5f7ca --- /dev/null +++ b/lib/crewai/tests/skills/test_progressive_disclosure.py @@ -0,0 +1,208 @@ +"""Regression tests for runtime skill progressive disclosure. + +Run this focused test file with: + + uv run pytest lib/crewai/tests/skills/test_progressive_disclosure.py -q +""" + +from pathlib import Path +from typing import Any + +from crewai import Agent, Task +from crewai.llms.base_llm import BaseLLM +from crewai.skills.models import METADATA +from crewai.skills.tool import LoadSkillTool +from crewai.utilities.prompts import Prompts + + +def _create_skill( + parent: Path, + name: str, + description: str, + instructions: str, +) -> None: + skill_dir = parent / name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n{instructions}", + encoding="utf-8", + ) + + +class _SkillChoosingLLM(BaseLLM): + """Small deterministic LLM that exercises the real agent tool loop.""" + + def call(self, messages: Any, **kwargs: Any) -> str: + rendered = str(messages) + if "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" in rendered: + return "Thought: I loaded the review skill.\nFinal Answer: python loaded" + if "TRAVEL_PRIVATE_INSTRUCTIONS" in rendered: + return "Thought: I loaded the travel skill.\nFinal Answer: travel loaded" + if "Review this Python change" in rendered: + return ( + "Thought: The Python review skill applies.\n" + "Action: load_skill\n" + 'Action Input: {"skill_name": "python-review"}' + ) + if "Plan a weekend trip" in rendered: + return ( + "Thought: The travel planning skill applies.\n" + "Action: load_skill\n" + 'Action Input: {"skill_name": "travel-planning"}' + ) + return "Thought: No skill applies.\nFinal Answer: no skill" + + def supports_function_calling(self) -> bool: + return False + + def supports_stop_words(self) -> bool: + return False + + def get_context_window_size(self) -> int: + return 8_192 + + +def test_agent_directory_skills_do_not_eagerly_disclose_instructions( + tmp_path: Path, +) -> None: + """An agent should initially receive only the skill catalog metadata.""" + _create_skill( + tmp_path, + "python-review", + "Review Python code when the user asks for a code review.", + "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS", + ) + _create_skill( + tmp_path, + "travel-planning", + "Plan travel when the user asks for a trip itinerary.", + "TRAVEL_PRIVATE_INSTRUCTIONS", + ) + + agent = Agent( + role="Assistant", + goal="Help with the current request.", + backstory="A general-purpose assistant.", + skills=[tmp_path], + ) + + assert agent.skills is not None + assert [skill.disclosure_level for skill in agent.skills] == [ + METADATA, + METADATA, + ] + assert [skill.instructions for skill in agent.skills] == [None, None] + + +def test_consecutive_kickoffs_select_skills_without_cross_call_leakage( + tmp_path: Path, +) -> None: + """Each execution should independently choose its relevant skill.""" + _create_skill( + tmp_path, + "python-review", + "Review Python code when the user asks for a code review.", + "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS", + ) + _create_skill( + tmp_path, + "travel-planning", + "Plan travel when the user asks for a trip itinerary.", + "TRAVEL_PRIVATE_INSTRUCTIONS", + ) + agent = Agent( + role="Assistant", + goal="Help with the current request.", + backstory="A general-purpose assistant.", + skills=[tmp_path], + llm=_SkillChoosingLLM(model="skill-test"), + max_iter=3, + ) + + review_output = agent.kickoff("Review this Python change") + travel_output = agent.kickoff("Plan a weekend trip") + repeated_review_output = agent.kickoff("Review this Python change") + + assert review_output.raw == "python loaded" + assert travel_output.raw == "travel loaded" + assert repeated_review_output.raw == "python loaded" + assert agent.skills is not None + assert [skill.disclosure_level for skill in agent.skills] == [ + METADATA, + METADATA, + ] + assert [skill.instructions for skill in agent.skills] == [None, None] + + prompt = Prompts( + agent=agent, + has_tools=False, + use_system_prompt=True, + ).task_execution() + rendered = getattr(prompt, "system", "") or prompt.prompt + + assert "python-review" in rendered + assert "travel-planning" in rendered + assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" not in rendered + assert "TRAVEL_PRIVATE_INSTRUCTIONS" not in rendered + + +def test_each_execution_can_disclose_only_its_relevant_skill(tmp_path: Path) -> None: + """Loading one skill must not leak or permanently activate another.""" + _create_skill( + tmp_path, + "python-review", + "Review Python code when the user asks for a code review.", + "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS", + ) + _create_skill( + tmp_path, + "travel-planning", + "Plan travel when the user asks for a trip itinerary.", + "TRAVEL_PRIVATE_INSTRUCTIONS", + ) + agent = Agent( + role="Assistant", + goal="Help with the current request.", + backstory="A general-purpose assistant.", + skills=[tmp_path], + ) + + review_task = Task( + description="Review this Python change.", + expected_output="Review findings.", + agent=agent, + ) + agent.create_agent_executor(task=review_task) + assert agent.agent_executor is not None + review_loader = next( + tool + for tool in agent.agent_executor.original_tools + if isinstance(tool, LoadSkillTool) + ) + review_context = review_loader.run(skill_name="python-review") + + assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" in review_context + assert "TRAVEL_PRIVATE_INSTRUCTIONS" not in review_context + + travel_task = Task( + description="Plan a weekend trip.", + expected_output="A travel itinerary.", + agent=agent, + ) + agent.create_agent_executor(task=travel_task) + assert agent.agent_executor is not None + travel_loader = next( + tool + for tool in agent.agent_executor.original_tools + if isinstance(tool, LoadSkillTool) + ) + travel_context = travel_loader.run(skill_name="travel-planning") + + assert "TRAVEL_PRIVATE_INSTRUCTIONS" in travel_context + assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" not in travel_context + assert agent.skills is not None + assert [skill.disclosure_level for skill in agent.skills] == [ + METADATA, + METADATA, + ] + assert [skill.instructions for skill in agent.skills] == [None, None] diff --git a/lib/crewai/tests/skills/test_registry.py b/lib/crewai/tests/skills/test_registry.py index a6b9de976..9d5dfc960 100644 --- a/lib/crewai/tests/skills/test_registry.py +++ b/lib/crewai/tests/skills/test_registry.py @@ -10,6 +10,7 @@ from zipfile import ZipFile from crewai.context import platform_context from crewai.skills.cache import SkillCacheManager +from crewai.skills.models import METADATA from crewai.skills.registry import ( SkillRef, download_skill, @@ -181,6 +182,17 @@ class TestParseSkillRef: class TestResolveRegistryRef: + def test_can_resolve_metadata_without_loading_instructions( + self, tmp_path: Path + ) -> None: + _write_local_skill(tmp_path, "my-skill") + + with patch.object(Path, "cwd", return_value=tmp_path): + skill = resolve_registry_ref("@acme/my-skill", activate=False) + + assert skill.disclosure_level == METADATA + assert skill.instructions is None + def test_prefers_project_local_skill_over_cached_skill( self, tmp_path: Path ) -> None: