Skip to main content

aether_core/context/
compaction.rs

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