behest_runtime/compaction/
mod.rs1pub mod overflow;
29pub mod prompt;
30pub mod prune;
31pub mod select;
32
33use std::sync::Arc;
34
35use uuid::Uuid;
36
37use super::token::estimate_records_tokens;
38use behest_provider::{ChatProvider, ChatRequest, Message, ModelName};
39use behest_store::{CompactionMeta, MessageRecord, MessageRole, SessionStore};
40
41use super::error::{RuntimeError, RuntimeResult};
42
43#[derive(Debug, Clone)]
50pub struct CompactionCircuitBreaker {
51 consecutive_failures: u32,
52 threshold: u32,
53 state: BreakerState,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum BreakerState {
58 Closed,
59 Open {
60 opened_at: chrono::DateTime<chrono::Utc>,
61 },
62}
63
64impl CompactionCircuitBreaker {
65 #[must_use]
67 pub fn new(threshold: u32) -> Self {
68 Self {
69 consecutive_failures: 0,
70 threshold,
71 state: BreakerState::Closed,
72 }
73 }
74
75 pub fn record_success(&mut self) {
78 self.consecutive_failures = 0;
79 self.state = BreakerState::Closed;
80 }
81
82 pub fn record_failure(&mut self) -> bool {
86 if self.is_open() {
87 return false;
88 }
89 self.consecutive_failures += 1;
90 if self.consecutive_failures >= self.threshold {
91 self.state = BreakerState::Open {
92 opened_at: chrono::Utc::now(),
93 };
94 return true;
95 }
96 false
97 }
98
99 #[must_use]
102 pub fn is_open(&self) -> bool {
103 matches!(self.state, BreakerState::Open { .. })
104 }
105
106 #[must_use]
108 pub fn consecutive_failures(&self) -> u32 {
109 self.consecutive_failures
110 }
111}
112
113pub struct CompactionService {
124 providers: behest_provider::ProviderRegistry,
126 config: crate::policy::CompactionConfig,
128}
129
130#[derive(Debug, Clone)]
132pub struct CompactionResult {
133 pub summary_text: String,
135 pub compaction_user_id: Uuid,
137 pub summary_message_id: Uuid,
139 pub tail_start_id: Uuid,
141 pub tokens_saved: usize,
143}
144
145impl CompactionService {
146 #[must_use]
148 pub fn new(
149 providers: behest_provider::ProviderRegistry,
150 config: crate::policy::CompactionConfig,
151 ) -> Self {
152 Self { providers, config }
153 }
154
155 #[must_use]
157 pub fn config(&self) -> &crate::policy::CompactionConfig {
158 &self.config
159 }
160
161 pub async fn compact_if_needed(
170 &self,
171 messages: &[MessageRecord],
172 model_context: u32,
173 max_output: u32,
174 store: &dyn SessionStore,
175 session_id: Uuid,
176 ) -> RuntimeResult<Option<CompactionResult>> {
177 let total_tokens = estimate_records_tokens(messages);
178
179 if !overflow::is_overflow(total_tokens, model_context, max_output, self.config.auto) {
180 return Ok(None);
181 }
182
183 let (provider, model) = self.resolve_compaction_provider()?;
184 self.compact_impl(messages, provider, model, store, session_id, None)
185 .await
186 .map(Some)
187 }
188
189 pub async fn compact_after_overflow(
200 &self,
201 messages: &[MessageRecord],
202 model_context: u32,
203 max_output: u32,
204 store: &dyn SessionStore,
205 session_id: Uuid,
206 ) -> RuntimeResult<CompactionResult> {
207 let _ = max_output;
208
209 let (provider, model) = self.resolve_compaction_provider()?;
210
211 let safe_keep = (model_context as usize)
215 .saturating_div(2)
216 .max(1_024)
217 .min(self.config.keep_tokens);
218
219 self.compact_impl(
220 messages,
221 provider,
222 model,
223 store,
224 session_id,
225 Some(safe_keep),
226 )
227 .await
228 }
229
230 async fn compact_impl(
236 &self,
237 messages: &[MessageRecord],
238 provider: Arc<dyn ChatProvider>,
239 model: ModelName,
240 store: &dyn SessionStore,
241 session_id: Uuid,
242 keep_tokens_override: Option<usize>,
243 ) -> RuntimeResult<CompactionResult> {
244 let effective_keep = keep_tokens_override.unwrap_or(self.config.keep_tokens);
245
246 let selection = select::select(messages, self.config.tail_turns, effective_keep);
247
248 if selection.head.is_empty() {
249 let dummy_id = Uuid::nil();
250 return Ok(CompactionResult {
251 summary_text: String::new(),
252 compaction_user_id: dummy_id,
253 summary_message_id: dummy_id,
254 tail_start_id: dummy_id,
255 tokens_saved: 0,
256 });
257 }
258
259 let tail_start_id = selection.tail_start_id.unwrap_or_else(Uuid::nil);
260
261 let previous_summary = store
262 .get_latest_compaction(&session_id)
263 .await
264 .map_err(RuntimeError::Storage)?
265 .and_then(|m| m.compaction_meta)
266 .and_then(|meta| meta.summary_text);
267
268 let prompt_text = prompt::build_prompt(&selection.head, previous_summary.as_deref());
269
270 let summary_text = self
271 .run_compaction_llm(&*provider, &model, &prompt_text)
272 .await?;
273
274 let compaction_user_id = Uuid::now_v7();
275 let compaction_user = MessageRecord {
276 id: compaction_user_id,
277 session_id,
278 role: MessageRole::User,
279 content: vec![behest_provider::ContentPart::text("[compaction]")],
280 tool_calls: Vec::new(),
281 tool_call_id: None,
282 tool_name: None,
283 usage: None,
284 created_at: chrono::Utc::now(),
285 is_compaction: true,
286 is_summary: false,
287 compaction_meta: Some(CompactionMeta::new(tail_start_id)),
288 };
289 store
290 .append_message(compaction_user)
291 .await
292 .map_err(RuntimeError::Storage)?;
293
294 let summary_message_id = Uuid::now_v7();
295 let summary_message = MessageRecord {
296 id: summary_message_id,
297 session_id,
298 role: MessageRole::Assistant,
299 content: vec![behest_provider::ContentPart::text(&summary_text)],
300 tool_calls: Vec::new(),
301 tool_call_id: None,
302 tool_name: None,
303 usage: None,
304 created_at: chrono::Utc::now(),
305 is_compaction: false,
306 is_summary: true,
307 compaction_meta: Some(
308 CompactionMeta::new(tail_start_id).with_summary(summary_text.clone()),
309 ),
310 };
311 store
312 .append_message(summary_message)
313 .await
314 .map_err(RuntimeError::Storage)?;
315
316 let head_tokens = estimate_records_tokens(&selection.head);
317 let summary_tokens = crate::token::estimate_tokens(&summary_text);
318
319 Ok(CompactionResult {
320 summary_text,
321 compaction_user_id,
322 summary_message_id,
323 tail_start_id,
324 tokens_saved: head_tokens.saturating_sub(summary_tokens),
325 })
326 }
327
328 async fn run_compaction_llm(
330 &self,
331 provider: &dyn ChatProvider,
332 model: &ModelName,
333 prompt: &str,
334 ) -> RuntimeResult<String> {
335 let request = ChatRequest::new(model.clone()).with_message(Message::user_text(prompt));
336
337 let response = provider
338 .complete(request)
339 .await
340 .map_err(RuntimeError::Provider)?;
341
342 let summary = extract_text_content(&response.message);
343
344 if summary.is_empty() {
345 return Err(RuntimeError::Provider(
346 behest_core::error::ProviderError::Decode {
347 provider: provider.id(),
348 message: "compaction LLM returned empty response".to_owned(),
349 },
350 ));
351 }
352
353 Ok(summary)
354 }
355
356 fn resolve_compaction_provider(&self) -> RuntimeResult<(Arc<dyn ChatProvider>, ModelName)> {
362 let provider_id = self
363 .config
364 .provider
365 .clone()
366 .or_else(|| self.providers.chat_ids().first().cloned())
367 .ok_or_else(|| {
368 RuntimeError::ProviderNotFound("no provider configured for compaction".to_owned())
369 })?;
370
371 let provider = self
372 .providers
373 .chat(&provider_id)
374 .ok_or_else(|| RuntimeError::ProviderNotFound(provider_id.to_string()))?;
375
376 let model = self
377 .config
378 .model
379 .clone()
380 .unwrap_or_else(|| ModelName::new("gpt-4o-mini"));
381
382 Ok((provider, model))
383 }
384}
385
386fn extract_text_content(message: &Message) -> String {
388 match message {
389 Message::Assistant { content, .. }
390 | Message::System { content }
391 | Message::User { content }
392 | Message::Tool { content, .. } => {
393 let mut text = String::new();
394 for part in content {
395 if let behest_provider::ContentPart::Text { text: t, .. } = part {
396 text.push_str(t);
397 }
398 }
399 text
400 }
401 _ => String::new(),
402 }
403}
404
405#[cfg(test)]
406#[allow(clippy::unwrap_used)]
407mod tests {
408 use super::*;
409 use crate::token::estimate_records_tokens;
410 use behest_store::{MessageRecord, MessageRole};
411 use uuid::Uuid;
412
413 fn make_user(text: &str) -> MessageRecord {
414 MessageRecord::new(
415 Uuid::now_v7(),
416 MessageRole::User,
417 vec![behest_provider::ContentPart::text(text)],
418 )
419 }
420
421 fn make_assistant(text: &str) -> MessageRecord {
422 MessageRecord::new(
423 Uuid::now_v7(),
424 MessageRole::Assistant,
425 vec![behest_provider::ContentPart::text(text)],
426 )
427 }
428
429 #[test]
430 fn compact_if_needed_skips_when_no_overflow() {
431 let messages = vec![make_user("hi"), make_assistant("hello")];
432 let total = estimate_records_tokens(&messages);
433 assert!(!overflow::is_overflow(total, 128_000, 16_384, true,));
435 }
436
437 #[test]
438 fn overflow_detected_on_large_context() {
439 let large_text = "x".repeat(100_000); let messages = vec![
442 make_user(&large_text),
443 make_assistant(&large_text),
444 make_user(&large_text),
445 make_assistant(&large_text),
446 make_user(&large_text),
447 make_assistant(&large_text),
448 ];
449 let total = estimate_records_tokens(&messages);
450 assert!(overflow::is_overflow(total, 32_000, 4_096, true,));
452 }
453
454 #[test]
455 fn extract_text_from_assistant() {
456 let msg = Message::assistant_text("summary text");
457 assert_eq!(extract_text_content(&msg), "summary text");
458 }
459
460 #[test]
461 fn breaker_starts_closed() {
462 let breaker = CompactionCircuitBreaker::new(3);
463 assert!(!breaker.is_open());
464 assert_eq!(breaker.consecutive_failures(), 0);
465 }
466
467 #[test]
468 fn breaker_opens_after_threshold_failures() {
469 let mut breaker = CompactionCircuitBreaker::new(3);
470 assert!(!breaker.record_failure());
471 assert!(!breaker.is_open());
472 assert!(!breaker.record_failure());
473 assert!(!breaker.is_open());
474 assert!(breaker.record_failure());
475 assert!(breaker.is_open());
476 assert_eq!(breaker.consecutive_failures(), 3);
477 }
478
479 #[test]
480 fn breaker_resets_on_success() {
481 let mut breaker = CompactionCircuitBreaker::new(3);
482 breaker.record_failure();
483 breaker.record_failure();
484 assert!(!breaker.is_open());
485
486 breaker.record_success();
487 assert!(!breaker.is_open());
488 assert_eq!(breaker.consecutive_failures(), 0);
489
490 breaker.record_failure();
492 breaker.record_failure();
493 assert!(!breaker.is_open());
494 breaker.record_failure();
495 assert!(breaker.is_open());
496 }
497
498 #[test]
499 fn breaker_after_open_does_not_count_further() {
500 let mut breaker = CompactionCircuitBreaker::new(2);
501 breaker.record_failure();
502 assert!(breaker.record_failure());
503 assert!(breaker.is_open());
504
505 assert!(!breaker.record_failure());
507 assert!(breaker.is_open());
508 assert_eq!(breaker.consecutive_failures(), 2);
509 }
510
511 #[test]
512 fn breaker_threshold_zero_opens_immediately() {
513 let mut breaker = CompactionCircuitBreaker::new(0);
514 assert!(breaker.record_failure());
515 assert!(breaker.is_open());
516 }
517}