Skip to main content

behest_runtime/compaction/
mod.rs

1//! Context compaction — LLM-driven conversation summarisation.
2//!
3//! When a conversation approaches the model's context window, older
4//! messages are summarised by a smaller/cheaper "compaction" LLM call
5//! into a structured anchored summary. This frees context tokens while
6//! preserving the semantic continuity of the conversation.
7//!
8//! # Sub-modules
9//!
10//! - [`select`] — Turn-based message selection (head vs tail).
11//! - [`prompt`] — Anchored summary prompt template.
12//! - [`overflow`] — Context overflow detection.
13//! - [`prune`] — Old tool output pruning.
14//!
15//! # Architecture
16//!
17//! ```text
18//! compact_if_needed() / compact_after_overflow()
19//!   ├── overflow::is_overflow()      — detect if compaction is needed
20//!   ├── select::select()             — split messages into head/tail
21//!   ├── prompt::build_prompt()       — construct anchored summary prompt
22//!   └── run_compaction()             — call compaction LLM
23//!       └── store compaction result  — persist compaction messages
24//! ```
25//!
26//! Ported from OpenCode V1/V2 compaction infrastructure.
27
28pub 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/// Circuit breaker that gates compaction calls after repeated failures.
44///
45/// When consecutive compaction failures reach the configured threshold,
46/// the breaker opens and all proactive compaction is skipped for the
47/// remainder of the run. Reactive compaction (triggered by a provider
48/// context overflow error) is still attempted.
49#[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    /// Creates a new breaker with the given failure threshold.
66    #[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    /// Records a successful compaction, resetting the failure count and
76    /// closing the breaker.
77    pub fn record_success(&mut self) {
78        self.consecutive_failures = 0;
79        self.state = BreakerState::Closed;
80    }
81
82    /// Records a failed compaction. If the failure count reaches the
83    /// threshold, the breaker opens. Returns `true` when the breaker
84    /// transitions from closed to open (so the caller can emit an event).
85    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    /// Returns `true` when the breaker is open and proactive compaction
100    /// should be skipped.
101    #[must_use]
102    pub fn is_open(&self) -> bool {
103        matches!(self.state, BreakerState::Open { .. })
104    }
105
106    /// Returns the number of consecutive failures recorded so far.
107    #[must_use]
108    pub fn consecutive_failures(&self) -> u32 {
109        self.consecutive_failures
110    }
111}
112
113/// Service for LLM-driven conversational context compaction.
114///
115/// When conversation history approaches a model's context window, this
116/// service summarises older messages via a smaller/cheaper "compaction"
117/// LLM call. The resulting anchored summary preserves semantic continuity
118/// while freeing tokens for the main provider.
119///
120/// Supports both proactive compaction (before each turn) and reactive
121/// compaction (after a provider context-overflow error). A circuit breaker
122/// gates repeated failures to avoid cascading costs.
123pub struct CompactionService {
124    /// Provider registry for model access.
125    providers: behest_provider::ProviderRegistry,
126    /// Compaction configuration.
127    config: crate::policy::CompactionConfig,
128}
129
130/// Result of a successful compaction.
131#[derive(Debug, Clone)]
132pub struct CompactionResult {
133    /// The LLM-generated summary text.
134    pub summary_text: String,
135    /// Message ID of the compaction user message (stored in session).
136    pub compaction_user_id: Uuid,
137    /// Message ID of the compaction assistant message (contains the summary).
138    pub summary_message_id: Uuid,
139    /// First retained message ID after the compaction boundary.
140    pub tail_start_id: Uuid,
141    /// Estimated tokens freed by this compaction.
142    pub tokens_saved: usize,
143}
144
145impl CompactionService {
146    /// Creates a new compaction service.
147    #[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    /// Returns the compaction configuration.
156    #[must_use]
157    pub fn config(&self) -> &crate::policy::CompactionConfig {
158        &self.config
159    }
160
161    /// Proactive compaction: called before each provider turn to check
162    /// whether the conversation will overflow the context window.
163    ///
164    /// Returns `Ok(None)` when compaction is not needed. Returns
165    /// `Ok(Some(result))` when compaction was performed successfully.
166    ///
167    /// # Errors
168    /// Returns [`RuntimeError`] when the compaction LLM call or storage fails.
169    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    /// Reactive compaction: called after the provider returns a context
190    /// overflow error. This always performs compaction (does not check
191    /// `is_overflow` first).
192    ///
193    /// Uses `model_context` to compute a safe [`crate::policy::CompactionConfig::keep_tokens`]
194    /// override so the compaction prompt itself does not exceed the compaction
195    /// model's context window.
196    ///
197    /// # Errors
198    /// Returns [`RuntimeError`] when the compaction LLM call or storage fails.
199    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        // The compaction model may have a smaller context window than the run
212        // model. Reserve half the context for the prompt and head material,
213        // the other half for the tail we want to keep.
214        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    /// Internal compaction implementation.
231    ///
232    /// `keep_tokens_override` allows callers to override the configured
233    /// [`CompactionConfig::keep_tokens`] — used by [`compact_after_overflow`]
234    /// to ensure the compaction prompt fits within the overflow model's context.
235    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    /// Calls the compaction LLM with the summarisation prompt.
329    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    /// Resolves the provider and model to use for compaction.
357    ///
358    /// Order of precedence:
359    /// 1. `config.provider` / `config.model` (explicit compaction provider/model)
360    /// 2. The default provider/model available in the registry
361    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
386/// Extracts plain text content from an assistant message.
387fn 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        // With gpt-4o's 128K context, these few messages won't overflow
434        assert!(!overflow::is_overflow(total, 128_000, 16_384, true,));
435    }
436
437    #[test]
438    fn overflow_detected_on_large_context() {
439        // Simulate a large conversation
440        let large_text = "x".repeat(100_000); // ~25K tokens
441        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        // With a 32K context model, this should overflow
451        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        // Should need 3 fresh failures to open again
491        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        // Further failures don't re-trigger
506        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}