From c0681dedb92fd788cf67c7aecf8b60e3682343b5 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sat, 29 Aug 2026 21:12:40 +0200 Subject: [PATCH] Integrate standard agent skills --- src/agent.rs | 236 +++++++++++++++++++++++++++++++++++++++++- src/app/generation.rs | 9 ++ 2 files changed, 244 insertions(+), 1 deletion(-) diff --git a/src/agent.rs b/src/agent.rs index 391ccc2..56c8991 100644 --- a/src/agent.rs +++ b/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> = 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 { + 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 { + 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::(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::>(); + skills.sort_by(|left, right| left.name.cmp(&right.name)); + skills +} + +fn agent_skills_prompt_for(root: &Path) -> Option { + 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::>() + .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 { + 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, 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::>(); + 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); diff --git a/src/app/generation.rs b/src/app/generation.rs index 9a1b4f6..7ba4296 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -412,6 +412,10 @@ impl App { prompt.push_str("\n\n"); prompt.push_str(agents); } + if let Some(skills) = crate::agent::agent_skills_prompt() { + prompt.push_str("\n\n"); + prompt.push_str(&skills); + } prompt } @@ -725,6 +729,7 @@ impl App { if let Some(skills) = self.dev_brain_skills_prompt() { reminders.push(skills); } + reminders.extend(crate::agent::agent_skills_prompt()); if self.config.a2ui_enabled { 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(&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 .lock() .unwrap_or_else(|poisoned| poisoned.into_inner())