aether_core/context/
compaction.rs1use 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#[derive(Debug, Clone)]
13pub struct CompactionResult {
14 pub context: Context,
16 pub summary: String,
18 pub messages_removed: usize,
20}
21
22#[derive(Debug, Clone)]
24pub enum CompactionError {
25 SummarizationFailed(String),
27 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#[derive(Debug, Clone)]
46pub struct CompactionConfig {
47 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 pub fn with_threshold(threshold: f64) -> Self {
60 Self { threshold }
61 }
62}
63
64pub 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 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}