skills progressive disclosure

This commit is contained in:
lorenzejay
2026-07-26 14:09:09 -07:00
parent 213d9485fe
commit ba9b29ffcf
12 changed files with 501 additions and 64 deletions

View File

@@ -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.
<Note type="info" title="Skills vs Tools — The Key Distinction">
**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.
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("<skills>\n" + "\n\n".join(active) + "\n</skills>")
if available:
catalog = "\n\n".join(available)
blocks.append(
"<available_skills>\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"
"</available_skills>"
)
if not blocks:
return ""
return "\n\n<skills>\n" + "\n\n".join(sections) + "\n</skills>"
return "\n\n" + "\n\n".join(blocks)
def _build_prompt(
self,

View File

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

View File

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

View File

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

View File

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