1use serde_json::{Value, json};
9
10use lean_ctx::core::tokens::{TokenizerFamily, count_tokens_for};
11
12use super::compress::compress_tool_result_gateway_with_policy;
13use lean_ctx::core::adaptive_policy::CompressionPolicy;
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct LiveZoneSplit {
18 pub frozen_messages: Vec<Value>,
19 pub live_messages: Vec<Value>,
20 pub boundary_turn: usize,
21 pub frozen_tokens_estimate: u64,
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq)]
26pub struct LiveZoneStats {
27 pub frozen_tokens: u64,
28 pub live_tokens_before: u64,
29 pub live_tokens_after: u64,
30 pub savings_pct: f64,
31}
32
33pub fn detect_live_zone(messages: &[Value]) -> LiveZoneSplit {
39 let marked_boundary = messages
40 .iter()
41 .enumerate()
42 .filter(|(_, message)| has_cache_control(message))
43 .map(|(index, _)| index + 1)
44 .max();
45 let fallback_boundary = messages.len().saturating_sub(3);
46 let mut boundary_turn = marked_boundary.unwrap_or(fallback_boundary);
47
48 if let Some(last_system) = messages
50 .iter()
51 .enumerate()
52 .filter(|(_, message)| role(message) == Some("system"))
53 .map(|(index, _)| index)
54 .max()
55 {
56 boundary_turn = boundary_turn.max(last_system + 1);
57 }
58 boundary_turn = boundary_turn.min(messages.len());
59
60 let frozen_messages = messages[..boundary_turn].to_vec();
61 let live_messages = messages[boundary_turn..].to_vec();
62 let frozen_tokens_estimate = messages_tokens(&frozen_messages, TokenizerFamily::default());
63 LiveZoneSplit {
64 frozen_messages,
65 live_messages,
66 boundary_turn,
67 frozen_tokens_estimate,
68 }
69}
70
71pub fn compress_live_only(
75 messages: &mut [Value],
76 family: TokenizerFamily,
77 policy: CompressionPolicy,
78) -> LiveZoneStats {
79 let split = detect_live_zone(messages);
80 let mut stats = LiveZoneStats {
81 frozen_tokens: messages_tokens(&split.frozen_messages, family),
82 live_tokens_before: messages_tokens(&split.live_messages, family),
83 ..LiveZoneStats::default()
84 };
85
86 for message in &mut messages[split.boundary_turn..] {
87 compress_message(message, family, policy);
88 }
89
90 stats.live_tokens_after = messages_tokens(&messages[split.boundary_turn..], family);
91 stats.savings_pct = percentage_saved(stats.live_tokens_before, stats.live_tokens_after);
92 stats
93}
94
95pub fn add_cache_markers(messages: &mut [Value]) {
100 let boundary = detect_live_zone(messages).boundary_turn;
101 let idx = if boundary > 0 && boundary <= messages.len() {
102 boundary - 1
103 } else {
104 messages.len().saturating_sub(1)
105 };
106 let Some(message) = messages.get_mut(idx) else {
107 return;
108 };
109
110 if has_cache_control(message) {
111 return;
112 }
113
114 let Some(object) = message.as_object_mut() else {
115 return;
116 };
117 match object.get_mut("content") {
118 Some(Value::Array(blocks)) => {
119 if let Some(block) = blocks.last_mut().and_then(Value::as_object_mut) {
120 block.insert("cache_control".into(), ephemeral());
121 }
122 }
123 Some(Value::String(text)) => {
124 let text = std::mem::take(text);
125 object.insert(
126 "content".into(),
127 Value::Array(vec![json!({
128 "type": "text",
129 "text": text,
130 "cache_control": ephemeral(),
131 })]),
132 );
133 }
134 _ => {}
135 }
136}
137
138fn compress_message(message: &mut Value, family: TokenizerFamily, policy: CompressionPolicy) {
139 let tool_name = message
140 .get("name")
141 .and_then(Value::as_str)
142 .map(str::to_owned);
143 if let Some(content) = message.get_mut("content") {
144 compress_content(content, tool_name.as_deref(), family, policy);
145 }
146}
147
148fn compress_content(
149 content: &mut Value,
150 tool_name: Option<&str>,
151 family: TokenizerFamily,
152 policy: CompressionPolicy,
153) {
154 match content {
155 Value::String(text) => compress_text(text, tool_name, family, policy),
156 Value::Array(blocks) => {
157 for block in blocks {
158 let Some(object) = block.as_object_mut() else {
159 continue;
160 };
161 let name = object
162 .get("name")
163 .and_then(Value::as_str)
164 .map(str::to_owned);
165 let block_type = object
166 .get("type")
167 .and_then(Value::as_str)
168 .map(str::to_owned);
169 match block_type.as_deref() {
170 Some("text") => {
171 if let Some(Value::String(text)) = object.get_mut("text") {
172 compress_text(text, name.as_deref(), family, policy);
173 }
174 }
175 Some("tool_result") => {
176 if let Some(inner) = object.get_mut("content") {
177 compress_content(inner, name.as_deref(), family, policy);
178 }
179 }
180 _ => {}
181 }
182 }
183 }
184 _ => {}
185 }
186}
187
188fn compress_text(
189 text: &mut String,
190 tool_name: Option<&str>,
191 family: TokenizerFamily,
192 policy: CompressionPolicy,
193) {
194 let compressed = compress_tool_result_gateway_with_policy(text, tool_name, family, policy);
195 if compressed.len() < text.len() {
196 *text = compressed;
197 }
198}
199
200fn messages_tokens(messages: &[Value], family: TokenizerFamily) -> u64 {
201 messages
202 .iter()
203 .map(|message| count_tokens_for(&message.to_string(), family) as u64)
204 .sum()
205}
206
207fn percentage_saved(before: u64, after: u64) -> f64 {
208 if before == 0 {
209 0.0
210 } else {
211 ((before.saturating_sub(after)) as f64 / before as f64) * 100.0
212 }
213}
214
215fn role(message: &Value) -> Option<&str> {
216 message.get("role").and_then(Value::as_str)
217}
218
219fn has_cache_control(message: &Value) -> bool {
220 message.get("cache_control").is_some()
221 || message
222 .get("content")
223 .and_then(Value::as_array)
224 .is_some_and(|blocks| {
225 blocks
226 .iter()
227 .any(|block| block.get("cache_control").is_some())
228 })
229}
230
231fn ephemeral() -> Value {
232 json!({"type": "ephemeral"})
233}
234
235#[cfg(test)]
236mod tests {
237 use serde_json::json;
238
239 use super::*;
240
241 fn policy() -> CompressionPolicy {
242 lean_ctx::core::adaptive_policy::select_policy("chat")
243 }
244
245 fn verbose_text() -> String {
246 (0..80)
247 .map(|index| format!("log line {index}: repeated operational detail for compression"))
248 .collect::<Vec<_>>()
249 .join("\n")
250 }
251
252 #[test]
253 fn system_messages_always_frozen() {
254 let messages = vec![
255 json!({"role": "user", "content": "old"}),
256 json!({"role": "system", "content": "do not alter"}),
257 json!({"role": "user", "content": "new"}),
258 ];
259 let split = detect_live_zone(&messages);
260 assert_eq!(split.boundary_turn, 2);
261 assert_eq!(split.frozen_messages[1], messages[1]);
262 }
263
264 #[test]
265 fn cache_control_freezes_preceding_messages() {
266 let messages = vec![
267 json!({"role": "user", "content": "old"}),
268 json!({"role": "assistant", "content": [{"type":"text", "text":"cached", "cache_control":{"type":"ephemeral"}}]}),
269 json!({"role": "user", "content": "new"}),
270 ];
271 let split = detect_live_zone(&messages);
272 assert_eq!(split.boundary_turn, 2);
273 assert_eq!(split.frozen_messages, messages[..2]);
274 assert_eq!(split.live_messages, messages[2..]);
275 }
276
277 #[test]
278 fn live_messages_are_compressed() {
279 let _data = lean_ctx::core::data_dir::isolated_data_dir();
280 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
281 let mut messages = vec![
282 json!({"role": "user", "content": "old"}),
283 json!({"role": "assistant", "content": "cached"}),
284 json!({"role": "user", "content": "old too"}),
285 json!({"role": "tool", "name": "bash", "content": verbose_text()}),
286 ];
287 let before = messages[3].clone();
288 let stats = compress_live_only(&mut messages, TokenizerFamily::default(), policy());
289 assert!(stats.live_tokens_after < stats.live_tokens_before);
290 assert_ne!(messages[3], before);
291 }
292
293 #[test]
294 fn frozen_messages_remain_byte_identical() {
295 let mut messages = vec![
296 json!({"role": "system", "content": "stable system"}),
297 json!({"role": "user", "content": [{"type":"text", "text":"cached", "cache_control":{"type":"ephemeral"}}]}),
298 json!({"role": "assistant", "content": "answer"}),
299 json!({"role": "tool", "name": "bash", "content": verbose_text()}),
300 ];
301 let frozen = messages[..2]
302 .iter()
303 .map(Value::to_string)
304 .collect::<Vec<_>>();
305 compress_live_only(&mut messages, TokenizerFamily::default(), policy());
306 assert_eq!(
307 frozen,
308 messages[..2]
309 .iter()
310 .map(Value::to_string)
311 .collect::<Vec<_>>(),
312 );
313 }
314
315 #[test]
316 fn stats_report_frozen_and_live_token_counts() {
317 let mut messages = vec![
318 json!({"role": "system", "content": "stable"}),
319 json!({"role": "user", "content": "old"}),
320 json!({"role": "assistant", "content": "answer"}),
321 json!({"role": "tool", "name": "bash", "content": verbose_text()}),
322 ];
323 let stats = compress_live_only(&mut messages, TokenizerFamily::default(), policy());
324 assert!(stats.frozen_tokens > 0);
325 assert!(stats.live_tokens_before >= stats.live_tokens_after);
326 assert!(stats.savings_pct >= 0.0);
327 }
328
329 #[test]
330 fn cache_marker_is_added_at_boundary() {
331 let mut messages = vec![
332 json!({"role": "user", "content": "old"}),
333 json!({"role": "assistant", "content": "answer"}),
334 json!({"role": "user", "content": "new"}),
335 json!({"role": "user", "content": "latest"}),
336 ];
337 add_cache_markers(&mut messages);
338 assert!(has_cache_control(&messages[0]));
339 }
340}