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";
|
const SHELL_ENV_SENTINEL: &[u8] = b"\0DS4_ENV\0";
|
||||||
static USER_SHELL_ENVIRONMENT: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
|
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 {
|
fn user_shell() -> OsString {
|
||||||
std::env::var_os("SHELL").unwrap_or_else(|| OsString::from("/bin/sh"))
|
std::env::var_os("SHELL").unwrap_or_else(|| OsString::from("/bin/sh"))
|
||||||
}
|
}
|
||||||
@@ -1451,6 +1553,7 @@ impl Drop for RalphRoundDirectory {
|
|||||||
|
|
||||||
pub(crate) struct Tools {
|
pub(crate) struct Tools {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
agent_skill_roots: Vec<PathBuf>,
|
||||||
context_tokens: i32,
|
context_tokens: i32,
|
||||||
more: Option<(PathBuf, usize, bool)>,
|
more: Option<(PathBuf, usize, bool)>,
|
||||||
more_text: Option<(String, usize)>,
|
more_text: Option<(String, usize)>,
|
||||||
@@ -1469,6 +1572,19 @@ impl Tools {
|
|||||||
root: root
|
root: root
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|error| format!("Could not open the project directory: {error}"))?,
|
.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,
|
context_tokens,
|
||||||
more: None,
|
more: None,
|
||||||
more_text: None,
|
more_text: None,
|
||||||
@@ -1993,6 +2109,13 @@ impl Tools {
|
|||||||
if path.starts_with(&self.root) {
|
if path.starts_with(&self.root) {
|
||||||
return Ok(path);
|
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
|
if let Some(brain) = &self.dev_brain
|
||||||
&& let Ok(relative) = path.strip_prefix(brain.folder())
|
&& let Ok(relative) = path.strip_prefix(brain.folder())
|
||||||
&& !relative
|
&& !relative
|
||||||
@@ -2002,7 +2125,7 @@ impl Tools {
|
|||||||
return Ok(path);
|
return Ok(path);
|
||||||
}
|
}
|
||||||
Err(format!(
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn prompts_and_parsers_expose_the_reference_tool_set() {
|
fn prompts_and_parsers_expose_the_reference_tool_set() {
|
||||||
let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra", false);
|
let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra", false);
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ impl App {
|
|||||||
prompt.push_str("\n\n");
|
prompt.push_str("\n\n");
|
||||||
prompt.push_str(agents);
|
prompt.push_str(agents);
|
||||||
}
|
}
|
||||||
|
if let Some(skills) = crate::agent::agent_skills_prompt() {
|
||||||
|
prompt.push_str("\n\n");
|
||||||
|
prompt.push_str(&skills);
|
||||||
|
}
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,6 +729,7 @@ impl App {
|
|||||||
if let Some(skills) = self.dev_brain_skills_prompt() {
|
if let Some(skills) = self.dev_brain_skills_prompt() {
|
||||||
reminders.push(skills);
|
reminders.push(skills);
|
||||||
}
|
}
|
||||||
|
reminders.extend(crate::agent::agent_skills_prompt());
|
||||||
if self.config.a2ui_enabled {
|
if self.config.a2ui_enabled {
|
||||||
reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned());
|
reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned());
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1296,10 @@ impl App {
|
|||||||
child_turn.system_prompt.push_str("\n\n");
|
child_turn.system_prompt.push_str("\n\n");
|
||||||
child_turn.system_prompt.push_str(&skills);
|
child_turn.system_prompt.push_str(&skills);
|
||||||
}
|
}
|
||||||
|
if let Some(skills) = crate::agent::agent_skills_prompt() {
|
||||||
|
child_turn.system_prompt.push_str("\n\n");
|
||||||
|
child_turn.system_prompt.push_str(&skills);
|
||||||
|
}
|
||||||
tools
|
tools
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
|||||||
Reference in New Issue
Block a user