1use serde_json::{Map, Value, json};
16
17use lean_ctx::core::tokens::count_tokens;
18use lean_ctx::core::transcript_compact::summarize_content;
19
20const PROTECTED_ROLES: [&str; 2] = ["system", "developer"];
21const SUMMARY_MARKER: &str = "[lean-ctx] compacted-context";
22const MAX_USER_SNIPPETS: usize = 24;
23const OFFLOAD_MAX_CHARS: usize = 8_000;
24
25pub struct CompactResult {
27 pub messages: Vec<Value>,
29 pub summarized: Vec<Value>,
31 pub original_tokens: usize,
32 pub compacted_tokens: usize,
33 pub did_compact: bool,
34}
35
36fn role(m: &Value) -> &str {
37 m.get("role").and_then(Value::as_str).unwrap_or("")
38}
39
40fn is_protected(m: &Value) -> bool {
41 PROTECTED_ROLES.contains(&role(m))
42}
43
44fn has_tool_calls(m: &Value) -> bool {
45 m.get("tool_calls")
46 .and_then(Value::as_array)
47 .is_some_and(|a| !a.is_empty())
48}
49
50fn content_text(m: &Value) -> String {
51 match m.get("content") {
52 Some(Value::String(s)) => s.clone(),
53 Some(Value::Array(parts)) => parts
54 .iter()
55 .filter_map(|p| {
56 if let Some(s) = p.as_str() {
57 Some(s.to_string())
58 } else {
59 p.get("text").and_then(Value::as_str).map(String::from)
60 }
61 })
62 .collect::<Vec<_>>()
63 .join("\n"),
64 _ => String::new(),
65 }
66}
67
68fn count_message_tokens(m: &Value) -> usize {
69 let mut total = 4 + count_tokens(&content_text(m));
70 if let Some(tcs) = m.get("tool_calls").and_then(Value::as_array) {
71 for tc in tcs {
72 if let Some(f) = tc.get("function") {
73 total += count_tokens(f.get("name").and_then(Value::as_str).unwrap_or(""));
74 total += count_tokens(f.get("arguments").and_then(Value::as_str).unwrap_or(""));
75 total += 3;
76 }
77 }
78 }
79 total
80}
81
82fn count_messages_tokens(msgs: &[Value]) -> usize {
83 msgs.iter().map(count_message_tokens).sum()
84}
85
86fn atomic_blocks(body: &[Value]) -> Vec<(usize, usize)> {
89 let mut blocks: Vec<(usize, usize)> = Vec::new();
90 let mut i = 0;
91 let n = body.len();
92 while i < n {
93 if role(&body[i]) == "tool" {
94 if let Some(last) = blocks.last_mut() {
95 last.1 = i + 1;
96 i += 1;
97 continue;
98 }
99 blocks.push((i, i + 1));
101 i += 1;
102 continue;
103 }
104 if role(&body[i]) == "assistant" && has_tool_calls(&body[i]) {
105 let mut j = i + 1;
106 while j < n && role(&body[j]) == "tool" {
107 j += 1;
108 }
109 blocks.push((i, j));
110 i = j;
111 } else {
112 blocks.push((i, i + 1));
113 i += 1;
114 }
115 }
116 blocks
117}
118
119fn snippet(text: &str, limit: usize) -> String {
120 let collapsed: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
121 if collapsed.chars().count() <= limit {
122 return collapsed;
123 }
124 let end: String = collapsed.chars().take(limit.saturating_sub(1)).collect();
125 format!("{}…", end.trim_end())
126}
127
128fn build_summary_text(to_summarize: &[Value], focus_topic: Option<&str>) -> String {
129 let mut assistant_turns = 0usize;
130 let mut tool_results = 0usize;
131 let mut tool_calls = 0usize;
132 let mut tool_names: Vec<String> = Vec::new();
133 let mut user_snippets: Vec<String> = Vec::new();
134
135 for m in to_summarize {
136 match role(m) {
137 "assistant" => assistant_turns += 1,
138 "tool" => tool_results += 1,
139 "user" => {
140 let c = content_text(m);
141 if !c.trim().is_empty() {
142 user_snippets.push(snippet(&c, 160));
143 }
144 }
145 _ => {}
146 }
147 if let Some(tcs) = m.get("tool_calls").and_then(Value::as_array) {
148 for tc in tcs {
149 tool_calls += 1;
150 let name = tc
151 .get("function")
152 .and_then(|f| f.get("name"))
153 .and_then(Value::as_str)
154 .unwrap_or("");
155 if !name.is_empty() && !tool_names.iter().any(|n| n == name) {
156 tool_names.push(name.to_string());
157 }
158 }
159 }
160 }
161 tool_names.sort();
162
163 let approx_tokens = count_messages_tokens(to_summarize);
164 let mut lines: Vec<String> = Vec::new();
165 lines.push(format!("## {SUMMARY_MARKER}"));
166 lines.push(format!(
167 "{} earlier messages (~{} tokens) were offloaded to lean-ctx and replaced by this summary. Full detail is recoverable with the recall tools.",
168 to_summarize.len(),
169 approx_tokens
170 ));
171 if let Some(topic) = focus_topic.map(str::trim).filter(|t| !t.is_empty()) {
172 lines.push(format!("Focus retained: {topic}."));
173 }
174
175 if !user_snippets.is_empty() {
176 lines.push(String::new());
177 lines.push("User intents (chronological):".to_string());
178 for s in user_snippets.iter().take(MAX_USER_SNIPPETS) {
179 lines.push(format!("- {s}"));
180 }
181 let extra = user_snippets.len().saturating_sub(MAX_USER_SNIPPETS);
182 if extra > 0 {
183 lines.push(format!("- … (+{extra} more user messages)"));
184 }
185 }
186
187 let mut activity = format!(
188 "{assistant_turns} assistant turns, {tool_results} tool results, {tool_calls} tool calls"
189 );
190 if !tool_names.is_empty() {
191 activity.push_str(&format!(" across: {}", tool_names.join(", ")));
192 }
193 lines.push(String::new());
194 lines.push(format!("Activity: {activity}."));
195
196 let serialized = serialize_transcript(to_summarize, OFFLOAD_MAX_CHARS);
199 if !serialized.is_empty() {
200 lines.push(String::new());
201 lines.push(summarize_content(&serialized));
202 }
203
204 lines.push(String::new());
205 lines.push(
206 "Recover detail: ctx_search(), ctx_read(), ctx_expand(), ctx_knowledge().".to_string(),
207 );
208
209 lines.join("\n")
210}
211
212pub fn serialize_transcript(messages: &[Value], max_chars: usize) -> String {
215 let mut lines: Vec<String> = Vec::new();
216 for m in messages {
217 let r = role(m);
218 let c = content_text(m);
219 if !c.trim().is_empty() {
220 lines.push(format!("{r}: {c}"));
221 }
222 if let Some(tcs) = m.get("tool_calls").and_then(Value::as_array) {
223 for tc in tcs {
224 if let Some(f) = tc.get("function") {
225 let name = f.get("name").and_then(Value::as_str).unwrap_or("");
226 let args = f.get("arguments").and_then(Value::as_str).unwrap_or("");
227 lines.push(format!("{r} -> tool_call {name}({args})"));
228 }
229 }
230 }
231 }
232 let text = lines.join("\n");
233 if text.chars().count() <= max_chars {
234 return text;
235 }
236 let half = max_chars / 2;
237 let head: String = text.chars().take(half).collect();
238 let tail: String = text
239 .chars()
240 .rev()
241 .take(half)
242 .collect::<Vec<_>>()
243 .into_iter()
244 .rev()
245 .collect();
246 format!("{head}\n… [truncated] …\n{tail}")
247}
248
249pub fn compact_messages(
252 messages: Vec<Value>,
253 fresh_tail_tokens: usize,
254 protect_min_messages: usize,
255 focus_topic: Option<&str>,
256) -> CompactResult {
257 let original_tokens = count_messages_tokens(&messages);
258 let n = messages.len();
259 if n == 0 {
260 return CompactResult {
261 messages,
262 summarized: Vec::new(),
263 original_tokens,
264 compacted_tokens: original_tokens,
265 did_compact: false,
266 };
267 }
268
269 let mut head_end = 0;
270 while head_end < n && is_protected(&messages[head_end]) {
271 head_end += 1;
272 }
273 let head = &messages[..head_end];
274 let body = &messages[head_end..];
275 if body.is_empty() {
276 return CompactResult {
277 messages: messages.clone(),
278 summarized: Vec::new(),
279 original_tokens,
280 compacted_tokens: original_tokens,
281 did_compact: false,
282 };
283 }
284
285 let blocks = atomic_blocks(body);
286 let mut tail_start_block = blocks.len();
287 let mut tail_tokens = 0usize;
288 let mut tail_msgs = 0usize;
289 for bi in (0..blocks.len()).rev() {
290 if tail_start_block != blocks.len()
291 && tail_tokens >= fresh_tail_tokens
292 && tail_msgs >= protect_min_messages
293 {
294 break;
295 }
296 let (s, e) = blocks[bi];
297 tail_start_block = bi;
298 tail_tokens += count_messages_tokens(&body[s..e]);
299 tail_msgs += e - s;
300 }
301 let tail_idx = if tail_start_block < blocks.len() {
302 blocks[tail_start_block].0
303 } else {
304 body.len()
305 };
306 let older = &body[..tail_idx];
307 let tail = &body[tail_idx..];
308
309 let lifted: Vec<Value> = older.iter().filter(|m| is_protected(m)).cloned().collect();
310 let to_summarize: Vec<Value> = older.iter().filter(|m| !is_protected(m)).cloned().collect();
311
312 if to_summarize.is_empty() {
313 return CompactResult {
314 messages: messages.clone(),
315 summarized: Vec::new(),
316 original_tokens,
317 compacted_tokens: original_tokens,
318 did_compact: false,
319 };
320 }
321
322 let summary = json!({
323 "role": "system",
324 "content": build_summary_text(&to_summarize, focus_topic),
325 });
326
327 let mut out: Vec<Value> = Vec::with_capacity(head.len() + lifted.len() + 1 + tail.len());
328 out.extend(head.iter().cloned());
329 out.extend(lifted);
330 out.push(summary);
331 out.extend(tail.iter().cloned());
332
333 let compacted_tokens = count_messages_tokens(&out);
334 CompactResult {
335 messages: out,
336 summarized: to_summarize,
337 original_tokens,
338 compacted_tokens,
339 did_compact: true,
340 }
341}
342
343#[cfg(test)]
346pub fn tool_pairing_errors(messages: &[Value]) -> Vec<String> {
347 let mut errors = Vec::new();
348 let mut open_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
349 let mut expecting = false;
350 for (idx, m) in messages.iter().enumerate() {
351 match role(m) {
352 "assistant" if has_tool_calls(m) => {
353 open_ids.clear();
354 if let Some(tcs) = m.get("tool_calls").and_then(Value::as_array) {
355 for tc in tcs {
356 if let Some(id) = tc.get("id").and_then(Value::as_str) {
357 open_ids.insert(id.to_string());
358 }
359 }
360 }
361 expecting = !open_ids.is_empty();
362 }
363 "tool" => {
364 let tcid = m.get("tool_call_id").and_then(Value::as_str);
365 if !expecting {
366 errors.push(format!("orphan tool result at index {idx}"));
367 } else if let Some(id) = tcid {
368 if !open_ids.is_empty() && !open_ids.contains(id) {
369 errors.push(format!("tool result at index {idx} references unknown id"));
370 } else {
371 open_ids.remove(id);
372 if open_ids.is_empty() {
373 expecting = false;
374 }
375 }
376 }
377 }
378 _ => {
379 expecting = false;
380 open_ids.clear();
381 }
382 }
383 }
384 errors
385}
386
387pub fn render_result(result: &CompactResult) -> String {
390 let saved = result
391 .original_tokens
392 .saturating_sub(result.compacted_tokens);
393 let payload = json!({
394 "messages": result.messages,
395 "stats": {
396 "compacted": result.did_compact,
397 "summarized_messages": result.summarized.len(),
398 "original_tokens": result.original_tokens,
399 "compacted_tokens": result.compacted_tokens,
400 "saved_tokens": saved,
401 },
402 });
403 let mut map = Map::new();
404 if let Value::Object(m) = payload {
405 map = m;
406 }
407 serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn filler(n: usize) -> String {
415 "lorem ipsum dolor sit amet ".repeat(n)
416 }
417
418 fn make_messages(pairs: usize) -> Vec<Value> {
419 let mut v = vec![json!({"role":"system","content":"You are helpful."})];
420 for i in 0..pairs {
421 v.push(json!({"role":"user","content": format!("q{i}: {}", filler(20))}));
422 v.push(json!({"role":"assistant","content": format!("a{i}: {}", filler(20))}));
423 }
424 v
425 }
426
427 fn with_tool_block() -> Vec<Value> {
428 vec![
429 json!({"role":"system","content":"sys"}),
430 json!({"role":"user","content": format!("u0 {}", filler(30))}),
431 json!({"role":"assistant","content": format!("a0 {}", filler(30))}),
432 json!({"role":"user","content": format!("u1 {}", filler(30))}),
433 json!({"role":"assistant","content":null,"tool_calls":[
434 {"id":"call_1","type":"function","function":{"name":"ctx_search","arguments":"{}"}},
435 {"id":"call_2","type":"function","function":{"name":"ctx_read","arguments":"{}"}}
436 ]}),
437 json!({"role":"tool","tool_call_id":"call_1","content": format!("r1 {}", filler(30))}),
438 json!({"role":"tool","tool_call_id":"call_2","content": format!("r2 {}", filler(30))}),
439 json!({"role":"assistant","content": format!("a1 {}", filler(30))}),
440 json!({"role":"user","content": format!("u2 {}", filler(30))}),
441 json!({"role":"assistant","content": format!("a2 {}", filler(30))}),
442 ]
443 }
444
445 #[test]
446 fn compacts_and_keeps_system_head() {
447 let msgs = make_messages(20);
448 let r = compact_messages(msgs.clone(), 400, 4, None);
449 assert!(r.did_compact);
450 assert_eq!(role(&r.messages[0]), "system");
451 assert!(r.messages.len() < msgs.len());
452 assert!(r.compacted_tokens < r.original_tokens);
453 }
454
455 #[test]
456 fn output_is_valid_sequence() {
457 let r = compact_messages(make_messages(20), 400, 4, None);
458 assert_eq!(tool_pairing_errors(&r.messages), Vec::<String>::new());
459 let markers: Vec<_> = r
460 .messages
461 .iter()
462 .filter(|m| content_text(m).contains(SUMMARY_MARKER))
463 .collect();
464 assert_eq!(markers.len(), 1);
465 }
466
467 #[test]
468 fn never_splits_tool_pairs() {
469 let r = compact_messages(with_tool_block(), 1, 1, None);
470 assert_eq!(tool_pairing_errors(&r.messages), Vec::<String>::new());
471 assert!(!r.messages.iter().any(|m| role(m) == "tool"));
472 }
473
474 #[test]
475 fn deterministic() {
476 let a = compact_messages(make_messages(20), 400, 4, Some("graph"));
477 let b = compact_messages(make_messages(20), 400, 4, Some("graph"));
478 assert_eq!(render_result(&a), render_result(&b));
479 }
480
481 #[test]
482 fn inline_system_is_lifted() {
483 let mut msgs = make_messages(12);
484 msgs.insert(7, json!({"role":"system","content":"MID RULE"}));
485 let r = compact_messages(msgs, 200, 2, None);
486 assert!(r.messages.iter().any(|m| content_text(m) == "MID RULE"));
488 assert!(!r.summarized.iter().any(|m| content_text(m) == "MID RULE"));
490 }
491
492 #[test]
493 fn noop_when_small() {
494 let msgs = make_messages(1);
495 let r = compact_messages(msgs.clone(), 10_000_000, 2, None);
496 assert!(!r.did_compact);
497 assert_eq!(r.messages.len(), msgs.len());
498 }
499
500 #[test]
501 fn serialize_transcript_bounded() {
502 let msgs = make_messages(50);
503 let text = serialize_transcript(&msgs, 500);
504 assert!(text.chars().count() <= 500 + 32);
505 }
506}