Skip to main content

aether_core/context/
compaction.rs

1use std::fmt;
2use std::sync::Arc;
3
4use tokio_stream::StreamExt;
5
6use llm::types::IsoString;
7use llm::{ChatMessage, Context, LlmResponse, StreamingModelProvider};
8
9const SUMMARIZATION_PROMPT: &str = include_str!("prompts/summarization.md");
10
11/// Result of a compaction operation
12#[derive(Debug, Clone)]
13pub struct CompactionResult {
14    /// The compacted context with summary message
15    pub context: Context,
16    /// The summary text that replaced the compacted messages
17    pub summary: String,
18    /// Number of messages that were removed/compacted
19    pub messages_removed: usize,
20}
21
22/// Errors that can occur during compaction
23#[derive(Debug, Clone)]
24pub enum CompactionError {
25    /// The LLM failed to generate a summary
26    SummarizationFailed(String),
27    /// No messages to compact
28    NothingToCompact,
29}
30
31impl fmt::Display for CompactionError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            CompactionError::SummarizationFailed(msg) => {
35                write!(f, "summarization failed: {msg}")
36            }
37            CompactionError::NothingToCompact => write!(f, "nothing to compact"),
38        }
39    }
40}
41
42impl std::error::Error for CompactionError {}
43
44/// Configuration for context compaction
45#[derive(Debug, Clone)]
46pub struct CompactionConfig {
47    /// Threshold (0.0-1.0) at which to trigger compaction
48    pub threshold: f64,
49}
50
51impl Default for CompactionConfig {
52    fn default() -> Self {
53        Self { threshold: super::DEFAULT_COMPACTION_THRESHOLD }
54    }
55}
56
57impl CompactionConfig {
58    /// Create a new compaction config with the given threshold
59    pub fn with_threshold(threshold: f64) -> Self {
60        Self { threshold }
61    }
62}
63
64/// Compacts context by generating an LLM summary
65pub struct Compactor {
66    llm: Arc<dyn StreamingModelProvider>,
67}
68
69impl Compactor {
70    pub fn new(llm: Arc<dyn StreamingModelProvider>) -> Self {
71        Self { llm }
72    }
73
74    /// Generate a structured summary of the conversation and return a new compacted context.
75    ///
76    /// This is a pure function that takes a reference to the context and returns a new
77    /// context with the compacted messages replaced by a summary.
78    pub async fn compact(&self, context: &Context) -> Result<CompactionResult, CompactionError> {
79        let messages_to_summarize = context.messages_for_summary();
80        if messages_to_summarize.is_empty() {
81            return Err(CompactionError::NothingToCompact);
82        }
83
84        let messages_removed = messages_to_summarize.len();
85
86        let mut summary_context = context.clone();
87        summary_context.add_message(ChatMessage::User {
88            content: vec![llm::ContentBlock::text(format!(
89                "{SUMMARIZATION_PROMPT}\n\nPlease perform a structured handoff of the conversation above."
90            ))],
91            timestamp: IsoString::now(),
92        });
93
94        let mut stream = self.llm.stream_response(&summary_context);
95        let mut summary = String::new();
96
97        while let Some(result) = stream.next().await {
98            match result {
99                Ok(LlmResponse::Text { chunk }) => {
100                    summary.push_str(&chunk);
101                }
102                Ok(LlmResponse::Done { .. }) => break,
103                Ok(LlmResponse::Error { message }) => {
104                    return Err(CompactionError::SummarizationFailed(message));
105                }
106                Err(e) => {
107                    return Err(CompactionError::SummarizationFailed(e.to_string()));
108                }
109                _ => {}
110            }
111        }
112
113        if summary.is_empty() {
114            return Err(CompactionError::SummarizationFailed("LLM returned empty summary".to_string()));
115        }
116
117        let compacted_context = context.with_compacted_summary(&summary);
118
119        Ok(CompactionResult { context: compacted_context, summary, messages_removed })
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use llm::ChatMessage;
127    use llm::types::IsoString;
128
129    #[test]
130    fn test_compaction_config_default() {
131        let config = CompactionConfig::default();
132        assert!((config.threshold - 0.85).abs() < 0.001);
133    }
134
135    #[test]
136    fn test_compaction_config_with_threshold() {
137        let config = CompactionConfig::with_threshold(0.9);
138        assert!((config.threshold - 0.9).abs() < 0.001);
139    }
140
141    #[tokio::test]
142    async fn test_compactor_generates_summary() {
143        use llm::testing::FakeLlmProvider;
144
145        let summary_response = vec![
146            LlmResponse::start("msg-1"),
147            LlmResponse::text(
148                "## Primary Goal\nTest the compaction feature\n\n## Completed Work\n- Wrote initial tests\n\n## File Changes\n- `src/main.rs` — added entry point\n\n## Key Decisions\n- Use structured handoff — preserves context better\n\n## Current State\nRunning compaction tests\n\n## Next Steps\n1. Verify all tests pass\n\n## Open Questions\n(none)\n\n## Constraints\n(none)",
149            ),
150            LlmResponse::done(),
151        ];
152
153        let fake_llm = Arc::new(FakeLlmProvider::with_single_response(summary_response));
154        let compactor = Compactor::new(fake_llm);
155
156        let context = Context::new(
157            vec![
158                ChatMessage::System { content: "System".to_string(), timestamp: IsoString::now() },
159                ChatMessage::User {
160                    content: vec![llm::ContentBlock::text("Test message")],
161                    timestamp: IsoString::now(),
162                },
163            ],
164            vec![],
165        );
166
167        let result = compactor.compact(&context).await;
168        assert!(result.is_ok());
169
170        let result = result.unwrap();
171        assert!(result.summary.contains("Primary Goal"));
172        assert!(result.summary.contains("File Changes"));
173        assert!(result.summary.contains("Next Steps"));
174        assert_eq!(result.messages_removed, 1);
175    }
176
177    #[tokio::test]
178    async fn test_compactor_handles_error() {
179        use llm::testing::FakeLlmProvider;
180
181        let error_response = vec![LlmResponse::Error { message: "API error".to_string() }];
182
183        let fake_llm = Arc::new(FakeLlmProvider::with_single_response(error_response));
184        let compactor = Compactor::new(fake_llm);
185
186        let context = Context::new(
187            vec![
188                ChatMessage::System { content: "System".to_string(), timestamp: IsoString::now() },
189                ChatMessage::User { content: vec![llm::ContentBlock::text("Test")], timestamp: IsoString::now() },
190            ],
191            vec![],
192        );
193
194        let result = compactor.compact(&context).await;
195        assert!(matches!(result, Err(CompactionError::SummarizationFailed(_))));
196    }
197
198    #[tokio::test]
199    async fn test_compactor_empty_context() {
200        use llm::testing::FakeLlmProvider;
201
202        let fake_llm = Arc::new(FakeLlmProvider::with_single_response(vec![]));
203        let compactor = Compactor::new(fake_llm);
204
205        let context = Context::new(
206            vec![ChatMessage::System { content: "System".to_string(), timestamp: IsoString::now() }],
207            vec![],
208        );
209
210        let result = compactor.compact(&context).await;
211        assert!(matches!(result, Err(CompactionError::NothingToCompact)));
212    }
213}