From f2c60f3dbfa8103d49c0a178908c2a18bcae146b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sun, 31 May 2026 16:14:59 +0000 Subject: [PATCH 1/3] fix(openab-agent): use XML format for skills prompt Align format_skills_prompt with industry standard used by Pi, OpenCode, OpenClaw, and Hermes. Changes: - Switch from markdown bullet list to XML structure - Add explicit , , elements - Strengthen instruction text to mandate skill scanning before reply - Update test to verify XML format output --- openab-agent/src/skills.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 5f1383284..8a9cb5de3 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -104,15 +104,21 @@ 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"); + let mut out = String::from(concat!( + "\n\n## Skills\n\n", + "Before replying, scan the skills below. If one clearly matches 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 skills { out.push_str(&format!( - "- **{}** ({}): {}\n", + " \n {}\n {}\n {}\n \n", skill.name, + skill.description, skill.path.join("SKILL.md").display(), - skill.description )); } + out.push_str("\n"); out } @@ -210,15 +216,18 @@ 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")); } } From ad0fa9825e379885235441b62c4155bee362938d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Sun, 31 May 2026 16:19:59 +0000 Subject: [PATCH 2/3] fix: soften skill matching wording to reduce false negatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change 'clearly matches' to 'matches or may be relevant' per review feedback from 普渡法師. This avoids the agent being too conservative and missing edge-case skill triggers. --- openab-agent/src/skills.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 8a9cb5de3..1da432759 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -106,7 +106,7 @@ pub fn format_skills_prompt(skills: &[Skill]) -> String { } let mut out = String::from(concat!( "\n\n## Skills\n\n", - "Before replying, scan the skills below. If one clearly matches the user's task, ", + "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", )); From 4902d25f1998a769dcff94738ac8edbc4d61bdcf Mon Sep 17 00:00:00 2001 From: shaun-agent Date: Mon, 1 Jun 2026 16:44:50 +0800 Subject: [PATCH 3/3] fix(openab-agent): escape XML special chars in skills prompt (#963) Merge XML escaping follow-up for PR #958. Includes escaping for skill name, description, and location text nodes, deterministic skill ordering, and regression tests for path escaping and closing-tag injection. --- openab-agent/src/skills.rs | 53 +++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/openab-agent/src/skills.rs b/openab-agent/src/skills.rs index 1da432759..f228c7efb 100644 --- a/openab-agent/src/skills.rs +++ b/openab-agent/src/skills.rs @@ -99,23 +99,35 @@ 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(); } + + // 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 skills { + for skill in sorted_skills { out.push_str(&format!( " \n {}\n {}\n {}\n \n", - skill.name, - skill.description, - skill.path.join("SKILL.md").display(), + xml_escape(&skill.name), + xml_escape(&skill.description), + xml_escape(&skill.path.join("SKILL.md").to_string_lossy()), )); } out.push_str("\n"); @@ -230,4 +242,37 @@ mod tests { 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); + } }