Skip to main content

oxi_ai/
compaction_seam.rs

1//! Compaction trait seams — ported from grok-build
2//! `xai-grok-compaction/src/{item,sampler,token}.rs` (Apache-2.0).
3//!
4//! These traits decouple the compaction algorithm from the specific
5//! conversation type. Hosts implement [`CompactionItem`] for their
6//! message enum and [`CompactionSampler`] for their LLM transport; the
7//! shared algorithm operates on the trait, not the concrete type.
8
9use std::pin::Pin;
10
11use crate::{ContentBlock, Message, MessageContent};
12
13/// Harness-agnostic role of a single conversation item.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CompactionRole {
16    /// System prompt.
17    System,
18    /// User message.
19    User,
20    /// Assistant message (may carry tool calls).
21    Assistant,
22    /// Tool result.
23    Tool,
24}
25
26/// Contract: one turn/item in a conversation, as seen by the shared
27/// compaction algorithms.
28///
29/// All methods are **required** (no defaults) because a forgotten
30/// implementation would silently drop prior summaries on re-compaction.
31pub trait CompactionItem {
32    /// The harness-agnostic role of this item.
33    fn role(&self) -> CompactionRole;
34    /// Text content, if any. Tool-only turns may return `None`.
35    fn text(&self) -> Option<String>;
36    /// Whether this is a tool result message.
37    fn is_tool_result(&self) -> bool;
38    /// Whether this assistant item has at least one tool call.
39    fn has_tool_requests(&self) -> bool;
40    /// Whether this item carries a prior compaction summary.
41    fn is_compaction_summary(&self) -> bool;
42}
43
44/// Interface for the LLM call that produces compaction summaries.
45pub trait CompactionSampler: Send + Sync {
46    /// Produce a summary for `prompt`.
47    fn sample<'a>(
48        &'a self,
49        prompt: &'a str,
50    ) -> Pin<Box<dyn Future<Output = Result<String, CompactionSampleError>> + Send + 'a>>;
51    /// Human-readable backend name for diagnostics.
52    fn name(&self) -> &str;
53}
54
55/// Error from a compaction sampling call.
56#[derive(Debug)]
57pub enum CompactionSampleError {
58    /// Nothing to compact (empty input).
59    NothingToCompact,
60    /// The model returned no usable text.
61    EmptyResponse,
62    /// A transport error. `deterministic` flags whether retrying could help.
63    Transport {
64        /// Error message.
65        message: String,
66        /// Whether re-sending the same input cannot help.
67        deterministic: bool,
68    },
69}
70
71impl std::fmt::Display for CompactionSampleError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::NothingToCompact => write!(f, "nothing to compact"),
75            Self::EmptyResponse => {
76                write!(f, "compaction model returned an empty summary")
77            }
78            Self::Transport { message, .. } => {
79                write!(f, "compaction sampling failed: {message}")
80            }
81        }
82    }
83}
84
85impl std::error::Error for CompactionSampleError {}
86/// Token counter for compaction budget calculations.
87pub trait ItemTokenCounter: Send + Sync {
88    /// Estimate the token count of `text`.
89    fn count_tokens(&self, text: &str) -> usize;
90}
91
92/// Heuristic token counter: `bytes / 4`.
93#[derive(Debug, Clone, Copy, Default)]
94pub struct HeuristicTokenCounter;
95
96impl ItemTokenCounter for HeuristicTokenCounter {
97    fn count_tokens(&self, text: &str) -> usize {
98        (text.len() / 4).max(1)
99    }
100}
101
102// ── impl CompactionItem for Message ───────────────────────────────
103
104impl CompactionItem for Message {
105    fn role(&self) -> CompactionRole {
106        match self {
107            Message::User(_) => CompactionRole::User,
108            Message::Assistant(_) => CompactionRole::Assistant,
109            Message::ToolResult(_) => CompactionRole::Tool,
110        }
111    }
112
113    fn text(&self) -> Option<String> {
114        match self {
115            Message::User(u) => extract_text_from_content(&u.content),
116            Message::Assistant(a) => {
117                let texts: Vec<&str> = a.content.iter().filter_map(|b| b.as_text()).collect();
118                if texts.is_empty() {
119                    None
120                } else {
121                    Some(texts.join("\n"))
122                }
123            }
124            Message::ToolResult(t) => {
125                let texts: Vec<&str> = t.content.iter().filter_map(|b| b.as_text()).collect();
126                if texts.is_empty() {
127                    None
128                } else {
129                    Some(texts.join("\n"))
130                }
131            }
132        }
133    }
134
135    fn is_tool_result(&self) -> bool {
136        matches!(self, Message::ToolResult(_))
137    }
138
139    fn has_tool_requests(&self) -> bool {
140        match self {
141            Message::Assistant(a) => a
142                .content
143                .iter()
144                .any(|b| matches!(b, ContentBlock::ToolCall(_))),
145            _ => false,
146        }
147    }
148
149    fn is_compaction_summary(&self) -> bool {
150        match self {
151            Message::User(u) => match &u.content {
152                MessageContent::Text(s) => s.starts_with("[Branch summary"),
153                _ => false,
154            },
155            _ => false,
156        }
157    }
158}
159
160fn extract_text_from_content(content: &MessageContent) -> Option<String> {
161    match content {
162        MessageContent::Text(s) => Some(s.clone()),
163        MessageContent::Blocks(blocks) => {
164            let texts: Vec<&str> = blocks.iter().filter_map(|b| b.as_text()).collect();
165            if texts.is_empty() {
166                None
167            } else {
168                Some(texts.join("\n"))
169            }
170        }
171    }
172}
173
174/// Select a split point that keeps tool-request/result pairs together.
175/// Returns the index of the first item to **summarise**.
176pub fn select_split_point(items: &[Message], keep_recent: usize) -> usize {
177    if items.len() <= keep_recent {
178        return items.len();
179    }
180    let candidate = items.len().saturating_sub(keep_recent);
181    let mut cut = candidate;
182    while cut > 0 {
183        let item = &items[cut];
184        if item.is_tool_result() {
185            cut -= 1;
186            continue;
187        }
188        if cut > 0 {
189            let prev = &items[cut - 1];
190            if prev.has_tool_requests() {
191                cut -= 1;
192                continue;
193            }
194        }
195        break;
196    }
197    cut
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::{AssistantMessage, Message, UserMessage};
204
205    #[test]
206    fn role_classification() {
207        assert_eq!(
208            Message::User(UserMessage::new("hi")).role(),
209            CompactionRole::User
210        );
211    }
212
213    #[test]
214    fn text_extraction_user() {
215        let msg = Message::User(UserMessage::new("hello world"));
216        assert_eq!(msg.text().as_deref(), Some("hello world"));
217    }
218
219    #[test]
220    fn is_tool_result_classification() {
221        assert!(!Message::User(UserMessage::new("hi")).is_tool_result());
222    }
223
224    #[test]
225    fn has_tool_requests_false_for_plain_text() {
226        let msg = Message::Assistant(AssistantMessage::new(
227            crate::Api::OpenAiCompletions,
228            "test",
229            "test-model",
230        ));
231        assert!(!msg.has_tool_requests());
232    }
233
234    #[test]
235    fn is_compaction_summary_detects_branch_marker() {
236        let msg = Message::User(UserMessage::new(
237            "[Branch summary of 5 msgs] topics: memory",
238        ));
239        assert!(msg.is_compaction_summary());
240    }
241
242    #[test]
243    fn is_compaction_summary_false_for_regular_user() {
244        assert!(!Message::User(UserMessage::new("just a question")).is_compaction_summary());
245    }
246
247    #[test]
248    fn heuristic_token_counter_basic() {
249        let c = HeuristicTokenCounter;
250        assert!(c.count_tokens("hello world") > 0);
251    }
252
253    #[test]
254    fn select_split_point_keeps_recent() {
255        let msgs = vec![
256            Message::User(UserMessage::new("a")),
257            Message::User(UserMessage::new("b")),
258            Message::User(UserMessage::new("c")),
259            Message::User(UserMessage::new("d")),
260            Message::User(UserMessage::new("e")),
261        ];
262        let split = select_split_point(&msgs, 2);
263        assert_eq!(split, 3);
264    }
265
266    #[test]
267    fn select_split_point_all_kept_when_under_threshold() {
268        let msgs = vec![
269            Message::User(UserMessage::new("a")),
270            Message::User(UserMessage::new("b")),
271        ];
272        assert_eq!(select_split_point(&msgs, 5), 2);
273    }
274
275    #[test]
276    fn sample_error_display() {
277        assert_eq!(
278            format!("{}", CompactionSampleError::NothingToCompact),
279            "nothing to compact"
280        );
281    }
282}