1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ContextConfig {
23 max_context_tokens: usize,
25
26 max_cached_skills: usize,
28
29 metadata_token_cost: usize,
31
32 instruction_token_factor: f64,
34
35 resource_token_cost: usize,
37
38 enable_monitoring: bool,
40
41 eviction_policy: EvictionPolicy,
43
44 enable_persistence: bool,
46
47 cache_path: Option<PathBuf>,
49}
50
51impl Default for ContextConfig {
52 fn default() -> Self {
53 Self {
54 max_context_tokens: 50_000, max_cached_skills: 100,
56 metadata_token_cost: 50,
57 instruction_token_factor: 0.25, 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#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum EvictionPolicy {
70 LRU,
72 LFU,
74 TokenCost,
76 Manual,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub enum ContextLevel {
83 Metadata,
85 Instructions,
87 Full,
89}
90
91#[derive(Debug, Clone)]
93pub struct ContextUsage {
94 access_count: u64,
96
97 last_access: std::time::Instant,
99
100 total_loaded_duration: std::time::Duration,
102
103 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#[derive(Debug, Clone)]
120pub struct SkillContextEntry {
121 name: String,
123
124 level: ContextLevel,
126
127 manifest: SkillManifest,
129
130 instructions: Option<String>,
132
133 skill: Option<Box<Skill>>,
135
136 usage: ContextUsage,
138
139 memory_size: usize,
141}
142
143#[derive(Clone)]
145pub struct ContextManager {
146 config: ContextConfig,
147 inner: Arc<RwLock<ContextManagerInner>>,
148}
149
150struct ContextManagerInner {
152 active_skills: HashMap<String, SkillContextEntry>,
154
155 loaded_skills: LruCache<String, SkillContextEntry>,
157
158 current_token_usage: usize,
160
161 stats: ContextStats,
163}
164
165#[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 fn new() -> Self {
181 Self::with_config(ContextConfig::default())
182 }
183
184 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 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 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 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 let instruction_size = instructions.len();
260
261 if inner.current_token_usage + instruction_size > self.config.max_context_tokens {
263 self.evict_skills_to_make_room_internal(&mut inner, instruction_size)?;
265 }
266
267 let mut entry = match inner.loaded_skills.get_mut(name) {
269 Some(entry) => entry.clone(),
270 None => {
271 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 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 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 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 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 let instruction_size = skill.instructions.len();
309 let resource_size = skill.list_resources().len() * self.config.resource_token_cost * 4; let incremental_cost = instruction_size + resource_size;
311
312 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 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 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 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 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 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 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 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 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 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 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 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 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 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
481pub struct PersistentContextManager {
483 inner: ContextManager,
484 cache_path: PathBuf,
485}
486
487impl PersistentContextManager {
488 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 if let Err(e) = manager.load_cache() {
497 debug!("Failed to load context cache: {}", e);
498 }
499
500 Ok(manager)
501 }
502
503 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 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 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 pub fn inner(&self) -> &ContextManager {
543 &self.inner
544 }
545
546 pub fn inner_mut(&mut self) -> &mut ContextManager {
548 &mut self.inner
549 }
550}
551
552#[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); }
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}