feat: implement Agent Skills specification support
Implements the Agent Skills specification (https://agentskills.io) for portable skill packages that give the agent new capabilities. Changes: - Add skills module with SKILL.md parser (YAML frontmatter + markdown body) - Implement skill discovery from ~/.g3/skills/, config extra_paths, and .g3/skills/ - Generate <available_skills> XML for system prompt injection - Add SkillsConfig to g3-config with enabled flag and extra_paths - Wire skills discovery into CLI startup - Add 29 unit tests for parser, discovery, and prompt generation - Update README with Agent Skills documentation Skill locations (priority order): 1. ~/.g3/skills/ (global) 2. Config extra_paths 3. .g3/skills/ (workspace, highest priority) At startup, g3 scans skill directories and injects a summary into the system prompt. When the agent needs a skill, it reads the full SKILL.md using the read_file tool.
This commit is contained in:
140
crates/g3-core/src/skills/prompt.rs
Normal file
140
crates/g3-core/src/skills/prompt.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Generate XML prompt section for available skills.
|
||||
//!
|
||||
//! Creates the `<available_skills>` XML block that gets injected into
|
||||
//! the system prompt to inform the agent about available skills.
|
||||
|
||||
use super::parser::Skill;
|
||||
|
||||
/// Generate the XML section for available skills.
|
||||
///
|
||||
/// Returns an empty string if no skills are available.
|
||||
/// The XML format follows the Agent Skills specification.
|
||||
pub fn generate_skills_prompt(skills: &[Skill]) -> String {
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut xml = String::new();
|
||||
xml.push_str("# Available Skills\n\n");
|
||||
xml.push_str("You have access to the following skills. When a task matches a skill's description, \
|
||||
read the full skill file using `read_file` to get detailed instructions.\n\n");
|
||||
xml.push_str("<available_skills>\n");
|
||||
|
||||
for skill in skills {
|
||||
xml.push_str(" <skill>\n");
|
||||
xml.push_str(&format!(" <name>{}</name>\n", escape_xml(&skill.name)));
|
||||
xml.push_str(&format!(" <description>{}</description>\n", escape_xml(&skill.description)));
|
||||
xml.push_str(&format!(" <location>{}</location>\n", escape_xml(&skill.path)));
|
||||
|
||||
// Include compatibility info if present
|
||||
if let Some(ref compat) = skill.compatibility {
|
||||
xml.push_str(&format!(" <compatibility>{}</compatibility>\n", escape_xml(compat)));
|
||||
}
|
||||
|
||||
xml.push_str(" </skill>\n");
|
||||
}
|
||||
|
||||
xml.push_str("</available_skills>\n");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Escape special XML characters.
|
||||
fn escape_xml(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_skill(name: &str, description: &str, path: &str) -> Skill {
|
||||
Skill {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
license: None,
|
||||
compatibility: None,
|
||||
metadata: None,
|
||||
allowed_tools: None,
|
||||
body: String::new(),
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_skills() {
|
||||
let result = generate_skills_prompt(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_skill() {
|
||||
let skills = vec![
|
||||
make_skill("pdf-processing", "Extract text from PDFs", "/home/user/.g3/skills/pdf-processing/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<available_skills>"));
|
||||
assert!(result.contains("</available_skills>"));
|
||||
assert!(result.contains("<name>pdf-processing</name>"));
|
||||
assert!(result.contains("<description>Extract text from PDFs</description>"));
|
||||
assert!(result.contains("<location>/home/user/.g3/skills/pdf-processing/SKILL.md</location>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_skills() {
|
||||
let skills = vec![
|
||||
make_skill("skill-a", "First skill", "/path/a/SKILL.md"),
|
||||
make_skill("skill-b", "Second skill", "/path/b/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<name>skill-a</name>"));
|
||||
assert!(result.contains("<name>skill-b</name>"));
|
||||
// Should have exactly 2 skill blocks
|
||||
assert_eq!(result.matches("<skill>").count(), 2);
|
||||
assert_eq!(result.matches("</skill>").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xml_escaping() {
|
||||
let skills = vec![
|
||||
make_skill("test-skill", "Handle <special> & \"characters\"", "/path/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("<special>"));
|
||||
assert!(result.contains("&"));
|
||||
assert!(result.contains(""characters""));
|
||||
// Should not contain unescaped special chars in description
|
||||
assert!(!result.contains("<special>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_compatibility() {
|
||||
let mut skill = make_skill("docker-skill", "Docker operations", "/path/SKILL.md");
|
||||
skill.compatibility = Some("Requires Docker 20.0+".to_string());
|
||||
|
||||
let result = generate_skills_prompt(&[skill]);
|
||||
|
||||
assert!(result.contains("<compatibility>Requires Docker 20.0+</compatibility>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_text() {
|
||||
let skills = vec![
|
||||
make_skill("test", "Test skill", "/path/SKILL.md"),
|
||||
];
|
||||
|
||||
let result = generate_skills_prompt(&skills);
|
||||
|
||||
assert!(result.contains("# Available Skills"));
|
||||
assert!(result.contains("read the full skill file using `read_file`"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user