Skip to main content

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