Skip to main content

vtcode_core/skills/
context_manager.rs

1//! Progressive Context Management for Skills
2//!
3//! Manages skill context loading with memory efficiency through:
4//! - Progressive disclosure (metadata → instructions → resources)
5//! - Context budget tracking and enforcement
6//! - LRU eviction for unused skills
7//! - Memory usage monitoring
8//! - Skill state persistence
9
10use crate::skills::types::{Skill, SkillManifest};
11use crate::utils::file_utils::{read_json_file_sync, write_json_file_sync};
12use anyhow::{Context, Result, anyhow};
13use hashbrown::HashMap;
14use lru::LruCache;
15use serde::{Deserialize, Serialize};
16use std::path::PathBuf;
17use std::sync::{Arc, RwLock};
18use tracing::{debug, info, warn};
19
20/// Configuration for context management
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextConfig {
23    /// Maximum total context size in tokens
24    pub max_context_tokens: usize,
25
26    /// Maximum number of cached skills
27    pub max_cached_skills: usize,
28
29    /// Token cost for skill metadata (name + description)
30    pub metadata_token_cost: usize,
31
32    /// Token cost for skill instructions per character
33    pub instruction_token_factor: f64,
34
35    /// Token cost for skill resources
36    pub resource_token_cost: usize,
37
38    /// Enable memory monitoring
39    pub enable_monitoring: bool,
40
41    /// Context eviction policy
42    pub eviction_policy: EvictionPolicy,
43
44    /// Enable persistent caching
45    pub enable_persistence: bool,
46
47    /// Cache persistence path
48    pub cache_path: Option<PathBuf>,
49}
50
51impl Default for ContextConfig {
52    fn default() -> Self {
53        Self {
54            max_context_tokens: 50_000, // 50k tokens total
55            max_cached_skills: 100,
56            metadata_token_cost: 50,
57            instruction_token_factor: 0.25, // ~4 chars per token
58            resource_token_cost: 200,
59            enable_monitoring: true,
60            eviction_policy: EvictionPolicy::LRU,
61            enable_persistence: false,
62            cache_path: None,
63        }
64    }
65}
66
67/// Context eviction policies
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum EvictionPolicy {
70    /// Least Recently Used eviction
71    LRU,
72    /// Least Frequently Used eviction
73    LFU,
74    /// Token-cost based eviction (evict most expensive)
75    TokenCost,
76    /// Manual eviction only
77    Manual,
78}
79
80/// Skill context loading levels
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub enum ContextLevel {
83    /// Metadata only (name, description) - ~50 tokens
84    Metadata,
85    /// Instructions loaded - variable tokens
86    Instructions,
87    /// Full skill with resources - maximum tokens
88    Full,
89}
90
91/// Context usage tracking
92#[derive(Debug, Clone)]
93pub struct ContextUsage {
94    /// Number of times skill was accessed
95    pub access_count: u64,
96
97    /// Last access timestamp
98    pub last_access: std::time::Instant,
99
100    /// Total time loaded in memory
101    pub total_loaded_duration: std::time::Duration,
102
103    /// Token cost for this skill
104    pub token_cost: usize,
105}
106
107impl Default for ContextUsage {
108    fn default() -> Self {
109        Self {
110            access_count: 0,
111            last_access: std::time::Instant::now(),
112            total_loaded_duration: std::time::Duration::ZERO,
113            token_cost: 0,
114        }
115    }
116}
117
118/// Skill context entry
119#[derive(Debug, Clone)]
120pub struct SkillContextEntry {
121    /// Skill name
122    pub name: String,
123
124    /// Current context level
125    pub level: ContextLevel,
126
127    /// Skill metadata (always available)
128    pub manifest: SkillManifest,
129
130    /// Skill instructions (loaded on demand)
131    pub instructions: Option<String>,
132
133    /// Full skill object (loaded on demand)
134    pub skill: Option<Box<Skill>>,
135
136    /// Usage tracking
137    pub usage: ContextUsage,
138
139    /// Memory size estimate (bytes)
140    pub memory_size: usize,
141}
142
143/// Progressive context manager
144#[derive(Clone)]
145pub struct ContextManager {
146    config: ContextConfig,
147    inner: Arc<RwLock<ContextManagerInner>>,
148}
149
150/// Inner state for ContextManager to be wrapped in Arc<RwLock<>>
151struct ContextManagerInner {
152    /// Active skill contexts (metadata only)
153    active_skills: HashMap<String, SkillContextEntry>,
154
155    /// LRU cache for loaded skills
156    loaded_skills: LruCache<String, SkillContextEntry>,
157
158    /// Current context usage in tokens
159    current_token_usage: usize,
160
161    /// Context usage statistics
162    stats: ContextStats,
163}
164
165/// Context management statistics
166#[derive(Debug, Default, Clone)]
167pub struct ContextStats {
168    pub total_skills_loaded: u64,
169    pub total_skills_evicted: u64,
170    pub total_tokens_loaded: u64,
171    pub total_tokens_evicted: u64,
172    pub cache_hits: u64,
173    pub cache_misses: u64,
174    pub peak_token_usage: usize,
175    pub current_token_usage: usize,
176}
177
178impl ContextManager {
179    /// Create new context manager with default configuration
180    pub fn new() -> Self {
181        Self::with_config(ContextConfig::default())
182    }
183
184    /// Create new context manager with custom configuration
185    pub fn with_config(config: ContextConfig) -> Self {
186        let max_cached_skills = config.max_cached_skills.max(1);
187        if max_cached_skills != config.max_cached_skills {
188            warn!(
189                configured = config.max_cached_skills,
190                effective = max_cached_skills,
191                "max_cached_skills must be at least 1; using fallback value"
192            );
193        }
194        let loaded_skills = LruCache::new(
195            std::num::NonZeroUsize::new(max_cached_skills).unwrap_or(std::num::NonZeroUsize::MIN),
196        );
197
198        Self {
199            config: config.clone(),
200            inner: Arc::new(RwLock::new(ContextManagerInner {
201                active_skills: HashMap::new(),
202                loaded_skills,
203                current_token_usage: 0,
204                stats: ContextStats::default(),
205            })),
206        }
207    }
208}
209
210impl Default for ContextManager {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl ContextManager {
217    /// Register skill metadata (Level 1 loading)
218    pub fn register_skill_metadata(&self, manifest: SkillManifest) -> Result<()> {
219        let name = manifest.name.clone();
220
221        let mut inner = self.inner.write().unwrap_or_else(|poisoned| {
222            warn!(
223                "ContextManager write lock poisoned while registering skill metadata; recovering"
224            );
225            poisoned.into_inner()
226        });
227
228        let entry = SkillContextEntry {
229            name: name.clone(),
230            level: ContextLevel::Metadata,
231            manifest: manifest.clone(),
232            instructions: None,
233            skill: None,
234            usage: ContextUsage {
235                access_count: 0,
236                last_access: std::time::Instant::now(),
237                total_loaded_duration: std::time::Duration::ZERO,
238                token_cost: self.config.metadata_token_cost,
239            },
240            memory_size: size_of::<SkillContextEntry>() + name.len() + manifest.description.len(),
241        };
242
243        // Update token usage
244        inner.current_token_usage += self.config.metadata_token_cost;
245        inner.stats.current_token_usage = inner.current_token_usage;
246        inner.stats.peak_token_usage = inner.stats.peak_token_usage.max(inner.current_token_usage);
247
248        inner.active_skills.insert(name, entry);
249        info!("Registered skill metadata: {}", manifest.name);
250
251        Ok(())
252    }
253
254    /// Load skill instructions (Level 2 loading)
255    pub fn load_skill_instructions(&self, name: &str, instructions: String) -> Result<()> {
256        let mut inner = self.inner.write().unwrap_or_else(|poisoned| {
257            warn!(
258                "ContextManager write lock poisoned while loading skill instructions; recovering"
259            );
260            poisoned.into_inner()
261        });
262
263        // Calculate simple size metric (characters) instead of tokens
264        let instruction_size = instructions.len();
265
266        // Check context budget (using character count instead of tokens)
267        if inner.current_token_usage + instruction_size > self.config.max_context_tokens {
268            // Need to evict skills to make room
269            self.evict_skills_to_make_room_internal(&mut inner, instruction_size)?;
270        }
271
272        // Get or create entry
273        let mut entry = match inner.loaded_skills.get_mut(name) {
274            Some(entry) => entry.clone(),
275            None => {
276                // Create new entry from active skills
277                match inner.active_skills.get(name) {
278                    Some(active_entry) => active_entry.clone(),
279                    None => return Err(anyhow!("Skill '{}' not found in active skills", name)),
280                }
281            }
282        };
283
284        // Update entry
285        entry.level = ContextLevel::Instructions;
286        entry.instructions = Some(instructions.clone());
287        entry.usage.token_cost = instruction_size;
288        entry.memory_size += instructions.len();
289
290        // Update usage
291        inner.current_token_usage += instruction_size;
292        inner.stats.current_token_usage = inner.current_token_usage;
293        inner.stats.peak_token_usage = inner.stats.peak_token_usage.max(inner.current_token_usage);
294        inner.stats.total_tokens_loaded += instruction_size as u64;
295
296        // Cache the entry
297        inner.loaded_skills.put(name.to_string(), entry);
298
299        info!(
300            "Loaded instructions for skill: {} ({} chars)",
301            name, instruction_size
302        );
303
304        Ok(())
305    }
306
307    /// Load full skill with resources (Level 3 loading)
308    pub fn load_full_skill(&self, skill: Skill) -> Result<()> {
309        let name = skill.name().to_string();
310        let mut inner = self.inner.write().unwrap_or_else(|poisoned| {
311            warn!("ContextManager write lock poisoned while loading full skill; recovering");
312            poisoned.into_inner()
313        });
314
315        // Calculate size-based cost for resources and instructions (characters instead of tokens)
316        let instruction_size = skill.instructions.len();
317        let resource_size = skill.list_resources().len() * self.config.resource_token_cost * 4; // Approximate
318        let incremental_cost = instruction_size + resource_size;
319
320        // Check context budget
321        if inner.current_token_usage + incremental_cost > self.config.max_context_tokens {
322            self.evict_skills_to_make_room_internal(&mut inner, incremental_cost)?;
323        }
324
325        // Create entry
326        let entry = SkillContextEntry {
327            name: name.clone(),
328            level: ContextLevel::Full,
329            manifest: skill.manifest.clone(),
330            instructions: Some(skill.instructions.clone()),
331            skill: Some(skill.into()),
332            usage: ContextUsage {
333                access_count: 0,
334                last_access: std::time::Instant::now(),
335                total_loaded_duration: std::time::Duration::ZERO,
336                token_cost: incremental_cost,
337            },
338            memory_size: size_of::<Skill>() + name.len() * 2,
339        };
340
341        // Update usage
342        inner.current_token_usage += incremental_cost;
343        inner.stats.current_token_usage = inner.current_token_usage;
344        inner.stats.peak_token_usage = inner.stats.peak_token_usage.max(inner.current_token_usage);
345        inner.stats.total_skills_loaded += 1;
346        inner.stats.total_tokens_loaded += incremental_cost as u64;
347
348        // Cache the entry
349        let entry_name = entry.name.clone();
350        inner.loaded_skills.put(name, entry);
351
352        info!(
353            "Loaded full skill: {} ({} tokens)",
354            entry_name,
355            incremental_cost + self.config.metadata_token_cost
356        );
357
358        Ok(())
359    }
360
361    /// Get skill context (with automatic loading)
362    pub fn get_skill_context(&self, name: &str) -> Option<SkillContextEntry> {
363        let mut inner = self.inner.write().unwrap_or_else(|poisoned| {
364            warn!("ContextManager write lock poisoned while fetching skill context; recovering");
365            poisoned.into_inner()
366        });
367
368        // Try loaded skills first
369        if let Some(mut entry) = inner.loaded_skills.get_mut(name).cloned() {
370            entry.usage.access_count += 1;
371            entry.usage.last_access = std::time::Instant::now();
372            inner.stats.cache_hits += 1;
373            return Some(entry);
374        }
375
376        // Fall back to active skills (metadata only)
377        if let Some(mut entry) = inner.active_skills.get(name).cloned() {
378            entry.usage.access_count += 1;
379            entry.usage.last_access = std::time::Instant::now();
380            inner.stats.cache_misses += 1;
381            return Some(entry);
382        }
383
384        None
385    }
386
387    /// Evict skills to make room for new ones
388    fn evict_skills_to_make_room_internal(
389        &self,
390        inner: &mut ContextManagerInner,
391        required_tokens: usize,
392    ) -> Result<()> {
393        let mut freed_tokens = 0;
394        let mut evicted_skills = Vec::new();
395
396        // Use LRU eviction
397        while freed_tokens < required_tokens && !inner.loaded_skills.is_empty() {
398            if let Some((name, entry)) = inner.loaded_skills.pop_lru() {
399                freed_tokens += entry.usage.token_cost;
400                evicted_skills.push(name);
401
402                inner.stats.total_skills_evicted += 1;
403                inner.stats.total_tokens_evicted += entry.usage.token_cost as u64;
404            } else {
405                break;
406            }
407        }
408
409        inner.current_token_usage -= freed_tokens;
410        inner.stats.current_token_usage = inner.current_token_usage;
411
412        info!(
413            "Evicted {} skills to free {} tokens",
414            evicted_skills.len(),
415            freed_tokens
416        );
417        debug!("Evicted skills: {:?}", evicted_skills);
418
419        if freed_tokens < required_tokens {
420            return Err(anyhow!(
421                "Unable to free enough tokens. Required: {}, Freed: {}",
422                required_tokens,
423                freed_tokens
424            ));
425        }
426
427        Ok(())
428    }
429
430    /// Get current context usage statistics
431    pub fn get_stats(&self) -> ContextStats {
432        self.inner
433            .read()
434            .unwrap_or_else(|poisoned| {
435                warn!("ContextManager read lock poisoned while reading stats; recovering");
436                poisoned.into_inner()
437            })
438            .stats
439            .clone()
440    }
441
442    /// Get current token usage
443    pub fn get_token_usage(&self) -> usize {
444        self.inner
445            .read()
446            .unwrap_or_else(|poisoned| {
447                warn!("ContextManager read lock poisoned while reading token usage; recovering");
448                poisoned.into_inner()
449            })
450            .current_token_usage
451    }
452
453    /// Clear all loaded skills (keep metadata)
454    pub fn clear_loaded_skills(&self) {
455        let mut inner = self.inner.write().unwrap_or_else(|poisoned| {
456            warn!("ContextManager write lock poisoned while clearing loaded skills; recovering");
457            poisoned.into_inner()
458        });
459
460        let evicted_count = inner.loaded_skills.len();
461        let evicted_tokens = inner.stats.current_token_usage
462            - (inner.active_skills.len() * self.config.metadata_token_cost);
463
464        inner.loaded_skills.clear();
465        inner.current_token_usage = inner.active_skills.len() * self.config.metadata_token_cost;
466        inner.stats.current_token_usage = inner.current_token_usage;
467        inner.stats.total_skills_evicted += evicted_count as u64;
468        inner.stats.total_tokens_evicted += evicted_tokens as u64;
469
470        info!(
471            "Cleared {} loaded skills ({} tokens)",
472            evicted_count, evicted_tokens
473        );
474    }
475
476    /// Get all active skill names
477    pub fn get_active_skills(&self) -> Vec<String> {
478        self.inner
479            .read()
480            .unwrap_or_else(|poisoned| {
481                warn!("ContextManager read lock poisoned while reading active skills; recovering");
482                poisoned.into_inner()
483            })
484            .active_skills
485            .keys()
486            .cloned()
487            .collect()
488    }
489
490    /// Get memory usage estimate
491    pub fn get_memory_usage(&self) -> usize {
492        let inner = self.inner.read().unwrap_or_else(|poisoned| {
493            warn!("ContextManager read lock poisoned while calculating memory usage; recovering");
494            poisoned.into_inner()
495        });
496        let active_memory: usize = inner
497            .active_skills
498            .values()
499            .map(|entry| entry.memory_size)
500            .sum();
501
502        let loaded_memory: usize = inner
503            .loaded_skills
504            .iter()
505            .map(|(_, entry)| entry.memory_size)
506            .sum();
507
508        active_memory + loaded_memory
509    }
510}
511
512/// Context manager with persistence support
513pub struct PersistentContextManager {
514    inner: ContextManager,
515    cache_path: PathBuf,
516}
517
518impl PersistentContextManager {
519    /// Create new persistent context manager
520    pub fn new(cache_path: PathBuf, config: ContextConfig) -> Result<Self> {
521        let mut manager = Self {
522            inner: ContextManager::with_config(config),
523            cache_path,
524        };
525
526        // Try to load cached state
527        if let Err(e) = manager.load_cache() {
528            debug!("Failed to load context cache: {}", e);
529        }
530
531        Ok(manager)
532    }
533
534    /// Load cached context state
535    fn load_cache(&mut self) -> Result<()> {
536        if !self.cache_path.exists() {
537            return Ok(());
538        }
539
540        let cache: ContextCache = read_json_file_sync(&self.cache_path)?;
541
542        // Restore active skills
543        let skill_count = cache.active_skills.len();
544        for manifest in cache.active_skills {
545            self.inner.register_skill_metadata(manifest)?;
546        }
547
548        info!("Loaded {} cached skills", skill_count);
549        Ok(())
550    }
551
552    /// Save context state to cache
553    pub fn save_cache(&self) -> Result<()> {
554        let inner = self
555            .inner
556            .inner
557            .read()
558            .map_err(|err| anyhow!("context manager lock poisoned while saving cache: {err}"))
559            .context("Failed to save context manager cache state")?;
560        let cache = ContextCache {
561            version: 1,
562            timestamp: std::time::SystemTime::now()
563                .duration_since(std::time::UNIX_EPOCH)?
564                .as_secs(),
565            active_skills: inner
566                .active_skills
567                .values()
568                .map(|entry| entry.manifest.clone())
569                .collect(),
570        };
571
572        write_json_file_sync(&self.cache_path, &cache)?;
573
574        info!("Saved {} skills to cache", cache.active_skills.len());
575        Ok(())
576    }
577
578    /// Get inner context manager
579    pub fn inner(&self) -> &ContextManager {
580        &self.inner
581    }
582
583    /// Get mutable inner context manager
584    pub fn inner_mut(&mut self) -> &mut ContextManager {
585        &mut self.inner
586    }
587}
588
589/// Cache structure for persistence
590#[derive(Debug, Serialize, Deserialize)]
591struct ContextCache {
592    version: u32,
593    timestamp: u64,
594    active_skills: Vec<SkillManifest>,
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_context_config_default() {
603        let config = ContextConfig::default();
604        assert_eq!(config.max_context_tokens, 50_000);
605        assert_eq!(config.max_cached_skills, 100);
606    }
607
608    #[test]
609    fn test_context_manager_creation() {
610        let manager = ContextManager::new();
611        assert_eq!(manager.get_token_usage(), 0);
612        assert_eq!(manager.get_active_skills().len(), 0);
613    }
614
615    #[test]
616    fn boxed_skill_entry_is_smaller_than_inline_option() {
617        use std::mem::size_of;
618
619        assert!(size_of::<Option<Box<Skill>>>() < size_of::<Option<Skill>>());
620        assert!(size_of::<SkillContextEntry>() < size_of::<SkillContextEntryInlineSkill>());
621    }
622
623    #[expect(dead_code)]
624    struct SkillContextEntryInlineSkill {
625        name: String,
626        level: ContextLevel,
627        manifest: SkillManifest,
628        instructions: Option<String>,
629        skill: Option<Skill>,
630        usage: ContextUsage,
631        memory_size: usize,
632    }
633
634    #[test]
635    fn test_skill_metadata_registration() {
636        let manager = ContextManager::new();
637
638        let manifest = SkillManifest {
639            name: "test-skill".to_string(),
640            description: "Test skill".to_string(),
641            version: Some("1.0.0".to_string()),
642            author: Some("Test".to_string()),
643            vtcode_native: Some(true),
644            ..Default::default()
645        };
646
647        manager.register_skill_metadata(manifest).unwrap();
648        assert_eq!(manager.get_active_skills().len(), 1);
649        assert_eq!(manager.get_token_usage(), 50); // metadata_token_cost
650    }
651
652    #[test]
653    fn test_skill_context_retrieval() {
654        let manager = ContextManager::new();
655
656        let manifest = SkillManifest {
657            name: "test-skill".to_string(),
658            description: "Test skill".to_string(),
659            ..Default::default()
660        };
661
662        manager.register_skill_metadata(manifest.clone()).unwrap();
663
664        let context = manager.get_skill_context("test-skill");
665        assert!(context.is_some());
666        assert_eq!(context.unwrap().manifest.name, "test-skill");
667    }
668}