Skip to main content

lean_ctx/tools/
ctx_transcript_compact.rs

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