Integrate standard agent skills
This commit is contained in:
236
src/agent.rs
236
src/agent.rs
@@ -34,6 +34,108 @@ const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SHELL_ENV_SENTINEL: &[u8] = b"\0DS4_ENV\0";
|
||||
static USER_SHELL_ENVIRONMENT: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentSkillMetadata {
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
struct AgentSkill {
|
||||
name: String,
|
||||
description: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn agent_skills_root(home: Option<&Path>) -> Option<PathBuf> {
|
||||
home.map(|home| home.join(".agents/skills"))
|
||||
}
|
||||
|
||||
fn valid_skill_name(name: &str) -> bool {
|
||||
(1..=64).contains(&name.len())
|
||||
&& !name.starts_with('-')
|
||||
&& !name.ends_with('-')
|
||||
&& !name.contains("--")
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
fn skill_frontmatter(content: &str) -> Option<&str> {
|
||||
let content = content.strip_prefix("---")?;
|
||||
let content = content
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| content.strip_prefix("\r\n"))?;
|
||||
let end = content
|
||||
.match_indices("\n---")
|
||||
.find(|(index, _)| {
|
||||
content.get(index + 4..).is_some_and(|tail| {
|
||||
tail.is_empty() || tail.starts_with('\n') || tail.starts_with("\r\n")
|
||||
})
|
||||
})?
|
||||
.0;
|
||||
Some(content[..end].trim_end_matches('\r'))
|
||||
}
|
||||
|
||||
fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(&root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut skills = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let directory_name = entry.file_name().into_string().ok()?;
|
||||
let path = entry.path().join("SKILL.md").canonicalize().ok()?;
|
||||
let content = fs::read_to_string(&path).ok()?;
|
||||
let metadata =
|
||||
serde_norway::from_str::<AgentSkillMetadata>(skill_frontmatter(&content)?).ok()?;
|
||||
if metadata.name != directory_name
|
||||
|| !valid_skill_name(&metadata.name)
|
||||
|| metadata.description.trim().is_empty()
|
||||
|| !(1..=1024).contains(&metadata.description.chars().count())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(AgentSkill {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
path,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
skills.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
skills
|
||||
}
|
||||
|
||||
fn agent_skills_prompt_for(root: &Path) -> Option<String> {
|
||||
let skills = discover_agent_skills(root);
|
||||
if skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let catalog = skills
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
serde_json::json!({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"location": skill.path,
|
||||
})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Some(format!(
|
||||
"# Available agent skills\n\nWhen a task matches a skill description, use the read tool to read its complete SKILL.md before proceeding. Resolve relative paths from the directory containing SKILL.md and read referenced resources only as needed.\n\n{catalog}"
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn agent_skills_prompt() -> Option<String> {
|
||||
let root = agent_skills_root(std::env::var_os("HOME").as_deref().map(Path::new))?;
|
||||
agent_skills_prompt_for(&root)
|
||||
}
|
||||
|
||||
fn user_shell() -> OsString {
|
||||
std::env::var_os("SHELL").unwrap_or_else(|| OsString::from("/bin/sh"))
|
||||
}
|
||||
@@ -1451,6 +1553,7 @@ impl Drop for RalphRoundDirectory {
|
||||
|
||||
pub(crate) struct Tools {
|
||||
root: PathBuf,
|
||||
agent_skill_roots: Vec<PathBuf>,
|
||||
context_tokens: i32,
|
||||
more: Option<(PathBuf, usize, bool)>,
|
||||
more_text: Option<(String, usize)>,
|
||||
@@ -1469,6 +1572,19 @@ impl Tools {
|
||||
root: root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("Could not open the project directory: {error}"))?,
|
||||
agent_skill_roots: agent_skills_root(
|
||||
std::env::var_os("HOME").as_deref().map(Path::new),
|
||||
)
|
||||
.map(|root| {
|
||||
let mut roots = discover_agent_skills(&root)
|
||||
.into_iter()
|
||||
.filter_map(|skill| skill.path.parent().map(Path::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
context_tokens,
|
||||
more: None,
|
||||
more_text: None,
|
||||
@@ -1993,6 +2109,13 @@ impl Tools {
|
||||
if path.starts_with(&self.root) {
|
||||
return Ok(path);
|
||||
}
|
||||
if self
|
||||
.agent_skill_roots
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root))
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
if let Some(brain) = &self.dev_brain
|
||||
&& let Ok(relative) = path.strip_prefix(brain.folder())
|
||||
&& !relative
|
||||
@@ -2002,7 +2125,7 @@ impl Tools {
|
||||
return Ok(path);
|
||||
}
|
||||
Err(format!(
|
||||
"path is outside the project and managed Dev Brain folder: {original}"
|
||||
"path is outside the project, agent skills, and managed Dev Brain folder: {original}"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -3453,6 +3576,117 @@ fn boolean(call: &ToolCall, name: &str, default: bool) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_agent_skills_are_discovered_prompted_and_read_only() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"ds4-agent-skills-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let project = directory.join("project");
|
||||
let skills_root = directory.join("home/.agents/skills");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
fs::create_dir_all(&skills_root).unwrap();
|
||||
assert!(agent_skills_prompt_for(&skills_root).is_none());
|
||||
|
||||
let allium = skills_root.join("allium");
|
||||
fs::create_dir(&allium).unwrap();
|
||||
fs::write(
|
||||
allium.join("SKILL.md"),
|
||||
"---\r\nname: allium\r\ndescription: Tend Allium specifications\r\nlicense: MIT\r\n---\r\nSECRET FULL INSTRUCTIONS\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(allium.join("reference.md"), "skill reference\n").unwrap();
|
||||
let invalid = skills_root.join("wrong-directory");
|
||||
fs::create_dir(&invalid).unwrap();
|
||||
fs::write(
|
||||
invalid.join("SKILL.md"),
|
||||
"---\nname: different-name\ndescription: Invalid mismatch\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
let linked_source = directory.join("linked-source");
|
||||
fs::create_dir(&linked_source).unwrap();
|
||||
fs::write(
|
||||
linked_source.join("SKILL.md"),
|
||||
"---\nname: linked\ndescription: Follow a linked skill\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(linked_source.join("reference.md"), "linked reference\n").unwrap();
|
||||
std::os::unix::fs::symlink(&linked_source, skills_root.join("linked")).unwrap();
|
||||
|
||||
let skills = discover_agent_skills(&skills_root);
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert_eq!(skills[0].name, "allium");
|
||||
let prompt = agent_skills_prompt_for(&skills_root).unwrap();
|
||||
assert!(prompt.contains("Tend Allium specifications"));
|
||||
assert!(prompt.contains(allium.join("SKILL.md").to_str().unwrap()));
|
||||
assert!(prompt.contains("read its complete SKILL.md"));
|
||||
assert!(prompt.contains("Follow a linked skill"));
|
||||
assert!(!prompt.contains("SECRET FULL INSTRUCTIONS"));
|
||||
assert!(!prompt.contains("different-name"));
|
||||
|
||||
let mut tools = Tools::new(&project, 4096).unwrap();
|
||||
tools.agent_skill_roots = skills
|
||||
.iter()
|
||||
.map(|skill| skill.path.parent().unwrap().to_owned())
|
||||
.collect();
|
||||
let cancel = AtomicBool::new(false);
|
||||
let reference = allium.join("reference.md");
|
||||
assert!(
|
||||
tools
|
||||
.execute(
|
||||
&call("read", [("path", reference.to_str().unwrap())]),
|
||||
&cancel,
|
||||
)
|
||||
.contains("skill reference")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.execute(
|
||||
&call(
|
||||
"read",
|
||||
[("path", linked_source.join("reference.md").to_str().unwrap())],
|
||||
),
|
||||
&cancel,
|
||||
)
|
||||
.contains("linked reference")
|
||||
);
|
||||
let created = allium.join("created.md");
|
||||
assert!(
|
||||
tools
|
||||
.execute(
|
||||
&call(
|
||||
"write",
|
||||
[
|
||||
("path", created.to_str().unwrap()),
|
||||
("content", "must not be written"),
|
||||
],
|
||||
),
|
||||
&cancel,
|
||||
)
|
||||
.contains("not a managed project or Dev Brain file")
|
||||
);
|
||||
assert!(!created.exists());
|
||||
|
||||
let outside = directory.join("outside.md");
|
||||
fs::write(&outside, "outside\n").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, allium.join("escape.md")).unwrap();
|
||||
assert!(
|
||||
tools
|
||||
.execute(
|
||||
&call(
|
||||
"read",
|
||||
[("path", allium.join("escape.md").to_str().unwrap())],
|
||||
),
|
||||
&cancel,
|
||||
)
|
||||
.contains("outside the project, agent skills")
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_and_parsers_expose_the_reference_tool_set() {
|
||||
let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra", false);
|
||||
|
||||
Reference in New Issue
Block a user