mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Add built-in create-skill skill (#57064)
Closes AI-266 This PR adds a built-in skill called `create-skill`, which allows the Zed agent to have access to a skill that teaches it how to properly create skills for Zed. You can manually invoke it as well as just letting the model auto-invoke it in case your prompt suggests creating a new skill. Release Notes: - Agent: Added a built-in skill called `create-skill` to make the Zed agent informed about how to do that. --------- Co-authored-by: Richard Feldman <richard@zed.dev>
This commit is contained in:
parent
980a294292
commit
8ca194d833
9 changed files with 449 additions and 87 deletions
|
|
@ -359,6 +359,7 @@ impl MentionUri {
|
|||
match self {
|
||||
MentionUri::Skill { name, source, .. } => {
|
||||
if source.is_empty() {
|
||||
// Must match `SkillSource::display_label()` in agent_skills.
|
||||
format!("{} (global)", name)
|
||||
} else {
|
||||
format!("{} ({})", name, source)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use acp_thread::{
|
|||
use agent_client_protocol::schema as acp;
|
||||
use agent_skills::{
|
||||
MAX_SKILL_DESCRIPTIONS_SIZE, Skill, SkillLoadError, SkillScopeId, SkillSource, SkillSummary,
|
||||
global_skills_dir, load_skills_from_directory, project_skills_relative_path,
|
||||
builtin_skills, global_skills_dir, load_skills_from_directory, project_skills_relative_path,
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -104,7 +104,7 @@ impl From<&Skill> for NativeAvailableSkill {
|
|||
Self {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
source: skill.source.scope_prefix().to_string().into(),
|
||||
source: skill.source.display_label().to_string().into(),
|
||||
skill_file_path: skill.skill_file_path.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1644,14 +1644,18 @@ impl NativeAgent {
|
|||
// Read the body on demand here — bodies live on disk between
|
||||
// materializations to keep memory cost O(total frontmatter)
|
||||
// rather than O(total file size).
|
||||
let body = agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read skill body from {}",
|
||||
skill.skill_file_path.display()
|
||||
)
|
||||
})?;
|
||||
let body = if let Some(embedded) = skill.embedded_body {
|
||||
embedded.to_string()
|
||||
} else {
|
||||
agent_skills::read_skill_body(fs.as_ref(), &skill.skill_file_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read skill body from {}",
|
||||
skill.skill_file_path.display()
|
||||
)
|
||||
})?
|
||||
};
|
||||
let envelope = crate::tools::render_skill_envelope(&skill, &body);
|
||||
let envelope_block = acp::ContentBlock::Text(acp::TextContent::new(envelope));
|
||||
|
||||
|
|
@ -2245,9 +2249,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
// we don't clone the entire skill list on every prompt
|
||||
// (including prompts like `/help` that aren't skills at
|
||||
// all). The resolution rule matches the override-applied
|
||||
// view: prefer a project-local with the matching name,
|
||||
// falling back to a global, so the slash command picks the
|
||||
// same entry the model sees in its catalog.
|
||||
// view: among skills with the matching name, pick the one
|
||||
// with the highest source precedence, so the slash command
|
||||
// picks the same entry the model sees in its catalog.
|
||||
// Ties (e.g. two project-local skills from different
|
||||
// worktrees) resolve to the first in iteration order to
|
||||
// match `apply_skill_overrides`.
|
||||
if parsed_command.explicit_server_id.is_none()
|
||||
&& parsed_command.skill_scope.is_none()
|
||||
&& !project_state.skills.is_empty()
|
||||
|
|
@ -2256,15 +2263,13 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
let resolved = project_state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| {
|
||||
skill.name == prompt_name
|
||||
&& matches!(skill.source, SkillSource::ProjectLocal { .. })
|
||||
})
|
||||
.or_else(|| {
|
||||
project_state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == prompt_name)
|
||||
.filter(|skill| skill.name == prompt_name)
|
||||
.reduce(|best, candidate| {
|
||||
if candidate.source.precedence() > best.source.precedence() {
|
||||
candidate
|
||||
} else {
|
||||
best
|
||||
}
|
||||
});
|
||||
if let Some(skill) = resolved {
|
||||
let skill = skill.clone();
|
||||
|
|
@ -2960,7 +2965,9 @@ fn combine_skills(
|
|||
global: Vec<Result<Skill, SkillLoadError>>,
|
||||
project: impl Iterator<Item = Result<Skill, SkillLoadError>>,
|
||||
) -> (Vec<Skill>, Vec<SkillLoadError>) {
|
||||
let mut skills = Vec::new();
|
||||
// Built-in skills go first (lowest priority) so that global and
|
||||
// project-local skills with the same name shadow them.
|
||||
let mut skills = builtin_skills();
|
||||
let mut errors = Vec::new();
|
||||
for result in global.into_iter().chain(project) {
|
||||
match result {
|
||||
|
|
@ -2979,17 +2986,16 @@ fn log_skill_conflicts(skills: &[Skill]) {
|
|||
let mut by_name: HashMap<&str, &Skill> = HashMap::default();
|
||||
for skill in skills {
|
||||
match by_name.get(skill.name.as_str()) {
|
||||
Some(existing) => match (&existing.source, &skill.source) {
|
||||
(SkillSource::Global, SkillSource::ProjectLocal { .. }) => {
|
||||
Some(existing) => {
|
||||
if skill.source.precedence() > existing.source.precedence() {
|
||||
log::warn!(
|
||||
"Project skill '{}' at '{}' overrides global skill at '{}' for the model; both appear in the slash-command popup with their source",
|
||||
"Skill '{}' at '{}' overrides skill at '{}' for the model; both appear in the slash-command popup with their source",
|
||||
skill.name,
|
||||
skill.skill_file_path.display(),
|
||||
existing.skill_file_path.display(),
|
||||
);
|
||||
by_name.insert(skill.name.as_str(), skill);
|
||||
}
|
||||
_ => {
|
||||
} else {
|
||||
log::warn!(
|
||||
"Skill '{}' at '{}' conflicts with skill at '{}'; the model will see the first one, but both appear in the slash-command popup with their source",
|
||||
skill.name,
|
||||
|
|
@ -2997,7 +3003,7 @@ fn log_skill_conflicts(skills: &[Skill]) {
|
|||
existing.skill_file_path.display(),
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
None => {
|
||||
by_name.insert(skill.name.as_str(), skill);
|
||||
}
|
||||
|
|
@ -3024,9 +3030,7 @@ fn apply_skill_overrides(skills: &[Skill]) -> Vec<Skill> {
|
|||
for skill in skills {
|
||||
match indices.get(skill.name.as_str()).copied() {
|
||||
Some(idx) => {
|
||||
if matches!(result[idx].source, SkillSource::Global)
|
||||
&& matches!(skill.source, SkillSource::ProjectLocal { .. })
|
||||
{
|
||||
if skill.source.precedence() > result[idx].source.precedence() {
|
||||
result[idx] = skill.clone();
|
||||
}
|
||||
}
|
||||
|
|
@ -3064,6 +3068,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from(format!("/home/user/.agents/skills/{name}")),
|
||||
skill_file_path: PathBuf::from(format!("/home/user/.agents/skills/{name}/SKILL.md")),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3078,9 +3083,30 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")),
|
||||
skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_builtin_skill(name: &str, description: &str) -> Skill {
|
||||
Skill {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
source: SkillSource::BuiltIn,
|
||||
directory_path: PathBuf::from(format!("/builtin/{name}")),
|
||||
skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: Some("built-in body"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter to only user-defined (non-built-in) skills for test assertions.
|
||||
fn user_skills(skills: &[Skill]) -> Vec<&Skill> {
|
||||
skills
|
||||
.iter()
|
||||
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combine_skills_keeps_every_entry_for_autocomplete() {
|
||||
// The autocomplete popup needs both same-named entries so the
|
||||
|
|
@ -3092,9 +3118,10 @@ mod internal_tests {
|
|||
let (skills, errors) = combine_skills(vec![Ok(global)], vec![Ok(project)].into_iter());
|
||||
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert!(matches!(skills[0].source, SkillSource::Global));
|
||||
assert!(matches!(skills[1].source, SkillSource::ProjectLocal { .. }));
|
||||
let user = user_skills(&skills);
|
||||
assert_eq!(user.len(), 2);
|
||||
assert!(matches!(user[0].source, SkillSource::Global));
|
||||
assert!(matches!(user[1].source, SkillSource::ProjectLocal { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3130,6 +3157,51 @@ mod internal_tests {
|
|||
assert_eq!(resolved[0].description, "First");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_skill_overrides_global_wins_over_builtin() {
|
||||
// A global skill with the same name as a built-in must shadow
|
||||
// the built-in in the model-facing projection, regardless of
|
||||
// iteration order.
|
||||
let built_in = make_builtin_skill("create-skill", "Built-in version");
|
||||
let global = make_global_skill("create-skill", "User override");
|
||||
|
||||
let resolved = apply_skill_overrides(&[built_in, global]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].description, "User override");
|
||||
assert!(matches!(resolved[0].source, SkillSource::Global));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_skill_overrides_project_wins_over_builtin() {
|
||||
let built_in = make_builtin_skill("create-skill", "Built-in version");
|
||||
let project = make_project_skill("create-skill", "Project override", "my-project");
|
||||
|
||||
let resolved = apply_skill_overrides(&[built_in, project]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].description, "Project override");
|
||||
assert!(matches!(
|
||||
resolved[0].source,
|
||||
SkillSource::ProjectLocal { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_skill_overrides_project_wins_over_builtin_and_global() {
|
||||
// All three sources present — the project-local must win and
|
||||
// both lower-precedence entries must be dropped from the
|
||||
// model-facing projection.
|
||||
let built_in = make_builtin_skill("create-skill", "Built-in");
|
||||
let global = make_global_skill("create-skill", "Global");
|
||||
let project = make_project_skill("create-skill", "Project", "my-project");
|
||||
|
||||
let resolved = apply_skill_overrides(&[built_in, global, project]);
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].description, "Project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_skill_overrides_preserves_unique_skills() {
|
||||
let global_a = make_global_skill("alpha", "a");
|
||||
|
|
@ -3201,6 +3273,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from(format!("/skills/{name}")),
|
||||
skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3275,6 +3348,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from("/skills/skill-01-first"),
|
||||
skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
let second = Skill {
|
||||
name: "skill-02-overflows".to_string(),
|
||||
|
|
@ -3283,6 +3357,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from("/skills/skill-02-overflows"),
|
||||
skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
let third = Skill {
|
||||
name: "skill-03-would-fit".to_string(),
|
||||
|
|
@ -3291,6 +3366,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from("/skills/skill-03-would-fit"),
|
||||
skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
|
||||
// Sanity-check the test setup: the third skill is small enough
|
||||
|
|
@ -3346,6 +3422,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from("/skills/hidden-huge"),
|
||||
skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"),
|
||||
disable_model_invocation: true,
|
||||
embedded_body: None,
|
||||
};
|
||||
let visible = Skill {
|
||||
name: "visible".to_string(),
|
||||
|
|
@ -3354,6 +3431,7 @@ mod internal_tests {
|
|||
directory_path: PathBuf::from("/skills/visible"),
|
||||
skill_file_path: PathBuf::from("/skills/visible/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
|
||||
let (kept, errors) = select_catalog_skills(&[hidden, visible]);
|
||||
|
|
@ -3496,9 +3574,10 @@ mod internal_tests {
|
|||
// The pre-existing skill should be loaded into the project state.
|
||||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project.entity_id()).unwrap();
|
||||
assert_eq!(state.skills.len(), 1);
|
||||
assert_eq!(state.skills[0].name, "my-skill");
|
||||
assert_eq!(state.skills[0].description, "First version");
|
||||
let user = user_skills(&state.skills);
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!(user[0].name, "my-skill");
|
||||
assert_eq!(user[0].description, "First version");
|
||||
});
|
||||
|
||||
// Modify the SKILL.md and verify the project context refreshes.
|
||||
|
|
@ -3512,8 +3591,9 @@ mod internal_tests {
|
|||
|
||||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project.entity_id()).unwrap();
|
||||
assert_eq!(state.skills.len(), 1);
|
||||
assert_eq!(state.skills[0].description, "Second version");
|
||||
let user = user_skills(&state.skills);
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!(user[0].description, "Second version");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3559,8 +3639,8 @@ mod internal_tests {
|
|||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project.entity_id()).unwrap();
|
||||
assert!(
|
||||
state.skills.is_empty(),
|
||||
"expected no skills before the global skills dir exists, got {:?}",
|
||||
user_skills(&state.skills).is_empty(),
|
||||
"expected no user skills before the global skills dir exists, got {:?}",
|
||||
state.skills
|
||||
);
|
||||
});
|
||||
|
|
@ -3585,9 +3665,10 @@ mod internal_tests {
|
|||
|
||||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project.entity_id()).unwrap();
|
||||
assert_eq!(state.skills.len(), 1);
|
||||
assert_eq!(state.skills[0].name, "late-skill");
|
||||
assert_eq!(state.skills[0].description, "Created after startup");
|
||||
let user = user_skills(&state.skills);
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!(user[0].name, "late-skill");
|
||||
assert_eq!(user[0].description, "Created after startup");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3638,8 +3719,8 @@ mod internal_tests {
|
|||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project_id).unwrap();
|
||||
assert!(
|
||||
state.skills.is_empty(),
|
||||
"expected no skills before the global skills dir exists, got {:?}",
|
||||
user_skills(&state.skills).is_empty(),
|
||||
"expected no user skills before the global skills dir exists, got {:?}",
|
||||
state.skills
|
||||
);
|
||||
});
|
||||
|
|
@ -3656,7 +3737,12 @@ mod internal_tests {
|
|||
// empty list — NOT the snapshot that `Thread::new` would have
|
||||
// captured.
|
||||
cx.update(|cx| {
|
||||
assert!(resolve(cx).is_empty());
|
||||
let all = resolve(cx);
|
||||
let user: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
||||
.collect();
|
||||
assert!(user.is_empty());
|
||||
});
|
||||
|
||||
// Now create a SKILL.md AFTER the session was registered. With
|
||||
|
|
@ -3681,15 +3767,20 @@ mod internal_tests {
|
|||
// `state.skills` reflects the new skill (the watcher ran).
|
||||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project_id).unwrap();
|
||||
assert_eq!(state.skills.len(), 1);
|
||||
assert_eq!(state.skills[0].name, "my-skill");
|
||||
let user = user_skills(&state.skills);
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!(user[0].name, "my-skill");
|
||||
});
|
||||
|
||||
// The resolver the `SkillTool` uses must see it too. This is the
|
||||
// crux of the regression test: the tool's view of skills is
|
||||
// resolved at invocation time, not at thread-construction time.
|
||||
cx.update(|cx| {
|
||||
let snapshot = resolve(cx);
|
||||
let all = resolve(cx);
|
||||
let snapshot: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
snapshot.len(),
|
||||
1,
|
||||
|
|
@ -3777,7 +3868,11 @@ mod internal_tests {
|
|||
let parent_resolve =
|
||||
cx.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), project_id));
|
||||
cx.update(|cx| {
|
||||
let parent_skills = parent_resolve(cx);
|
||||
let all = parent_resolve(cx);
|
||||
let parent_skills: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
||||
.collect();
|
||||
assert_eq!(parent_skills.len(), 1);
|
||||
assert_eq!(parent_skills[0].name, "shared-skill");
|
||||
});
|
||||
|
|
@ -3823,7 +3918,11 @@ mod internal_tests {
|
|||
let subagent_resolve = cx
|
||||
.update(|_cx| super::skills_resolver_for_project(agent.downgrade(), parent_project_id));
|
||||
cx.update(|cx| {
|
||||
let subagent_skills = subagent_resolve(cx);
|
||||
let all = subagent_resolve(cx);
|
||||
let subagent_skills: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|s| !matches!(s.source, SkillSource::BuiltIn))
|
||||
.collect();
|
||||
assert_eq!(subagent_skills.len(), 1);
|
||||
assert_eq!(subagent_skills[0].name, "shared-skill");
|
||||
});
|
||||
|
|
@ -3919,7 +4018,14 @@ mod internal_tests {
|
|||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(catalog, vec!["visible-skill"]);
|
||||
assert!(
|
||||
catalog.contains(&"visible-skill"),
|
||||
"visible skill missing from catalog: {catalog:?}"
|
||||
);
|
||||
assert!(
|
||||
!catalog.contains(&"deploy"),
|
||||
"deploy should be excluded from catalog: {catalog:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3986,7 +4092,7 @@ mod internal_tests {
|
|||
agent.read_with(cx, |agent, cx| {
|
||||
let state = agent.projects.get(&project_id).unwrap();
|
||||
assert!(
|
||||
state.skills.is_empty(),
|
||||
user_skills(&state.skills).is_empty(),
|
||||
"untrusted worktree skills should not load: {:?}",
|
||||
state
|
||||
.skills
|
||||
|
|
@ -4019,7 +4125,8 @@ mod internal_tests {
|
|||
|
||||
agent.read_with(cx, |agent, _cx| {
|
||||
let state = agent.projects.get(&project_id).unwrap();
|
||||
let names: Vec<&str> = state.skills.iter().map(|s| s.name.as_str()).collect();
|
||||
let user = user_skills(&state.skills);
|
||||
let names: Vec<&str> = user.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["my-skill"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -364,11 +364,7 @@ impl UserMessage {
|
|||
.ok();
|
||||
}
|
||||
MentionUri::Skill { name, source, .. } => {
|
||||
let label = if source.is_empty() {
|
||||
format!("{} (global)", name)
|
||||
} else {
|
||||
format!("{} ({})", name, source)
|
||||
};
|
||||
let label = format!("{} ({})", name, source);
|
||||
write!(&mut skills_context, "\nSkill: {}\n{}\n", label, content).ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@ fn neutralize_envelope_tags(input: &str) -> String {
|
|||
/// frontmatter), not O(total file size).
|
||||
pub fn render_skill_envelope(skill: &Skill, body: &str) -> String {
|
||||
let source = match &skill.source {
|
||||
agent_skills::SkillSource::BuiltIn => "built-in",
|
||||
agent_skills::SkillSource::Global => "global",
|
||||
agent_skills::SkillSource::ProjectLocal { .. } => "project-local",
|
||||
};
|
||||
let worktree = match &skill.source {
|
||||
agent_skills::SkillSource::Global => None,
|
||||
agent_skills::SkillSource::BuiltIn | agent_skills::SkillSource::Global => None,
|
||||
agent_skills::SkillSource::ProjectLocal {
|
||||
worktree_root_name, ..
|
||||
} => Some(worktree_root_name.clone()),
|
||||
|
|
@ -200,31 +201,33 @@ impl AgentTool for SkillTool {
|
|||
(skill.clone(), path_string)
|
||||
};
|
||||
|
||||
// Read the body on demand. Bodies are not kept in memory
|
||||
// between materializations — see `agent_skills::read_skill_body`.
|
||||
let body = agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path)
|
||||
.await
|
||||
.map_err(|e| SkillToolOutput::Error {
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
// For built-in skills the body is already in memory (compiled
|
||||
// into the binary). For user skills, read on demand from disk.
|
||||
let body = if let Some(embedded) = skill.embedded_body {
|
||||
embedded.to_string()
|
||||
} else {
|
||||
agent_skills::read_skill_body(self.fs.as_ref(), &skill.skill_file_path)
|
||||
.await
|
||||
.map_err(|e| SkillToolOutput::Error {
|
||||
error: e.to_string(),
|
||||
})?
|
||||
};
|
||||
let rendered = render_skill_envelope(&skill, &body);
|
||||
|
||||
// Activations go through the standard tool-permission flow so
|
||||
// they participate in the same Allow-Once / Always-Allow UX as
|
||||
// every other built-in tool. The auth context value is the
|
||||
// skill's absolute SKILL.md path so that "always allow this
|
||||
// specific skill" is keyed to a specific file: editing the
|
||||
// SKILL.md will change the path's content but not the path,
|
||||
// so for content-change re-trust we'd want a hash too — but
|
||||
// at minimum, two skills with the same name from different
|
||||
// locations get independent trust grants.
|
||||
let authorize = cx.update(|cx| {
|
||||
let context = crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]);
|
||||
event_stream.authorize(self.initial_title(Ok(input), cx), context, cx)
|
||||
});
|
||||
authorize.await.map_err(|e| SkillToolOutput::Error {
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
// Built-in skills ship with Zed and are trusted by default,
|
||||
// so they skip the authorization prompt. User-installed skills
|
||||
// go through the standard Allow-Once / Always-Allow UX.
|
||||
let is_builtin = skill.source == agent_skills::SkillSource::BuiltIn;
|
||||
if !is_builtin {
|
||||
let authorize = cx.update(|cx| {
|
||||
let context =
|
||||
crate::ToolPermissionContext::new(Self::NAME, vec![skill_file_path]);
|
||||
event_stream.authorize(self.initial_title(Ok(input), cx), context, cx)
|
||||
});
|
||||
authorize.await.map_err(|e| SkillToolOutput::Error {
|
||||
error: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(SkillToolOutput::Found { rendered })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -64,11 +64,19 @@ pub struct Skill {
|
|||
/// `skill` tool refuses to load it. The user can still invoke it as a
|
||||
/// slash command.
|
||||
pub disable_model_invocation: bool,
|
||||
/// For built-in skills whose content is compiled into the binary,
|
||||
/// this holds the full SKILL.md body so the skill tool can serve it
|
||||
/// without a filesystem read.
|
||||
pub embedded_body: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Indicates where a skill was loaded from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillSource {
|
||||
/// Compiled into the Zed binary. These are always available and have
|
||||
/// the lowest override priority (global and project-local skills can
|
||||
/// shadow them).
|
||||
BuiltIn,
|
||||
/// From ~/.agents/skills/
|
||||
Global,
|
||||
/// From {project}/.agents/skills/
|
||||
|
|
@ -79,6 +87,23 @@ pub enum SkillSource {
|
|||
}
|
||||
|
||||
impl SkillSource {
|
||||
/// Precedence for resolving same-named skills. Higher values shadow
|
||||
/// lower ones: `ProjectLocal` > `Global` > `BuiltIn`. Two sources
|
||||
/// returning equal precedence (e.g. two project-local skills from
|
||||
/// different worktrees) leave the winner up to the caller, which by
|
||||
/// convention keeps the first one in iteration order.
|
||||
///
|
||||
/// Adding a new `SkillSource` variant should be a one-line change
|
||||
/// here — every consumer routes through this method so the hierarchy
|
||||
/// stays in sync.
|
||||
pub fn precedence(&self) -> u8 {
|
||||
match self {
|
||||
Self::BuiltIn => 0,
|
||||
Self::Global => 1,
|
||||
Self::ProjectLocal { .. } => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope prefix used in the `/<prefix>:<name>` slash-command
|
||||
/// syntax that the autocomplete popup inserts. Global skills use
|
||||
/// an empty prefix (so the inserted text is `/:<name>`), and
|
||||
|
|
@ -91,9 +116,21 @@ impl SkillSource {
|
|||
/// invoked as `/:<name>`, and the worktree's skill is invoked as
|
||||
/// `/global:<name>`. The two grammars never collide on the
|
||||
/// inserted text.
|
||||
/// Human-readable label for this source, used in the UI to
|
||||
/// distinguish skills from different origins.
|
||||
pub fn display_label(&self) -> &str {
|
||||
match self {
|
||||
Self::BuiltIn => "built-in",
|
||||
Self::Global => "global",
|
||||
Self::ProjectLocal {
|
||||
worktree_root_name, ..
|
||||
} => worktree_root_name.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope_prefix(&self) -> &str {
|
||||
match self {
|
||||
Self::Global => "",
|
||||
Self::BuiltIn | Self::Global => "",
|
||||
Self::ProjectLocal {
|
||||
worktree_root_name, ..
|
||||
} => worktree_root_name.as_ref(),
|
||||
|
|
@ -112,7 +149,7 @@ impl SkillSource {
|
|||
/// strictness only affects users typing by memory.
|
||||
pub fn matches_scope(&self, scope: &str) -> bool {
|
||||
match self {
|
||||
Self::Global => scope.is_empty(),
|
||||
Self::BuiltIn | Self::Global => scope.is_empty(),
|
||||
Self::ProjectLocal {
|
||||
worktree_root_name, ..
|
||||
} => !scope.is_empty() && worktree_root_name.as_ref() == scope,
|
||||
|
|
@ -211,6 +248,7 @@ pub fn parse_skill_frontmatter(
|
|||
directory_path,
|
||||
skill_file_path: skill_file_path.to_path_buf(),
|
||||
disable_model_invocation: metadata.disable_model_invocation,
|
||||
embedded_body: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -600,6 +638,53 @@ pub async fn read_skill_body(
|
|||
Ok(body.trim().to_string())
|
||||
}
|
||||
|
||||
/// Content of the built-in `create-skill` SKILL.md, embedded at compile time.
|
||||
const CREATE_SKILL_CONTENT: &str = include_str!("builtin/create-skill/SKILL.md");
|
||||
|
||||
/// Returns the set of skills that are compiled into the Zed binary.
|
||||
pub fn builtin_skills() -> Vec<Skill> {
|
||||
let mut skills = Vec::new();
|
||||
if let Ok(skill) = parse_builtin_skill("create-skill", CREATE_SKILL_CONTENT) {
|
||||
skills.push(skill);
|
||||
}
|
||||
skills
|
||||
}
|
||||
|
||||
/// Parse a built-in skill from its embedded SKILL.md content. The skill
|
||||
/// gets a synthetic `<built-in>` path since it doesn't live on disk.
|
||||
fn parse_builtin_skill(name: &str, content: &'static str) -> Result<Skill> {
|
||||
let (metadata, body) = extract_frontmatter(content)?;
|
||||
validate_name(&metadata.name)?;
|
||||
validate_description(&metadata.description)?;
|
||||
|
||||
let synthetic_dir = PathBuf::from(format!("<built-in>/{}", name));
|
||||
let synthetic_path = synthetic_dir.join(SKILL_FILE_NAME);
|
||||
|
||||
Ok(Skill {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
source: SkillSource::BuiltIn,
|
||||
directory_path: synthetic_dir,
|
||||
skill_file_path: synthetic_path,
|
||||
disable_model_invocation: metadata.disable_model_invocation,
|
||||
embedded_body: Some(body.trim()),
|
||||
})
|
||||
}
|
||||
|
||||
/// All built-in skills as `(name, raw_content)` pairs. Used by
|
||||
/// `builtin_skill_content` to serve the full SKILL.md without disk I/O.
|
||||
const BUILTIN_SKILL_ENTRIES: &[(&str, &str)] = &[("create-skill", CREATE_SKILL_CONTENT)];
|
||||
|
||||
/// Look up the full embedded content of a built-in skill by its
|
||||
/// synthetic file path. Returns `None` if the path doesn't match any
|
||||
/// built-in skill.
|
||||
pub fn builtin_skill_content(skill_file_path: &Path) -> Option<&'static str> {
|
||||
BUILTIN_SKILL_ENTRIES.iter().find_map(|(name, content)| {
|
||||
let expected = PathBuf::from(format!("<built-in>/{}", name)).join(SKILL_FILE_NAME);
|
||||
(expected == skill_file_path).then_some(*content)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the global skills directory: `~/.agents/skills`.
|
||||
///
|
||||
/// Other agents (e.g. Claude Code) already write skill files into this
|
||||
|
|
@ -663,6 +748,34 @@ mod tests {
|
|||
use fs::FakeFs;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[test]
|
||||
fn test_skill_source_precedence_is_total_and_ordered() {
|
||||
// Pin the hierarchy: project-local > global > built-in. Every
|
||||
// override and conflict-resolution site routes through this,
|
||||
// so the rest of the codebase relies on it being correct.
|
||||
let built_in = SkillSource::BuiltIn.precedence();
|
||||
let global = SkillSource::Global.precedence();
|
||||
let project = SkillSource::ProjectLocal {
|
||||
worktree_id: SkillScopeId(1),
|
||||
worktree_root_name: "my-project".into(),
|
||||
}
|
||||
.precedence();
|
||||
|
||||
assert!(built_in < global, "global must shadow built-in");
|
||||
assert!(global < project, "project-local must shadow global");
|
||||
|
||||
// Two project-local skills from different worktrees tie. The
|
||||
// "first wins" convention is enforced by the callers, but the
|
||||
// precedence itself must be equal so neither silently shadows
|
||||
// the other.
|
||||
let other_project = SkillSource::ProjectLocal {
|
||||
worktree_id: SkillScopeId(2),
|
||||
worktree_root_name: "other-project".into(),
|
||||
}
|
||||
.precedence();
|
||||
assert_eq!(project, other_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_skill() {
|
||||
let content = r#"---
|
||||
|
|
@ -1532,6 +1645,7 @@ description: A skill with no body content
|
|||
directory_path: PathBuf::from("/skills/test-skill"),
|
||||
skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
|
||||
let summary = SkillSummary::from(&skill);
|
||||
|
|
|
|||
95
crates/agent_skills/builtin/create-skill/SKILL.md
Normal file
95
crates/agent_skills/builtin/create-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
name: create-skill
|
||||
description: Helps users create new agent skills for Zed. Use this when a user wants to create a skill, asks about SKILL.md structure, or wants to package reusable agent instructions.
|
||||
---
|
||||
|
||||
# Creating a Zed Agent Skill
|
||||
|
||||
Use this skill when the user wants to create, edit, or understand agent skills in Zed.
|
||||
|
||||
## What is a Skill?
|
||||
|
||||
A skill is a reusable set of instructions that an agent can load on demand. Each skill lives in its own directory and is defined by a `SKILL.md` file with YAML frontmatter.
|
||||
|
||||
## Where Skills Live
|
||||
|
||||
Skills can be placed in two locations:
|
||||
|
||||
| Scope | Path | When to use |
|
||||
|-------|------|-------------|
|
||||
| Global | `~/.agents/skills/<skill-name>/SKILL.md` | Personal skills, available in all projects |
|
||||
| Project-local | `<project>/.agents/skills/<skill-name>/SKILL.md` | Project-specific skills, shared with collaborators through version control |
|
||||
|
||||
Prefer project-local when the skill is specific to a repository. Prefer global when the skill is a personal workflow the user wants everywhere.
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
Every `SKILL.md` must start with YAML frontmatter between `---` delimiters:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill-name
|
||||
description: A clear, specific description of what this skill does and when to use it.
|
||||
---
|
||||
|
||||
# Skill Title
|
||||
|
||||
Instructions for the agent go here. Write them as if you're telling the agent
|
||||
what to do when this skill is activated.
|
||||
```
|
||||
|
||||
### Required Frontmatter Fields
|
||||
|
||||
- **`name`** (required): Must be 1–64 characters, lowercase alphanumeric with single-hyphen separators. Must match the containing directory name exactly. Regex: `^[a-z0-9]+(-[a-z0-9]+)*$`
|
||||
- **`description`** (required): Must be 1–1024 characters. This is what the agent sees when deciding whether to use the skill — make it specific and actionable.
|
||||
|
||||
### Optional Frontmatter Fields
|
||||
|
||||
- **`disable-model-invocation`**: When set to `true`, the skill is hidden from the agent's automatic catalog. The user can still invoke it manually via the `/` slash command menu. Useful for skills that should only run when explicitly requested.
|
||||
|
||||
## Naming Rules
|
||||
|
||||
The skill name must:
|
||||
- Be lowercase letters and numbers only, with single hyphens as separators
|
||||
- Not start or end with `-`
|
||||
- Not contain consecutive `--`
|
||||
- Match the directory name that contains the `SKILL.md`
|
||||
|
||||
Good: `git-release`, `pr-review`, `rust-patterns`
|
||||
Bad: `Git-Release`, `pr--review`, `-my-skill`, `my_skill`
|
||||
|
||||
## Writing Good Skill Instructions
|
||||
|
||||
The body of the SKILL.md (after the frontmatter) contains the instructions the agent will follow. Guidelines:
|
||||
|
||||
1. **Be direct**: Write instructions as if talking to the agent. "Do X", "Check Y", "Ask the user about Z".
|
||||
2. **Be specific**: Include concrete file paths, commands, formats, and patterns.
|
||||
3. **Include when-to-use guidance**: Help the agent understand the right context for this skill.
|
||||
4. **Reference supporting files**: Skills can include additional files in their directory. Reference them with relative paths (e.g., `templates/component.tsx`). The agent can read these files when the skill is activated.
|
||||
5. **Keep descriptions actionable**: The `description` field is the agent's primary signal for whether to load this skill. "Helps with code" is too vague. "Generate React components following the project's design system patterns" is specific.
|
||||
|
||||
## Supporting Files
|
||||
|
||||
A skill directory can contain additional files beyond `SKILL.md`:
|
||||
|
||||
```
|
||||
~/.agents/skills/react-component/
|
||||
├── SKILL.md
|
||||
├── templates/
|
||||
│ ├── component.tsx
|
||||
│ └── test.tsx
|
||||
└── examples/
|
||||
└── button.tsx
|
||||
```
|
||||
|
||||
Reference these in the skill body. The agent can read them using the file path shown in the `<directory>` tag of the skill envelope.
|
||||
|
||||
## Step-by-Step: Creating a Skill
|
||||
|
||||
1. Decide on scope (global vs project-local) based on the user's needs.
|
||||
2. Choose a descriptive, hyphenated name.
|
||||
3. Create the directory structure.
|
||||
4. Write the `SKILL.md` with frontmatter and instructions.
|
||||
5. Optionally add supporting files (templates, examples, references).
|
||||
|
||||
After creating the skill, it will be automatically discovered by Zed's agent on the next conversation (no restart needed for global skills if the `~/.agents/skills/` directory already exists).
|
||||
|
|
@ -491,6 +491,14 @@ impl MentionSet {
|
|||
skill_file_path: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Mention>> {
|
||||
// Built-in skills have synthetic paths that don't exist on disk;
|
||||
// serve their content directly from the compiled-in data.
|
||||
if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) {
|
||||
return Task::ready(Ok(Mention::Text {
|
||||
content: content.to_string(),
|
||||
tracked_buffers: Vec::new(),
|
||||
}));
|
||||
}
|
||||
cx.background_spawn(async move {
|
||||
let content = std::fs::read_to_string(&skill_file_path).map_err(|e| {
|
||||
anyhow!(
|
||||
|
|
|
|||
|
|
@ -203,6 +203,43 @@ fn open_skill_file(
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
// Built-in skills have synthetic paths that don't exist on disk.
|
||||
// Open a read-only buffer with the embedded content instead.
|
||||
if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) {
|
||||
let project = workspace.project().clone();
|
||||
let languages = project.read(cx).languages().clone();
|
||||
let buffer = project.update(cx, |project, cx| {
|
||||
project.create_local_buffer(content, None, false, cx)
|
||||
});
|
||||
// Set markdown highlighting asynchronously — the buffer
|
||||
// opens instantly and the highlighting appears once loaded.
|
||||
cx.spawn({
|
||||
let buffer = buffer.clone();
|
||||
async move |_, cx| {
|
||||
if let Ok(markdown) = languages.language_for_name("Markdown").await {
|
||||
buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx));
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
let editor = cx.new(|cx| {
|
||||
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
||||
editor.set_read_only(true);
|
||||
let title = skill_file_path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "built-in skill".into());
|
||||
editor
|
||||
.buffer()
|
||||
.update(cx, |buffer, cx| buffer.set_title(title, cx));
|
||||
editor
|
||||
});
|
||||
let pane = workspace.active_pane().clone();
|
||||
workspace.add_item(pane, Box::new(editor), None, true, true, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace
|
||||
.open_abs_path(
|
||||
skill_file_path,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ mod tests {
|
|||
directory_path: PathBuf::from("/skills/oversized"),
|
||||
skill_file_path: PathBuf::from("/skills/oversized/SKILL.md"),
|
||||
disable_model_invocation: false,
|
||||
embedded_body: None,
|
||||
};
|
||||
let summary = SkillSummary::from(&skill);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue