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); } }