aether_core/context/
compaction.rs1use 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#[derive(Debug, Clone)]
12pub struct CompactionResult {
13 pub context: Context,
15 pub summary: String,
17 pub messages_removed: usize,
19}
20
21#[derive(Debug, Clone, thiserror::Error)]
23pub enum CompactionError {
24 #[error("summarization failed: {0}")]
26 SummarizationFailed(String),
27 #[error("nothing to compact")]
29 NothingToCompact,
30}
31
32#[derive(Debug, Clone)]
34pub struct CompactionConfig {
35 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 pub fn with_threshold(threshold: f64) -> Self {
48 Self { threshold }
49 }
50}
51
52pub 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 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}