Skip to main content

vtcode_core/skills/
manager.rs

1#[cfg(test)]
2use crate::skills::loader::discover_skill_metadata_lightweight_hermetic;
3use crate::skills::loader::{
4    SkillLoaderConfig, clear_lightweight_skill_metadata_cache, discover_skill_metadata_lightweight,
5    load_skills,
6};
7use crate::skills::model::SkillLoadOutcome;
8use crate::skills::system::install_system_skills;
9use crate::skills::system::uninstall_system_skills;
10use crate::skills::types::Skill;
11use anyhow::Result;
12use hashbrown::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15use std::sync::RwLock;
16use std::time::{Duration, SystemTime};
17
18use crate::utils::file_utils::read_file_with_context_sync;
19
20/// Cache entry with TTL tracking for skill outcomes
21#[derive(Clone)]
22struct CachedSkillOutcome {
23    outcome: SkillLoadOutcome,
24    timestamp: SystemTime,
25}
26
27impl CachedSkillOutcome {
28    fn is_expired(&self, ttl: Duration) -> bool {
29        self.timestamp.elapsed().unwrap_or(ttl) > ttl
30    }
31}
32
33/// Cached instruction entry for on-demand skill parsing (Phase 3)
34#[derive(Clone)]
35struct CachedSkillInstruction {
36    skill: Skill,
37    timestamp: SystemTime,
38}
39
40impl CachedSkillInstruction {
41    fn is_expired(&self, ttl: Duration) -> bool {
42        self.timestamp.elapsed().unwrap_or(ttl) > ttl
43    }
44}
45
46pub struct SkillsManager {
47    codex_home: PathBuf,
48    bundled_skills_enabled: bool,
49    /// Per-cwd skill loading cache with TTL and max capacity
50    cache_by_cwd: RwLock<HashMap<PathBuf, CachedSkillOutcome>>,
51    /// Max number of cached workspaces (prevents unbounded growth)
52    max_cache_size: usize,
53    /// TTL for cached metadata (default 5 minutes)
54    cache_ttl: Duration,
55    /// Tracks if system skills installation has been attempted
56    system_skills_initialized: OnceLock<()>,
57    /// Per-skill instruction cache with TTL (Phase 3: deferred SKILL.md parsing)
58    instruction_cache: RwLock<HashMap<String, CachedSkillInstruction>>,
59    /// Max instructions cached in memory (prevents unbounded growth)
60    max_instruction_cache_size: usize,
61    /// TTL for instruction cache (default 10 minutes - longer than metadata)
62    instruction_cache_ttl: Duration,
63}
64
65impl SkillsManager {
66    pub fn new(codex_home: PathBuf) -> Self {
67        Self::new_with_bundled_skills_enabled(codex_home, true)
68    }
69
70    pub fn new_with_bundled_skills_enabled(
71        codex_home: PathBuf,
72        bundled_skills_enabled: bool,
73    ) -> Self {
74        let manager = Self {
75            codex_home,
76            bundled_skills_enabled,
77            cache_by_cwd: RwLock::new(HashMap::new()),
78            max_cache_size: 10,
79            cache_ttl: Duration::from_secs(5 * 60), // 5 minutes
80            system_skills_initialized: OnceLock::new(),
81            instruction_cache: RwLock::new(HashMap::new()),
82            max_instruction_cache_size: 50, // Cache up to 50 parsed skills
83            instruction_cache_ttl: Duration::from_secs(10 * 60), // 10 minutes
84        };
85
86        if !manager.bundled_skills_enabled {
87            uninstall_system_skills(&manager.codex_home);
88        }
89
90        manager
91    }
92
93    pub fn bundled_skills_enabled(&self) -> bool {
94        self.bundled_skills_enabled
95    }
96
97    /// Lazy initialize system skills (non-blocking, can be called async)
98    pub fn ensure_system_skills_installed(&self) {
99        self.system_skills_initialized.get_or_init(|| {
100            if !self.bundled_skills_enabled {
101                return;
102            }
103
104            // Try to install system skills, log error if fails but don't panic
105            if let Err(err) = install_system_skills(&self.codex_home) {
106                tracing::warn!("lazy system skills installation failed: {err}");
107            }
108        });
109    }
110
111    pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
112        self.skills_for_cwd_with_options(cwd, false)
113    }
114
115    pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
116        // Ensure system skills are installed at least once
117        self.ensure_system_skills_installed();
118
119        if !force_reload {
120            if let Ok(cache) = self.cache_by_cwd.read() {
121                if let Some(cached) = cache.get(cwd)
122                    && !cached.is_expired(self.cache_ttl)
123                {
124                    return cached.outcome.clone();
125                }
126            } else {
127                tracing::warn!("skills metadata cache lock poisoned while reading cache");
128            }
129        }
130
131        let project_root = find_git_root(cwd);
132
133        let config = SkillLoaderConfig {
134            codex_home: self.codex_home.clone(),
135            cwd: cwd.to_path_buf(),
136            project_root,
137            include_bundled_system_skills: self.bundled_skills_enabled,
138        };
139
140        let outcome = load_skills(&config);
141
142        if let Ok(mut cache) = self.cache_by_cwd.write() {
143            // Enforce max cache size: remove oldest expired entries first, then LRU
144            if cache.len() >= self.max_cache_size && !cache.contains_key(cwd) {
145                // Remove oldest expired entries
146                let expired: Vec<_> = cache
147                    .iter()
148                    .filter(|(_, v)| v.timestamp.elapsed().unwrap_or_default() > self.cache_ttl)
149                    .map(|(k, _)| k.clone())
150                    .collect();
151
152                for key in expired {
153                    cache.remove(&key);
154                }
155
156                // If still at capacity, remove oldest entry by timestamp (simple LRU)
157                if cache.len() >= self.max_cache_size {
158                    let oldest_key = cache
159                        .iter()
160                        .min_by_key(|(_, v)| v.timestamp)
161                        .map(|(k, _)| k.clone());
162                    if let Some(key) = oldest_key {
163                        cache.remove(&key);
164                    }
165                }
166            }
167
168            cache.insert(
169                cwd.to_path_buf(),
170                CachedSkillOutcome {
171                    outcome: outcome.clone(),
172                    timestamp: SystemTime::now(),
173                },
174            );
175        } else {
176            tracing::warn!("skills metadata cache lock poisoned while writing cache");
177        }
178
179        outcome
180    }
181
182    /// Clear the entire cache
183    pub fn clear_cache(&self) {
184        if let Ok(mut cache) = self.cache_by_cwd.write() {
185            cache.clear();
186        } else {
187            tracing::warn!("skills metadata cache lock poisoned while clearing cache");
188        }
189        clear_lightweight_skill_metadata_cache();
190    }
191
192    /// Get current cache size (for testing/diagnostics)
193    pub fn cache_size(&self) -> usize {
194        if let Ok(cache) = self.cache_by_cwd.read() {
195            cache.len()
196        } else {
197            tracing::warn!("skills metadata cache lock poisoned while reading cache size");
198            0
199        }
200    }
201
202    /// Quick metadata discovery without parsing SKILL.md files (Phase 2)
203    /// Returns skill stubs with name and path only (no manifest parsing).
204    /// ~10x faster than full discovery, suitable for listing available skills.
205    pub fn skills_metadata_lightweight(&self, cwd: &Path) -> SkillLoadOutcome {
206        // Ensure system skills are installed at least once
207        self.ensure_system_skills_installed();
208
209        let project_root = find_git_root(cwd);
210
211        let config = SkillLoaderConfig {
212            codex_home: self.codex_home.clone(),
213            cwd: cwd.to_path_buf(),
214            project_root,
215            include_bundled_system_skills: self.bundled_skills_enabled,
216        };
217
218        // Use lightweight discovery (no manifest parsing)
219        discover_skill_metadata_lightweight(&config)
220    }
221
222    #[cfg(test)]
223    fn skills_metadata_lightweight_hermetic(&self, cwd: &Path) -> SkillLoadOutcome {
224        let project_root = find_git_root(cwd);
225
226        let config = SkillLoaderConfig {
227            codex_home: self.codex_home.clone(),
228            cwd: cwd.to_path_buf(),
229            project_root,
230            include_bundled_system_skills: self.bundled_skills_enabled,
231        };
232
233        discover_skill_metadata_lightweight_hermetic(&config)
234    }
235
236    /// Load full skill instructions on-demand (Phase 3)
237    /// Parses SKILL.md and returns Skill with full manifest and instructions.
238    /// Cached with LRU eviction (max 50 skills, 10-minute TTL).
239    /// Returns cached result if available and not expired.
240    pub fn load_skill_instructions(&self, skill_name: &str, skill_path: &Path) -> Result<Skill> {
241        // Check cache first
242        {
243            if let Ok(cache) = self.instruction_cache.read() {
244                if let Some(cached) = cache.get(skill_name)
245                    && !cached.is_expired(self.instruction_cache_ttl)
246                {
247                    return Ok(cached.skill.clone());
248                }
249            } else {
250                tracing::warn!("skill instruction cache lock poisoned while reading cache");
251            }
252        }
253
254        // Parse SKILL.md on-demand
255        let skill_md = skill_path.join("SKILL.md");
256        let content = read_file_with_context_sync(&skill_md, "skill instructions")
257            .map_err(|e| anyhow::anyhow!("Failed to read SKILL.md for '{}': {}", skill_name, e))?;
258
259        let (manifest, instructions) = crate::skills::manifest::parse_skill_content(&content)?;
260        let skill = Skill::new(manifest, skill_path.to_path_buf(), instructions)?;
261
262        // Cache the parsed skill
263        if let Ok(mut cache) = self.instruction_cache.write() {
264            // Enforce max cache size: remove expired entries first, then LRU
265            if cache.len() >= self.max_instruction_cache_size && !cache.contains_key(skill_name) {
266                // Remove expired entries
267                let expired: Vec<_> = cache
268                    .iter()
269                    .filter(|(_, v)| v.is_expired(self.instruction_cache_ttl))
270                    .map(|(k, _)| k.clone())
271                    .collect();
272
273                for key in expired {
274                    cache.remove(&key);
275                }
276
277                // If still at capacity, remove oldest by timestamp (LRU)
278                if cache.len() >= self.max_instruction_cache_size {
279                    let oldest_key = cache
280                        .iter()
281                        .min_by_key(|(_, v)| v.timestamp)
282                        .map(|(k, _)| k.clone());
283                    if let Some(key) = oldest_key {
284                        cache.remove(&key);
285                    }
286                }
287            }
288
289            cache.insert(
290                skill_name.to_string(),
291                CachedSkillInstruction {
292                    skill: skill.clone(),
293                    timestamp: SystemTime::now(),
294                },
295            );
296        } else {
297            tracing::warn!("skill instruction cache lock poisoned while writing cache");
298        }
299
300        Ok(skill)
301    }
302
303    /// Clear instruction cache
304    pub fn clear_instruction_cache(&self) {
305        if let Ok(mut cache) = self.instruction_cache.write() {
306            cache.clear();
307        } else {
308            tracing::warn!("skill instruction cache lock poisoned while clearing cache");
309        }
310    }
311
312    /// Get instruction cache size (for testing/diagnostics)
313    pub fn instruction_cache_size(&self) -> usize {
314        if let Ok(cache) = self.instruction_cache.read() {
315            cache.len()
316        } else {
317            tracing::warn!("skill instruction cache lock poisoned while reading cache size");
318            0
319        }
320    }
321}
322
323fn find_git_root(path: &Path) -> Option<PathBuf> {
324    let mut current = path;
325    loop {
326        if current.join(".git").exists() {
327            return Some(current.to_path_buf());
328        }
329        if let Some(parent) = current.parent() {
330            current = parent;
331        } else {
332            return None;
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use serial_test::serial;
341    use std::fs;
342    use tempfile::TempDir;
343
344    #[test]
345    fn test_skills_manager_lazy_initialization() {
346        let temp_home = TempDir::new().unwrap();
347        let manager = SkillsManager::new(temp_home.path().to_path_buf());
348
349        // Manager should be created without system skills initialized yet
350        assert_eq!(manager.cache_size(), 0);
351
352        // Ensure system skills are initialized
353        manager.ensure_system_skills_installed();
354
355        // Should still have empty cache (only system installation tracked)
356        assert_eq!(manager.cache_size(), 0);
357    }
358
359    #[test]
360    fn test_skills_manager_cache_with_ttl() {
361        let temp_home = TempDir::new().unwrap();
362        let manager = SkillsManager::new(temp_home.path().to_path_buf());
363
364        let cwd = temp_home.path();
365
366        // First load
367        let outcome1 = manager.skills_for_cwd(cwd);
368        assert_eq!(manager.cache_size(), 1);
369
370        // Second load (should return cached)
371        let outcome2 = manager.skills_for_cwd(cwd);
372        assert_eq!(outcome1.skills.len(), outcome2.skills.len());
373        assert_eq!(manager.cache_size(), 1);
374
375        // Force reload
376        let outcome3 = manager.skills_for_cwd_with_options(cwd, true);
377        assert_eq!(manager.cache_size(), 1);
378        assert_eq!(outcome1.skills.len(), outcome3.skills.len());
379    }
380
381    #[test]
382    fn test_skills_manager_max_cache_size() {
383        let temp_home = TempDir::new().unwrap();
384        let manager = SkillsManager::new(temp_home.path().to_path_buf());
385
386        // Create multiple temp directories and load skills
387        for _ in 0..15 {
388            let dir = TempDir::new().unwrap();
389            let cwd = dir.path();
390            manager.skills_for_cwd(cwd);
391        }
392
393        // Cache should respect max_cache_size (10)
394        assert!(manager.cache_size() <= 10);
395    }
396
397    #[test]
398    fn test_skills_manager_clear_cache() {
399        let temp_home = TempDir::new().unwrap();
400        let manager = SkillsManager::new(temp_home.path().to_path_buf());
401
402        let cwd = temp_home.path();
403        manager.skills_for_cwd(cwd);
404        assert_eq!(manager.cache_size(), 1);
405
406        manager.clear_cache();
407        assert_eq!(manager.cache_size(), 0);
408    }
409
410    #[test]
411    #[serial]
412    fn test_skills_manager_clear_cache_clears_lightweight_discovery_cache() {
413        clear_lightweight_skill_metadata_cache();
414
415        let temp_home = TempDir::new().unwrap();
416        let workspace = TempDir::new().unwrap();
417        let manager = SkillsManager::new(temp_home.path().to_path_buf());
418        let skill_dir = workspace.path().join(".agents/skills/clear-cache-skill");
419
420        fs::create_dir_all(&skill_dir).unwrap();
421        fs::write(
422            skill_dir.join("SKILL.md"),
423            "---\nname: clear-cache-skill\ndescription: clear cache test\n---\n# Body\n",
424        )
425        .unwrap();
426
427        let first = manager.skills_metadata_lightweight_hermetic(workspace.path());
428        assert!(
429            first
430                .skills
431                .iter()
432                .any(|skill| skill.name == "clear-cache-skill"),
433            "expected first discovery to find test skill",
434        );
435
436        fs::remove_dir_all(&skill_dir).unwrap();
437
438        let second = manager.skills_metadata_lightweight_hermetic(workspace.path());
439        assert!(
440            second
441                .skills
442                .iter()
443                .any(|skill| skill.name == "clear-cache-skill"),
444            "expected cached discovery to preserve removed skill before clear_cache",
445        );
446
447        manager.clear_cache();
448
449        let third = manager.skills_metadata_lightweight_hermetic(workspace.path());
450        assert!(
451            !third
452                .skills
453                .iter()
454                .any(|skill| skill.name == "clear-cache-skill"),
455            "expected clear_cache to flush lightweight discovery cache",
456        );
457    }
458
459    #[test]
460    fn test_skills_metadata_lightweight() {
461        let temp_home = TempDir::new().unwrap();
462        let manager = SkillsManager::new(temp_home.path().to_path_buf());
463
464        let cwd = temp_home.path();
465
466        // Lightweight discovery should work without errors
467        let outcome = manager.skills_metadata_lightweight_hermetic(cwd);
468
469        // Should return empty outcome (no skills in temp dir)
470        // but should not crash or error
471        assert_eq!(outcome.errors.len(), 0);
472    }
473
474    #[test]
475    fn test_lightweight_vs_full_discovery() {
476        let temp_home = TempDir::new().unwrap();
477        let manager = SkillsManager::new(temp_home.path().to_path_buf());
478
479        let cwd = temp_home.path();
480
481        // Both should complete without error
482        let lightweight = manager.skills_metadata_lightweight(cwd);
483        let full = manager.skills_for_cwd(cwd);
484
485        // Lightweight discovery should find at least as many skills as full discovery
486        // (or may find more if full discovery filters some due to parse errors)
487        // but they should be in the same ballpark (within 10% difference)
488        let light_count = lightweight.skills.len() as i32;
489        let full_count = full.skills.len() as i32;
490        let diff = (light_count - full_count).abs();
491        let tolerance = (full_count / 10).max(5); // 10% tolerance or 5 items, whichever is larger
492
493        assert!(
494            diff <= tolerance,
495            "Lightweight discovery found {} skills, full discovery found {}. Difference {} exceeds tolerance {}",
496            light_count,
497            full_count,
498            diff,
499            tolerance
500        );
501    }
502
503    #[test]
504    fn test_instruction_cache_initialization() {
505        let temp_home = TempDir::new().unwrap();
506        let manager = SkillsManager::new(temp_home.path().to_path_buf());
507
508        // Instruction cache should be empty at start
509        assert_eq!(manager.instruction_cache_size(), 0);
510
511        // Clear should work without error
512        manager.clear_instruction_cache();
513        assert_eq!(manager.instruction_cache_size(), 0);
514    }
515
516    #[test]
517    fn test_instruction_cache_max_size() {
518        let temp_home = TempDir::new().unwrap();
519        let manager = SkillsManager::new(temp_home.path().to_path_buf());
520
521        // Max size is 50, create more entries than that
522        // Note: This test is limited because we need actual SKILL.md files
523        // For now, just verify the cache respects max size in behavior
524        assert_eq!(manager.instruction_cache_size(), 0);
525
526        // Even after multiple operations, should be bounded
527        manager.clear_instruction_cache();
528        assert_eq!(manager.instruction_cache_size(), 0);
529    }
530
531    #[test]
532    fn test_instruction_cache_clear() {
533        let temp_home = TempDir::new().unwrap();
534        let manager = SkillsManager::new(temp_home.path().to_path_buf());
535
536        // Cache should start empty
537        assert_eq!(manager.instruction_cache_size(), 0);
538
539        // Clear should work
540        manager.clear_instruction_cache();
541        assert_eq!(manager.instruction_cache_size(), 0);
542    }
543
544    #[test]
545    fn test_disabled_bundled_skills_remove_stale_system_cache() {
546        let temp_home = TempDir::new().unwrap();
547        let stale_skill_dir = temp_home.path().join("skills/.system/stale-skill");
548        fs::create_dir_all(&stale_skill_dir).unwrap();
549        fs::write(stale_skill_dir.join("SKILL.md"), "# stale\n").unwrap();
550
551        let _manager =
552            SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
553
554        assert!(!temp_home.path().join("skills/.system").exists());
555    }
556
557    #[test]
558    fn test_disabled_bundled_skills_exclude_system_root_even_if_recreated() {
559        let temp_home = TempDir::new().unwrap();
560        let workspace = TempDir::new().unwrap();
561        let bundled_skill_dir = temp_home.path().join("skills/.system/bundled-skill");
562        fs::create_dir_all(&bundled_skill_dir).unwrap();
563        fs::write(
564            bundled_skill_dir.join("SKILL.md"),
565            "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
566        )
567        .unwrap();
568
569        let manager =
570            SkillsManager::new_with_bundled_skills_enabled(temp_home.path().to_path_buf(), false);
571
572        fs::create_dir_all(&bundled_skill_dir).unwrap();
573        fs::write(
574            bundled_skill_dir.join("SKILL.md"),
575            "---\nname: bundled-skill\ndescription: bundled\n---\n\n# Body\n",
576        )
577        .unwrap();
578
579        let outcome = manager.skills_for_cwd(workspace.path());
580        assert!(
581            outcome
582                .skills
583                .iter()
584                .all(|skill| skill.name != "bundled-skill")
585        );
586    }
587}