Skip to main content

enact_context/
window.rs

1//! Context Window Management
2//!
3//! The ContextWindow manages all segments within token budget limits.
4//!
5//! @see packages/enact-schemas/src/context.schemas.ts
6
7use crate::budget::{BudgetHealth, ContextBudget};
8#[cfg(test)]
9use crate::compactor::CompactionStrategy;
10use crate::compactor::{CompactionResult, CompactionStrategyType, Compactor};
11use crate::segment::{ContextSegment, ContextSegmentType};
12use crate::token_counter::TokenCounter;
13use chrono::{DateTime, Utc};
14use enact_core::kernel::ExecutionId;
15use serde::{Deserialize, Serialize};
16use std::time::Instant;
17use thiserror::Error;
18
19/// Context window errors
20#[derive(Debug, Error)]
21pub enum ContextWindowError {
22    #[error("Token counter error: {0}")]
23    TokenCounter(String),
24
25    #[error("Budget exceeded: need {needed} tokens, only {available} available")]
26    BudgetExceeded { needed: usize, available: usize },
27
28    #[error("Segment budget exceeded for {segment_type:?}: need {needed}, max {max}")]
29    SegmentBudgetExceeded {
30        segment_type: ContextSegmentType,
31        needed: usize,
32        max: usize,
33    },
34
35    #[error("Compaction failed: {0}")]
36    CompactionFailed(String),
37}
38
39/// Context window state
40///
41/// Matches `contextWindowStateSchema` in @enact/schemas
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ContextWindowState {
45    /// Execution ID
46    pub execution_id: ExecutionId,
47
48    /// All segments currently in context
49    pub segments: Vec<ContextSegment>,
50
51    /// Current budget state
52    pub budget: ContextBudget,
53
54    /// Compaction history
55    pub compaction_history: Vec<CompactionResult>,
56
57    /// Number of compactions performed
58    pub compaction_count: u32,
59
60    /// Total tokens saved by compaction
61    pub total_tokens_saved: usize,
62
63    /// Current health status
64    pub health: BudgetHealth,
65
66    /// Last updated timestamp
67    pub updated_at: DateTime<Utc>,
68}
69
70/// Context Window - manages segments within budget
71pub struct ContextWindow {
72    /// Execution ID
73    execution_id: ExecutionId,
74
75    /// All segments in the context
76    segments: Vec<ContextSegment>,
77
78    /// Token budget
79    budget: ContextBudget,
80
81    /// Token counter
82    token_counter: TokenCounter,
83
84    /// Compaction history
85    compaction_history: Vec<CompactionResult>,
86
87    /// Next sequence number
88    next_sequence: u64,
89}
90
91impl ContextWindow {
92    /// Create a new context window
93    pub fn new(budget: ContextBudget) -> Result<Self, ContextWindowError> {
94        let token_counter =
95            TokenCounter::new().map_err(|e| ContextWindowError::TokenCounter(e.to_string()))?;
96
97        Ok(Self {
98            execution_id: budget.execution_id.clone(),
99            segments: Vec::new(),
100            budget,
101            token_counter,
102            compaction_history: Vec::new(),
103            next_sequence: 0,
104        })
105    }
106
107    /// Create with a preset budget
108    pub fn with_preset_gpt4_128k(execution_id: ExecutionId) -> Result<Self, ContextWindowError> {
109        Self::new(ContextBudget::preset_gpt4_128k(execution_id))
110    }
111
112    /// Create with Claude 200K preset
113    pub fn with_preset_claude_200k(execution_id: ExecutionId) -> Result<Self, ContextWindowError> {
114        Self::new(ContextBudget::preset_claude_200k(execution_id))
115    }
116
117    /// Create with default (8K) preset
118    pub fn with_preset_default(execution_id: ExecutionId) -> Result<Self, ContextWindowError> {
119        Self::new(ContextBudget::preset_default(execution_id))
120    }
121
122    /// Get the execution ID
123    pub fn execution_id(&self) -> &ExecutionId {
124        &self.execution_id
125    }
126
127    /// Get all segments
128    pub fn segments(&self) -> &[ContextSegment] {
129        &self.segments
130    }
131
132    /// Get the budget
133    pub fn budget(&self) -> &ContextBudget {
134        &self.budget
135    }
136
137    /// Get mutable budget
138    pub fn budget_mut(&mut self) -> &mut ContextBudget {
139        &mut self.budget
140    }
141
142    /// Count tokens for text
143    pub fn count_tokens(&self, text: &str) -> usize {
144        self.token_counter.count(text)
145    }
146
147    /// Add a segment with automatic token counting
148    pub fn add_segment_auto(
149        &mut self,
150        mut segment: ContextSegment,
151    ) -> Result<(), ContextWindowError> {
152        // Count tokens if not already set
153        if segment.token_count == 0 {
154            segment.token_count = self.token_counter.count(&segment.content);
155        }
156
157        self.add_segment(segment)
158    }
159
160    /// Add a segment to the context window
161    pub fn add_segment(&mut self, mut segment: ContextSegment) -> Result<(), ContextWindowError> {
162        // Check segment budget
163        if let Some(seg_budget) = self.budget.get_segment(segment.segment_type) {
164            let new_usage = seg_budget.current_tokens + segment.token_count;
165            if new_usage > seg_budget.max_tokens {
166                return Err(ContextWindowError::SegmentBudgetExceeded {
167                    segment_type: segment.segment_type,
168                    needed: segment.token_count,
169                    max: seg_budget.max_tokens - seg_budget.current_tokens,
170                });
171            }
172        }
173
174        // Check total budget
175        let new_total = self.budget.used_tokens + segment.token_count;
176        if new_total > self.budget.available_tokens {
177            return Err(ContextWindowError::BudgetExceeded {
178                needed: segment.token_count,
179                available: self.budget.remaining(),
180            });
181        }
182
183        // Assign sequence number
184        segment.sequence = self.next_sequence;
185        self.next_sequence += 1;
186
187        // Update budget
188        self.budget
189            .add_tokens(segment.segment_type, segment.token_count);
190
191        // Add segment
192        self.segments.push(segment);
193
194        Ok(())
195    }
196
197    /// Remove a segment by ID
198    pub fn remove_segment(&mut self, segment_id: &str) -> bool {
199        if let Some(pos) = self.segments.iter().position(|s| s.id == segment_id) {
200            let segment = self.segments.remove(pos);
201            self.budget
202                .remove_tokens(segment.segment_type, segment.token_count);
203            true
204        } else {
205            false
206        }
207    }
208
209    /// Get segments of a specific type
210    pub fn segments_of_type(&self, segment_type: ContextSegmentType) -> Vec<&ContextSegment> {
211        self.segments
212            .iter()
213            .filter(|s| s.segment_type == segment_type)
214            .collect()
215    }
216
217    /// Total tokens currently used
218    pub fn used_tokens(&self) -> usize {
219        self.budget.used_tokens
220    }
221
222    /// Remaining tokens available
223    pub fn remaining_tokens(&self) -> usize {
224        self.budget.remaining()
225    }
226
227    /// Check if the window needs compaction
228    pub fn needs_compaction(&self) -> bool {
229        self.budget.is_warning()
230    }
231
232    /// Check if the window is in critical state
233    pub fn is_critical(&self) -> bool {
234        self.budget.is_critical()
235    }
236
237    /// Get health status
238    pub fn health(&self) -> BudgetHealth {
239        self.budget.health()
240    }
241
242    /// Compact the context using the given compactor
243    pub fn compact(
244        &mut self,
245        compactor: &Compactor,
246    ) -> Result<CompactionResult, ContextWindowError> {
247        let start = Instant::now();
248        let tokens_before = self.budget.used_tokens;
249
250        let result = match compactor.strategy().strategy_type {
251            CompactionStrategyType::Truncate => {
252                compactor.compact_truncate(&mut self.segments, tokens_before)
253            }
254            CompactionStrategyType::SlidingWindow => {
255                compactor.compact_sliding_window(&mut self.segments)
256            }
257            CompactionStrategyType::Summarize => {
258                compactor.compact_summarize(&mut self.segments, tokens_before)
259            }
260            CompactionStrategyType::ExtractKeyPoints => {
261                compactor.compact_extract_key_points(&mut self.segments, tokens_before)
262            }
263            CompactionStrategyType::ImportanceWeighted => {
264                compactor.compact_importance_weighted(&mut self.segments, tokens_before)
265            }
266            CompactionStrategyType::Hybrid => {
267                compactor.compact_hybrid(&mut self.segments, tokens_before)
268            }
269        };
270
271        let duration_ms = start.elapsed().as_millis() as u64;
272
273        match result {
274            Ok(tokens_removed) => {
275                // Recalculate budget
276                self.recalculate_budget();
277
278                let tokens_after = self.budget.used_tokens;
279                let segments_compacted = (tokens_removed > 0) as usize;
280
281                let compaction_result = CompactionResult::success(
282                    self.execution_id.clone(),
283                    compactor.strategy().strategy_type,
284                    tokens_before,
285                    tokens_after,
286                    segments_compacted,
287                    duration_ms,
288                );
289
290                self.compaction_history.push(compaction_result.clone());
291                Ok(compaction_result)
292            }
293            Err(e) => {
294                let compaction_result = CompactionResult::failure(
295                    self.execution_id.clone(),
296                    compactor.strategy().strategy_type,
297                    tokens_before,
298                    e.to_string(),
299                    duration_ms,
300                );
301
302                self.compaction_history.push(compaction_result.clone());
303                Err(ContextWindowError::CompactionFailed(e.to_string()))
304            }
305        }
306    }
307
308    /// Recalculate budget from current segments
309    fn recalculate_budget(&mut self) {
310        // Reset all segment budgets
311        for seg_budget in &mut self.budget.segments {
312            seg_budget.current_tokens = 0;
313        }
314
315        // Sum up tokens by segment type
316        for segment in &self.segments {
317            self.budget
318                .add_tokens(segment.segment_type, segment.token_count);
319        }
320    }
321
322    /// Build the context as a single string (for LLM calls)
323    pub fn build_context(&self) -> String {
324        let mut parts: Vec<&str> = Vec::new();
325
326        // Sort segments by sequence
327        let mut sorted: Vec<&ContextSegment> = self.segments.iter().collect();
328        sorted.sort_by_key(|s| s.sequence);
329
330        for segment in sorted {
331            parts.push(&segment.content);
332        }
333
334        parts.join("\n\n")
335    }
336
337    /// Get the current state (for serialization)
338    pub fn state(&self) -> ContextWindowState {
339        ContextWindowState {
340            execution_id: self.execution_id.clone(),
341            segments: self.segments.clone(),
342            budget: self.budget.clone(),
343            compaction_history: self.compaction_history.clone(),
344            compaction_count: self.compaction_history.len() as u32,
345            total_tokens_saved: self.compaction_history.iter().map(|r| r.tokens_saved).sum(),
346            health: self.budget.health(),
347            updated_at: Utc::now(),
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn test_execution_id() -> ExecutionId {
357        ExecutionId::new()
358    }
359
360    #[test]
361    fn test_create_window() {
362        let budget = ContextBudget::preset_default(test_execution_id());
363        let window = ContextWindow::new(budget).unwrap();
364
365        assert_eq!(window.used_tokens(), 0);
366        assert!(window.remaining_tokens() > 0);
367    }
368
369    #[test]
370    fn test_add_segment() {
371        let budget = ContextBudget::preset_default(test_execution_id());
372        let mut window = ContextWindow::new(budget).unwrap();
373
374        let segment = ContextSegment::system("You are a helpful assistant.", 10);
375        window.add_segment(segment).unwrap();
376
377        assert_eq!(window.segments().len(), 1);
378        assert_eq!(window.used_tokens(), 10);
379    }
380
381    #[test]
382    fn test_health_tracking() {
383        let budget = ContextBudget::preset_default(test_execution_id());
384        let window = ContextWindow::new(budget).unwrap();
385
386        assert_eq!(window.health(), BudgetHealth::Healthy);
387        assert!(!window.needs_compaction());
388    }
389
390    #[test]
391    fn test_compact_with_summarize_strategy() {
392        let budget = ContextBudget::preset_default(test_execution_id());
393        let mut window = ContextWindow::new(budget).unwrap();
394
395        // Add system segment (protected)
396        let system = ContextSegment::system("You are helpful.", 10);
397        window.add_segment(system).unwrap();
398
399        // Add history segments that can be summarized
400        let history1 = ContextSegment::history(
401            "The user asked about the weather. The result was sunny. Important note about temperature.",
402            500,
403            1,
404        );
405        let history2 = ContextSegment::history(
406            "Then we discussed travel plans. The conclusion was to visit Paris. The output showed flight options.",
407            600,
408            2,
409        );
410        window.add_segment(history1).unwrap();
411        window.add_segment(history2).unwrap();
412
413        // Create summarize compactor with low target to force compaction
414        let compactor = Compactor::summarize(200, 100);
415        let result = window.compact(&compactor);
416
417        assert!(result.is_ok());
418        let compaction_result = result.unwrap();
419        assert!(compaction_result.success);
420        assert!(compaction_result.tokens_saved > 0);
421
422        // System segment should still exist
423        assert!(window
424            .segments()
425            .iter()
426            .any(|s| s.segment_type == ContextSegmentType::System));
427    }
428
429    #[test]
430    fn test_compact_with_extract_key_points_returns_error() {
431        let budget = ContextBudget::preset_default(test_execution_id());
432        let mut window = ContextWindow::new(budget).unwrap();
433
434        let history = ContextSegment::history("Some content", 100, 1);
435        window.add_segment(history).unwrap();
436
437        // Create ExtractKeyPoints compactor (not implemented)
438        let strategy = CompactionStrategy {
439            strategy_type: CompactionStrategyType::ExtractKeyPoints,
440            target_tokens: 50,
441            min_preserve_percent: 20,
442            segments_to_compact: None,
443            protected_segments: None,
444            summary_max_tokens: None,
445            window_size: None,
446            min_importance_score: None,
447        };
448        let compactor = Compactor::new(strategy);
449        let result = window.compact(&compactor);
450
451        assert!(result.is_err());
452        match result {
453            Err(ContextWindowError::CompactionFailed(msg)) => {
454                assert!(msg.contains("ExtractKeyPoints"));
455            }
456            _ => panic!("Expected CompactionFailed error"),
457        }
458    }
459
460    #[test]
461    fn test_compact_with_importance_weighted_returns_error() {
462        let budget = ContextBudget::preset_default(test_execution_id());
463        let mut window = ContextWindow::new(budget).unwrap();
464
465        let history = ContextSegment::history("Some content", 100, 1);
466        window.add_segment(history).unwrap();
467
468        let strategy = CompactionStrategy {
469            strategy_type: CompactionStrategyType::ImportanceWeighted,
470            target_tokens: 50,
471            min_preserve_percent: 20,
472            segments_to_compact: None,
473            protected_segments: None,
474            summary_max_tokens: None,
475            window_size: None,
476            min_importance_score: Some(0.5),
477        };
478        let compactor = Compactor::new(strategy);
479        let result = window.compact(&compactor);
480
481        assert!(result.is_err());
482        match result {
483            Err(ContextWindowError::CompactionFailed(msg)) => {
484                assert!(msg.contains("ImportanceWeighted"));
485            }
486            _ => panic!("Expected CompactionFailed error"),
487        }
488    }
489
490    #[test]
491    fn test_compact_with_hybrid_returns_error() {
492        let budget = ContextBudget::preset_default(test_execution_id());
493        let mut window = ContextWindow::new(budget).unwrap();
494
495        let history = ContextSegment::history("Some content", 100, 1);
496        window.add_segment(history).unwrap();
497
498        let strategy = CompactionStrategy {
499            strategy_type: CompactionStrategyType::Hybrid,
500            target_tokens: 50,
501            min_preserve_percent: 20,
502            segments_to_compact: None,
503            protected_segments: None,
504            summary_max_tokens: None,
505            window_size: None,
506            min_importance_score: None,
507        };
508        let compactor = Compactor::new(strategy);
509        let result = window.compact(&compactor);
510
511        assert!(result.is_err());
512        match result {
513            Err(ContextWindowError::CompactionFailed(msg)) => {
514                assert!(msg.contains("Hybrid"));
515            }
516            _ => panic!("Expected CompactionFailed error"),
517        }
518    }
519}