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