Skip to main content

lean_ctx_server/tools/
ctx_transcript_compact.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_transcript_compact` business logic (#hermes-engine).
3//!
4//! Deterministic, prompt-cache-friendly compaction of an OpenAI-format message
5//! array. Used by the Hermes context-engine plugin so the compaction lives in
6//! the daemon (Single Source of Truth) instead of being re-implemented per
7//! client (AGENTS.md #498).
8//!
9//! Invariants (mirrored by the Python plugin's `compaction.py`):
10//! * an `assistant` message with `tool_calls` is never separated from its
11//!   following `tool` results (atomic blocks);
12//! * leading / inline `system`/`developer` messages are preserved verbatim;
13//! * output is a deterministic function of the input (no time/random).
14
15use 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
25/// Outcome of a compaction pass.
26pub struct CompactResult {
27    /// The compacted message array (head + lifted + summary + tail).
28    pub messages: Vec<Value>,
29    /// The non-protected older messages that were summarized (for offload).
30    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
86/// Group `body` into atomic `[start, end)` blocks (assistant+tool_calls and its
87/// trailing tool results stay together; stray tool results attach backwards).
88fn 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            // No previous block: treat as its own (degenerate) block.
100            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    // Reuse lean-ctx's deterministic transcript summarizer for a compressed
197    // head/tail glimpse of the raw turns.
198    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
212/// Render messages to a plain-text transcript (bounded, deterministic) for
213/// durable offload into the session/knowledge store.
214pub 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
249/// Compact a message array. `fresh_tail_tokens` and `protect_min_messages`
250/// bound the verbatim tail; the split always lands on an atomic-block boundary.
251pub 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/// Detect tool_call/tool_result pairing violations (empty == valid). Used by
344/// tests to assert the hard OpenAI-sequence invariant after compaction.
345#[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
387/// Build the JSON payload returned by the MCP tool: the compacted array plus
388/// deterministic stats. Separated so the registered wrapper stays thin.
389pub 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        // the mid-convo system rule survives verbatim somewhere in the output
487        assert!(r.messages.iter().any(|m| content_text(m) == "MID RULE"));
488        // and was not part of the summarized set
489        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}