Skip to main content

opi_agent/
compaction.rs

1//! Compaction engine for managing conversation context size (S9.5).
2//!
3//! Provides manual, threshold-based, and overflow-triggered compaction
4//! with hook extensibility for custom summary generation.
5
6use thiserror::Error;
7
8use crate::message::AgentMessage;
9use crate::session_event::CompactionReason;
10
11/// Configuration for compaction behavior.
12#[derive(Debug, Clone, PartialEq)]
13pub struct CompactionConfig {
14    pub enabled: bool,
15    pub threshold_tokens: u64,
16}
17
18impl Default for CompactionConfig {
19    fn default() -> Self {
20        Self {
21            enabled: true,
22            threshold_tokens: 100_000,
23        }
24    }
25}
26
27/// A conversation entry with its session ID, for compaction input.
28#[derive(Debug, Clone)]
29pub struct Entry {
30    pub id: String,
31    pub message: AgentMessage,
32}
33
34/// Whether the summary came from the core engine or a hook.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SummarySource {
37    Core,
38    Hook,
39}
40
41/// Result of a compaction operation.
42#[derive(Debug, Clone)]
43pub struct CompactionOutput {
44    pub reason: CompactionReason,
45    pub summary_text: String,
46    pub first_kept_entry_id: String,
47    pub tokens_before: u64,
48    pub tokens_after: u64,
49    pub kept_entries: Vec<Entry>,
50    pub summary_source: SummarySource,
51}
52
53/// Errors from compaction operations.
54#[derive(Debug, Error)]
55pub enum CompactionError {
56    #[error("nothing to compact")]
57    NothingToCompact,
58}
59
60/// Hook trait for customizing compaction summary generation.
61pub trait CompactionHooks: Send + Sync {
62    /// Generate a summary for the messages being compacted.
63    /// Return `None` to fall back to the core summary generator.
64    fn generate_summary(&self, messages: &[AgentMessage]) -> Option<String>;
65}
66
67/// Default no-op hooks that always return `None` (core summary used).
68pub struct DefaultCompactionHooks;
69
70impl CompactionHooks for DefaultCompactionHooks {
71    fn generate_summary(&self, _messages: &[AgentMessage]) -> Option<String> {
72        None
73    }
74}
75
76/// The compaction engine.
77pub struct CompactionEngine {
78    config: CompactionConfig,
79}
80
81impl CompactionEngine {
82    pub fn new(config: CompactionConfig) -> Self {
83        Self { config }
84    }
85
86    /// Check if compaction should be triggered.
87    pub fn should_compact(&self, total_tokens: u64, reason: CompactionReason) -> bool {
88        match reason {
89            CompactionReason::Manual => true,
90            CompactionReason::Overflow => self.config.enabled,
91            CompactionReason::Threshold => {
92                self.config.enabled && total_tokens >= self.config.threshold_tokens
93            }
94        }
95    }
96
97    /// Execute compaction on the given entries.
98    pub fn compact(
99        &self,
100        entries: &[Entry],
101        reason: CompactionReason,
102        hooks: &dyn CompactionHooks,
103    ) -> Result<CompactionOutput, CompactionError> {
104        if entries.len() < 2 {
105            return Err(CompactionError::NothingToCompact);
106        }
107
108        let tokens_before = estimate_total_tokens(entries);
109
110        // Always keep the last entry; try to keep recent entries up to a
111        // reasonable fraction of the threshold.
112        let split_idx = find_split_point(entries);
113
114        let (compacted, kept) = entries.split_at(split_idx);
115        if kept.is_empty() {
116            return Err(CompactionError::NothingToCompact);
117        }
118
119        let first_kept_entry_id = kept[0].id.clone();
120
121        // Try hook first, fall back to core summary
122        let compacted_messages: Vec<AgentMessage> =
123            compacted.iter().map(|e| e.message.clone()).collect();
124        let (summary_text, source) = match hooks.generate_summary(&compacted_messages) {
125            Some(s) => (s, SummarySource::Hook),
126            None => (
127                generate_core_summary(&compacted_messages),
128                SummarySource::Core,
129            ),
130        };
131
132        let kept_entries = kept.to_vec();
133        let tokens_after = estimate_total_tokens(&kept_entries);
134
135        Ok(CompactionOutput {
136            reason,
137            summary_text,
138            first_kept_entry_id,
139            tokens_before,
140            tokens_after,
141            kept_entries,
142            summary_source: source,
143        })
144    }
145}
146
147/// Find the split point: keep the last 25% of entries (minimum 1), compact the rest.
148fn find_split_point(entries: &[Entry]) -> usize {
149    if entries.is_empty() {
150        return 0;
151    }
152
153    // Always keep at least the last entry
154    if entries.len() == 1 {
155        return 0;
156    }
157
158    // Keep the last 25% of entries, minimum 1
159    let min_keep = 1;
160    let proportional = entries.len() / 4;
161    let keep_count = proportional.max(min_keep);
162
163    entries.len().saturating_sub(keep_count)
164}
165
166/// Estimate total tokens for a set of entries.
167fn estimate_total_tokens(entries: &[Entry]) -> u64 {
168    entries.iter().map(estimate_entry_tokens).sum()
169}
170
171/// Estimate tokens for a single entry using character heuristic.
172fn estimate_entry_tokens(entry: &Entry) -> u64 {
173    estimate_message_tokens(&entry.message)
174}
175
176/// Estimate tokens in an AgentMessage (rough: chars / 4).
177fn estimate_message_tokens(msg: &AgentMessage) -> u64 {
178    let text = extract_text(msg);
179    text.len() as u64 / 4
180}
181
182/// Extract displayable text from an AgentMessage for summary generation.
183fn extract_text(msg: &AgentMessage) -> String {
184    match msg {
185        AgentMessage::Llm(opi_ai::message::Message::User(u)) => u
186            .content
187            .iter()
188            .filter_map(|c| match c {
189                opi_ai::message::InputContent::Text { text } => Some(text.as_str()),
190                _ => None,
191            })
192            .collect::<Vec<_>>()
193            .join(" "),
194        AgentMessage::Llm(opi_ai::message::Message::Assistant(a)) => a
195            .content
196            .iter()
197            .filter_map(|c| match c {
198                opi_ai::message::AssistantContent::Text { text } => Some(text.as_str()),
199                _ => None,
200            })
201            .collect::<Vec<_>>()
202            .join(" "),
203        AgentMessage::Llm(opi_ai::message::Message::ToolResult(tr)) => tr
204            .content
205            .iter()
206            .filter_map(|c| match c {
207                opi_ai::message::OutputContent::Text { text } => Some(text.as_str()),
208                _ => None,
209            })
210            .collect::<Vec<_>>()
211            .join(" "),
212        AgentMessage::CompactionSummary(cs) => cs.summary.clone(),
213        AgentMessage::BranchSummary(bs) => bs.summary.clone(),
214        AgentMessage::Custom(c) => c.data.to_string(),
215        _ => String::new(),
216    }
217}
218
219/// Generate a core summary from compacted messages.
220fn generate_core_summary(messages: &[AgentMessage]) -> String {
221    let texts: Vec<String> = messages.iter().map(extract_text).collect();
222    let combined = texts.join(". ");
223    let byte_count = combined.len();
224
225    if byte_count <= 500 {
226        format!("Compacted {} messages: {}", messages.len(), combined)
227    } else {
228        // Truncate to ~500 chars, finding a word boundary
229        let truncated = &combined[..combined
230            .char_indices()
231            .take_while(|(i, _)| *i < 497)
232            .last()
233            .map(|(i, _)| i)
234            .unwrap_or(497)];
235        format!("Compacted {} messages: {}...", messages.len(), truncated)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn estimate_tokens_basic() {
245        let msg = AgentMessage::Llm(opi_ai::message::Message::User(
246            opi_ai::message::UserMessage {
247                content: vec![opi_ai::message::InputContent::Text {
248                    text: "Hello world test".into(), // 17 chars → ~4 tokens
249                }],
250                timestamp_ms: 0,
251            },
252        ));
253        let tokens = estimate_message_tokens(&msg);
254        assert_eq!(tokens, 4, "17 chars / 4 = 4 tokens");
255    }
256
257    #[test]
258    fn split_point_keeps_tail() {
259        let entries: Vec<Entry> = (0..10)
260            .map(|i| Entry {
261                id: format!("e{}", i),
262                message: AgentMessage::Llm(opi_ai::message::Message::User(
263                    opi_ai::message::UserMessage {
264                        content: vec![opi_ai::message::InputContent::Text {
265                            text: format!("msg {}", i),
266                        }],
267                        timestamp_ms: 0,
268                    },
269                )),
270            })
271            .collect();
272
273        let split = find_split_point(&entries);
274        assert_eq!(split, 8, "should keep last 2 of 10 entries");
275        assert_eq!(entries[split].id, "e8");
276    }
277}