oxi_ai/
compaction_seam.rs1use std::pin::Pin;
10
11use crate::{ContentBlock, Message, MessageContent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CompactionRole {
16 System,
18 User,
20 Assistant,
22 Tool,
24}
25
26pub trait CompactionItem {
32 fn role(&self) -> CompactionRole;
34 fn text(&self) -> Option<String>;
36 fn is_tool_result(&self) -> bool;
38 fn has_tool_requests(&self) -> bool;
40 fn is_compaction_summary(&self) -> bool;
42}
43
44pub trait CompactionSampler: Send + Sync {
46 fn sample<'a>(
48 &'a self,
49 prompt: &'a str,
50 ) -> Pin<Box<dyn Future<Output = Result<String, CompactionSampleError>> + Send + 'a>>;
51 fn name(&self) -> &str;
53}
54
55#[derive(Debug)]
57pub enum CompactionSampleError {
58 NothingToCompact,
60 EmptyResponse,
62 Transport {
64 message: String,
66 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 {}
86pub trait ItemTokenCounter: Send + Sync {
88 fn count_tokens(&self, text: &str) -> usize;
90}
91
92#[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
102impl 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
174pub 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}