diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs
index 5f1383284..f228c7efb 100644
--- a/openab-agent/src/skills.rs
+++ b/openab-agent/src/skills.rs
@@ -99,20 +99,38 @@ fn parse_frontmatter(content: &str) -> Option<(String, String)> {
Some((name, description))
}
+/// Escape XML special characters in user-controlled strings to preserve prompt structure.
+fn xml_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+}
+
/// Format skills as a system prompt section listing available skills.
pub fn format_skills_prompt(skills: &[Skill]) -> String {
if skills.is_empty() {
return String::new();
}
- let mut out = String::from("\n\n## Available Skills\n\nThe following skills are available. Use the `read` tool to load the full SKILL.md when you need a skill's instructions.\n\n");
- for skill in skills {
+
+ // Sort skills by name to ensure deterministic output
+ let mut sorted_skills = skills.to_vec();
+ sorted_skills.sort_by(|a, b| a.name.cmp(&b.name));
+
+ let mut out = String::from(concat!(
+ "\n\n## Skills\n\n",
+ "Before replying, scan the skills below. If one matches or may be relevant to the user's task, ",
+ "use the `read` tool to load the full SKILL.md at the listed location and follow its instructions.\n\n",
+ "\n",
+ ));
+ for skill in sorted_skills {
out.push_str(&format!(
- "- **{}** ({}): {}\n",
- skill.name,
- skill.path.join("SKILL.md").display(),
- skill.description
+ " \n {}\n {}\n {}\n \n",
+ xml_escape(&skill.name),
+ xml_escape(&skill.description),
+ xml_escape(&skill.path.join("SKILL.md").to_string_lossy()),
));
}
+ out.push_str("\n");
out
}
@@ -210,15 +228,51 @@ mod tests {
}
#[test]
- fn format_skills_prompt_includes_path() {
+ fn format_skills_prompt_uses_xml_format() {
let skills = vec![Skill {
name: "test".to_string(),
description: "A test skill".to_string(),
path: PathBuf::from("/home/agent/.openab/skills/test"),
}];
let prompt = format_skills_prompt(&skills);
- assert!(prompt.contains("test"));
- assert!(prompt.contains("A test skill"));
- assert!(prompt.contains("SKILL.md"));
+ assert!(prompt.contains(""));
+ assert!(prompt.contains("test"));
+ assert!(prompt.contains("A test skill"));
+ assert!(prompt.contains("/home/agent/.openab/skills/test/SKILL.md"));
+ assert!(prompt.contains(""));
+ assert!(prompt.contains("Before replying, scan the skills below"));
+ }
+
+ #[test]
+ fn format_skills_prompt_escapes_xml_chars() {
+ let skills = vec![Skill {
+ name: "a & \"quotes\" injection test".to_string(),
+ path: PathBuf::from("/skills/test&cool"),
+ }];
+ let prompt = format_skills_prompt(&skills);
+ assert!(prompt.contains("a<b"));
+ assert!(prompt.contains("Use <tag> & \"quotes\" </description> injection test"));
+ assert!(prompt.contains("/skills/test&cool/SKILL.md"));
+ }
+
+ #[test]
+ fn format_skills_prompt_is_deterministic() {
+ let skills = vec![
+ Skill {
+ name: "zoo".to_string(),
+ description: "Zoo skill".to_string(),
+ path: PathBuf::from("/skills/zoo"),
+ },
+ Skill {
+ name: "apple".to_string(),
+ description: "Apple skill".to_string(),
+ path: PathBuf::from("/skills/apple"),
+ },
+ ];
+ let prompt = format_skills_prompt(&skills);
+ let apple_idx = prompt.find("apple").unwrap();
+ let zoo_idx = prompt.find("zoo").unwrap();
+ assert!(apple_idx < zoo_idx);
}
}