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