aether_core/context/
compaction.rs1use 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#[derive(Debug, Clone)]
12pub struct CompactionResult {
13 pub context: Context,
15 pub summary: String,
17 pub messages_removed: usize,
19 pub usage: Option<TokenUsage>,
21}
22
23#[derive(Debug, Clone, thiserror::Error)]
25pub enum CompactionError {
26 #[error("summarization failed: {0}")]
28 SummarizationFailed(String),
29 #[error("nothing to compact")]
31 NothingToCompact,
32}
33
34#[derive(Debug, Clone)]
36pub struct CompactionConfig {
37 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 pub fn with_threshold(threshold: f64) -> Self {
50 Self { threshold }
51 }
52}
53
54pub 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 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}