Skip to main content

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