Skip to main content

vtcode_core/skills/
manager.rs

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