Skip to main content

vtcode_core/skills/
prompt_integration.rs

1//! Skills prompt integration
2//!
3//! Dynamically injects available skills information into system prompt,
4//! similar to OpenAI Codex's approach.
5
6use crate::skills::model::{SkillMetadata, SkillScope};
7use std::fmt::Write;
8
9// Re-export PromptFormat from config for consistency
10pub use vtcode_config::core::skills::PromptFormat;
11
12/// Rendering mode for skills section
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum SkillsRenderMode {
15    /// Full metadata (currently same core fields as lean mode).
16    Full,
17    /// Lean mode: only name + description + file path (Codex-style, 40-60% token savings)
18    #[default]
19    Lean,
20}
21
22/// Usage rules embedded in skills section (Codex pattern)
23const SKILL_USAGE_RULES: &str = r#"
24**Usage Rules:**
25- **Discovery**: Skills listed above (name + description + file path)
26- **Trigger**: Use skill if user mentions `$SkillName` OR task matches description
27- **Progressive disclosure**:
28  1. Open SKILL.md to get full instructions
29  2. Load referenced files (scripts/, references/) only if needed
30  3. Prefer running existing scripts vs. retyping code
31- **Missing/blocked**: State issue briefly and continue with fallback approach
32- **Routing**: Treat `description` as the primary trigger signal
33"#;
34
35/// Generate skills section for system prompt (full mode - backward compatible)
36pub fn generate_skills_prompt(skills: &[SkillMetadata]) -> String {
37    generate_skills_prompt_with_mode(skills, SkillsRenderMode::Full)
38}
39
40/// Generate skills section with specified rendering mode
41pub fn generate_skills_prompt_with_mode(
42    skills: &[SkillMetadata],
43    mode: SkillsRenderMode,
44) -> String {
45    if skills.is_empty() {
46        return String::new();
47    }
48
49    match mode {
50        SkillsRenderMode::Full => render_skills_full(skills),
51        SkillsRenderMode::Lean => render_skills_lean(skills),
52    }
53}
54
55/// Render skills in full mode.
56fn render_skills_full(skills: &[SkillMetadata]) -> String {
57    render_skills_lean(skills)
58}
59
60/// Render skills in lean mode (Codex-style: name + description + path only)
61///
62/// This keeps only the metadata required by the strict SKILL.md spec.
63fn render_skills_lean(skills: &[SkillMetadata]) -> String {
64    let mut prompt = String::from("\n\n## Skills\n");
65    prompt.push_str(
66        "Available skills (name: description + directory + scope). Content on disk; open SKILL.md when triggered.\n\n",
67	);
68
69    // Sort skills by name for stable ordering
70    let mut skill_list: Vec<_> = skills.iter().collect();
71    skill_list.sort_by_key(|skill| &skill.name);
72
73    // Show up to 10 skills to keep prompt lean
74    let overflow = skill_list.len().saturating_sub(10);
75    if overflow > 0 {
76        skill_list.truncate(10);
77    }
78
79    for skill in skill_list {
80        let location = skill.path.display().to_string();
81        let scope = match skill.scope {
82            SkillScope::User => "user",
83            SkillScope::Repo => "repo",
84            SkillScope::System => "system",
85            SkillScope::Admin => "admin",
86        };
87
88        let line = format!(
89            "- {}: {} (file: {}, scope: {})",
90            skill.name, skill.description, location, scope
91        );
92
93        let _ = writeln!(prompt, "{}", line);
94    }
95
96    if overflow > 0 {
97        let _ = write!(prompt, "\n(+{} more skills available)", overflow);
98    }
99
100    // Append usage rules (Codex pattern)
101    prompt.push_str(SKILL_USAGE_RULES);
102
103    prompt
104}
105
106/// Generate skills prompt in XML format (Agent Skills spec recommendation for LLM models)
107///
108/// Wraps skills in `<available_skills>` tags for improved safety and isolation.
109/// This is the recommended format per the Agent Skills specification.
110pub fn generate_skills_prompt_xml(skills: &[SkillMetadata]) -> String {
111    if skills.is_empty() {
112        return String::new();
113    }
114
115    let mut xml = String::from("\n<available_skills>\n");
116
117    // Sort skills by name for stable ordering
118    let mut skill_list: Vec<_> = skills.iter().collect();
119    skill_list.sort_by_key(|skill| &skill.name);
120
121    // Show up to 10 skills to keep prompt lean
122    let overflow = skill_list.len().saturating_sub(10);
123    if overflow > 0 {
124        skill_list.truncate(10);
125    }
126
127    for skill in skill_list {
128        xml.push_str("  <skill>\n");
129        let _ = writeln!(xml, "    <name>{}</name>", xml_escape(&skill.name));
130        let _ = writeln!(
131            xml,
132            "    <description>{}</description>",
133            xml_escape(&skill.description)
134        );
135        let _ = writeln!(
136            xml,
137            "    <location>{}</location>",
138            xml_escape(&skill.path.display().to_string())
139        );
140
141        // Optional fields per Agent Skills spec
142        if let Some(manifest) = &skill.manifest {
143            if let Some(ref compatibility) = manifest.compatibility {
144                let _ = writeln!(
145                    xml,
146                    "    <compatibility>{}</compatibility>",
147                    xml_escape(compatibility)
148                );
149            }
150
151            if let Some(ref allowed_tools) = manifest.allowed_tools {
152                let _ = writeln!(
153                    xml,
154                    "    <allowed-tools>{}</allowed-tools>",
155                    xml_escape(allowed_tools)
156                );
157            }
158        }
159
160        xml.push_str("  </skill>\n");
161    }
162
163    if overflow > 0 {
164        let _ = writeln!(xml, "  <!-- +{} more skills available -->", overflow);
165    }
166
167    xml.push_str("</available_skills>\n");
168    xml
169}
170
171/// Escape special XML characters
172fn xml_escape(s: &str) -> String {
173    s.replace('&', "&amp;")
174        .replace('<', "&lt;")
175        .replace('>', "&gt;")
176        .replace('"', "&quot;")
177        .replace('\'', "&apos;")
178}
179
180/// Generate skills prompt with format specification
181pub fn generate_skills_prompt_with_format(
182    skills: &[SkillMetadata],
183    render_mode: SkillsRenderMode,
184    format: PromptFormat,
185) -> String {
186    match format {
187        PromptFormat::Xml => generate_skills_prompt_xml(skills),
188        PromptFormat::Markdown => generate_skills_prompt_with_mode(skills, render_mode),
189    }
190}
191
192/// Test helper
193pub fn test_skills_prompt_generation() {
194    use crate::skills::types::SkillManifest;
195    use std::path::PathBuf;
196
197    let mut skills = Vec::new();
198
199    let manifest = SkillManifest {
200        name: "pdf-analyzer".to_string(),
201        description: "Analyze PDF documents".to_string(),
202        ..Default::default()
203    };
204
205    let skill = SkillMetadata {
206        name: manifest.name.clone(),
207        description: manifest.description.clone(),
208        short_description: None,
209        path: PathBuf::from("/tmp/test"),
210        scope: SkillScope::User,
211        manifest: Some(manifest.into()),
212    };
213
214    skills.push(skill);
215
216    let prompt = generate_skills_prompt(&skills);
217    assert!(prompt.contains("pdf-analyzer"));
218    assert!(prompt.contains("Analyze PDF documents"));
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::path::PathBuf;
225
226    #[test]
227    fn test_empty_skills() {
228        let skills = Vec::new();
229        let prompt = generate_skills_prompt(&skills);
230        assert!(prompt.is_empty());
231    }
232
233    #[test]
234    fn test_skills_rendering() {
235        test_skills_prompt_generation();
236    }
237
238    #[test]
239    fn test_lean_rendering_mode() {
240        use crate::skills::types::SkillManifest;
241        let mut skills = Vec::new();
242
243        let manifest = SkillManifest {
244            name: "test-skill".to_string(),
245            description: "Test skill description".to_string(),
246            ..Default::default()
247        };
248
249        let skill = SkillMetadata {
250            name: manifest.name.clone(),
251            description: manifest.description.clone(),
252            short_description: None,
253            path: PathBuf::from("/tmp/test-skill"),
254            scope: SkillScope::User,
255            manifest: Some(manifest.into()),
256        };
257
258        skills.push(skill);
259
260        let lean_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Lean);
261
262        // Lean mode should include name, description, and file path.
263        assert!(lean_prompt.contains("test-skill"));
264        assert!(lean_prompt.contains("Test skill description"));
265        assert!(lean_prompt.contains("(file: /tmp/test-skill"));
266
267        // Lean mode should include usage rules
268        assert!(lean_prompt.contains("Usage Rules"));
269        assert!(lean_prompt.contains("$SkillName"));
270    }
271
272    #[test]
273    fn test_full_vs_lean_token_savings() {
274        use crate::skills::types::SkillManifest;
275        let mut skills = Vec::new();
276
277        for i in 0..5 {
278            let manifest = SkillManifest {
279                name: format!("skill-{}", i),
280                description: format!("Example skill number {}", i),
281                ..Default::default()
282            };
283
284            let skill = SkillMetadata {
285                name: manifest.name.clone(),
286                description: manifest.description.clone(),
287                short_description: None,
288                path: PathBuf::from(format!("/path/to/skill-{}", i)),
289                scope: SkillScope::User,
290                manifest: Some(manifest.into()),
291            };
292
293            skills.push(skill);
294        }
295
296        let full_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Full);
297        let lean_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Lean);
298
299        assert_eq!(full_prompt, lean_prompt);
300        assert!(lean_prompt.contains("Usage Rules"));
301        assert!(full_prompt.contains("Available skills"));
302    }
303
304    #[test]
305    fn test_xml_generation() {
306        use crate::skills::types::SkillManifest;
307        let mut skills = Vec::new();
308        use hashbrown::HashMap as StdHashMap;
309
310        let mut metadata = StdHashMap::new();
311        metadata.insert("author".to_string(), serde_json::json!("Test Author"));
312
313        let manifest = SkillManifest {
314            name: "test-xml-skill".to_string(),
315            description: "Test XML generation".to_string(),
316            allowed_tools: Some("Read Write Bash".to_string()),
317            compatibility: Some("Designed for VT Code".to_string()),
318            metadata: Some(metadata),
319            ..Default::default()
320        };
321
322        let skill = SkillMetadata {
323            name: manifest.name.clone(),
324            description: manifest.description.clone(),
325            short_description: None,
326            path: PathBuf::from("/tmp/test-xml-skill"),
327            scope: SkillScope::User,
328            manifest: Some(manifest.into()),
329        };
330
331        skills.push(skill);
332
333        let xml_prompt = generate_skills_prompt_xml(&skills);
334
335        // Should be wrapped in XML tags
336        assert!(xml_prompt.contains("<available_skills>"));
337        assert!(xml_prompt.contains("</available_skills>"));
338        assert!(xml_prompt.contains("<skill>"));
339        assert!(xml_prompt.contains("</skill>"));
340
341        // Should include required fields
342        assert!(xml_prompt.contains("<name>test-xml-skill</name>"));
343        assert!(xml_prompt.contains("<description>Test XML generation</description>"));
344        assert!(xml_prompt.contains("<location>/tmp/test-xml-skill</location>"));
345
346        // Should include optional fields
347        assert!(xml_prompt.contains("<compatibility>Designed for VT Code</compatibility>"));
348        assert!(xml_prompt.contains("<allowed-tools>Read Write Bash</allowed-tools>"));
349    }
350
351    #[test]
352    fn test_xml_escaping() {
353        use crate::skills::types::SkillManifest;
354        let mut skills = Vec::new();
355
356        let manifest = SkillManifest {
357            name: "test-escape".to_string(),
358            description: "Test <special> & \"characters\"".to_string(),
359            ..Default::default()
360        };
361
362        let skill = SkillMetadata {
363            name: manifest.name.clone(),
364            description: manifest.description.clone(),
365            short_description: None,
366            path: PathBuf::from("/tmp/test"),
367            scope: SkillScope::User,
368            manifest: Some(manifest.into()),
369        };
370
371        skills.push(skill);
372
373        let xml_prompt = generate_skills_prompt_xml(&skills);
374
375        // XML special characters should be escaped
376        assert!(xml_prompt.contains("&lt;special&gt;"));
377        assert!(xml_prompt.contains("&amp;"));
378        assert!(xml_prompt.contains("&quot;"));
379    }
380
381    #[test]
382    fn test_prompt_format_selection() {
383        use crate::skills::types::SkillManifest;
384        let mut skills = Vec::new();
385
386        let manifest = SkillManifest {
387            name: "test-format".to_string(),
388            description: "Test format selection".to_string(),
389            ..Default::default()
390        };
391
392        let skill = SkillMetadata {
393            name: manifest.name.clone(),
394            description: manifest.description.clone(),
395            short_description: None,
396            path: PathBuf::from("/tmp/test"),
397            scope: SkillScope::User,
398            manifest: Some(manifest.into()),
399        };
400
401        skills.push(skill);
402
403        let xml_output =
404            generate_skills_prompt_with_format(&skills, SkillsRenderMode::Lean, PromptFormat::Xml);
405        let markdown_output = generate_skills_prompt_with_format(
406            &skills,
407            SkillsRenderMode::Lean,
408            PromptFormat::Markdown,
409        );
410
411        // XML format should have XML tags
412        assert!(xml_output.contains("<available_skills>"));
413        assert!(!markdown_output.contains("<available_skills>"));
414
415        // Markdown format should have markdown headers
416        assert!(markdown_output.contains("## Skills"));
417        assert!(!xml_output.contains("## Skills"));
418    }
419}