Skip to main content

deepstrike_core/context/
summarizer.rs

1use crate::context::pressure::PressureAction;
2use crate::types::message::{Message, Role};
3
4/// Deterministic rule-based summariser — no LLM required. The compression
5/// pipeline is its only consumer; richer (LLM) summaries are an SDK concern.
6pub struct RuleSummarizer;
7
8impl RuleSummarizer {
9    /// Produce a summary of `messages` (the `max_tokens` budget is currently advisory).
10    pub fn summarize(&self, messages: &[Message], action: PressureAction, _max_tokens: u32) -> String {
11        let n = messages.len();
12        let tokens: u32 = messages.iter().map(|m| m.token_count.unwrap_or(0)).sum();
13        let mut tool_names: Vec<String> = messages
14            .iter()
15            .flat_map(|m| m.tool_calls.iter().map(|tc| tc.name.to_string()))
16            .collect();
17        tool_names.sort();
18        tool_names.dedup();
19
20        let last_assistant = messages
21            .iter()
22            .rev()
23            .find(|m| m.role == Role::Assistant)
24            .and_then(|m| m.content.as_text())
25            .map(|t| {
26                if t.len() > 200 {
27                    // Cut on a char boundary — a raw `&t[..200]` panics (and aborts the
28                    // whole process) when byte 200 lands inside a multi-byte scalar, e.g. CJK.
29                    format!(
30                        "{}...",
31                        crate::context::text::truncate_bytes_at_char_boundary(t, 200)
32                    )
33                } else {
34                    t.to_string()
35                }
36            })
37            .unwrap_or_default();
38
39        let action_str = action.label();
40
41        let mut s =
42            format!("[Compressed: {action_str}]\n{n} messages / {tokens} tokens archived\n");
43        if !tool_names.is_empty() {
44            s.push_str(&format!("tools used: {}\n", tool_names.join(", ")));
45        }
46        if !last_assistant.is_empty() {
47            s.push_str(&format!("last assistant output: {last_assistant}"));
48        }
49        s
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use crate::types::message::Content;
57
58    /// Regression: a long CJK assistant message whose 200th byte falls inside a
59    /// multi-byte scalar must not panic (previously aborted the whole process).
60    #[test]
61    fn summarize_does_not_panic_on_cjk_boundary() {
62        // 100 × "规范" = 200 chars / 600 bytes; byte 200 is inside a '规' (bytes 198..201).
63        let long_cjk = "规范".repeat(100);
64        assert!(!long_cjk.is_char_boundary(200));
65
66        let msg = Message {
67            role: Role::Assistant,
68            content: Content::Text(long_cjk),
69            tool_calls: vec![],
70            token_count: None,
71        };
72        let out = RuleSummarizer.summarize(&[msg], PressureAction::AutoCompact, 1000);
73        assert!(out.contains("规范"));
74        assert!(out.contains("..."));
75    }
76}