Skip to main content

deepstrike_core/context/
summarizer.rs

1use std::sync::OnceLock;
2
3use crate::context::pressure::PressureAction;
4use crate::context::token_engine::ContextTokenEngine;
5use crate::types::message::{Content, ContentPart, Message};
6
7/// Deterministic six-slot summariser used before archived units page out.
8pub struct RuleSummarizer;
9
10/// Items rendered per slot before an honest `(+N more)` line. Bounds each digest so the
11/// task-state compression history can afford to keep MANY digests visible instead of a few
12/// bloated ones.
13const SLOT_ITEM_CAP: usize = 6;
14
15static SUMMARY_ENGINE: OnceLock<ContextTokenEngine> = OnceLock::new();
16
17impl RuleSummarizer {
18    /// Produce a structured summary whose fallback-estimated token count never exceeds
19    /// `max_tokens`. Slot order is the deterministic truncation priority.
20    pub fn summarize(
21        &self,
22        messages: &[Message],
23        action: PressureAction,
24        max_tokens: u32,
25    ) -> String {
26        if max_tokens == 0 {
27            return String::new();
28        }
29        // Keep the production BPE semantics without rebuilding the cl100k tables for every
30        // compression. ContextTokenEngine is Arc-backed, so one process-wide instance is safe.
31        let engine = SUMMARY_ENGINE.get_or_init(ContextTokenEngine::fallback_estimator);
32        let archived_tokens = messages
33            .iter()
34            .map(|message| {
35                message
36                    .token_count
37                    .unwrap_or_else(|| engine.count_message(message))
38            })
39            .sum::<u32>();
40        let mut slots = SummarySlots::default();
41        for message in messages {
42            for call in &message.tool_calls {
43                push_unique(
44                    &mut slots.artifacts,
45                    format!("tool {} args {}", call.name, call.arguments),
46                );
47            }
48            match &message.content {
49                Content::Text(text) => classify_text(text, &mut slots),
50                Content::Parts(parts) => {
51                    for part in parts {
52                        match part {
53                            ContentPart::Text { text } => classify_text(text, &mut slots),
54                            ContentPart::ToolResult {
55                                call_id,
56                                output,
57                                is_error,
58                                ..
59                            } => {
60                                if *is_error {
61                                    push_unique(
62                                        &mut slots.failures,
63                                        format!("tool {call_id}: {}", compact(output, 240)),
64                                    );
65                                }
66                                classify_text(output, &mut slots);
67                            }
68                            ContentPart::Image { url, .. } => {
69                                if let Some(url) = url {
70                                    push_unique(&mut slots.artifacts, url.clone());
71                                }
72                            }
73                            ContentPart::Audio { .. } => {}
74                        }
75                    }
76                }
77            }
78        }
79
80        let mut output = String::new();
81        push_line(
82            &mut output,
83            &format!("[Compressed: {}]", action.label()),
84            max_tokens,
85            engine,
86        );
87        push_line(
88            &mut output,
89            &format!(
90                "archived_messages: {}; archived_tokens: {archived_tokens}",
91                messages.len()
92            ),
93            max_tokens,
94            engine,
95        );
96        for (name, values) in [
97            ("constraints", slots.constraints),
98            ("decisions", slots.decisions),
99            ("artifacts", slots.artifacts),
100            ("open_questions", slots.open_questions),
101            ("failures", slots.failures),
102            ("next_actions", slots.next_actions),
103        ] {
104            if !push_line(&mut output, &format!("{name}:"), max_tokens, engine) {
105                break;
106            }
107            if values.is_empty() {
108                push_line(&mut output, "- none", max_tokens, engine);
109            } else {
110                for value in values.iter().take(SLOT_ITEM_CAP) {
111                    push_line(
112                        &mut output,
113                        &format!("- {}", compact(value, 240)),
114                        max_tokens,
115                        engine,
116                    );
117                }
118                if values.len() > SLOT_ITEM_CAP {
119                    push_line(
120                        &mut output,
121                        &format!("- (+{} more)", values.len() - SLOT_ITEM_CAP),
122                        max_tokens,
123                        engine,
124                    );
125                }
126            }
127        }
128
129        if engine.count(&output) > max_tokens {
130            engine.truncate(&output, max_tokens).to_string()
131        } else {
132            output
133        }
134    }
135}
136
137#[derive(Default)]
138struct SummarySlots {
139    constraints: Vec<String>,
140    decisions: Vec<String>,
141    artifacts: Vec<String>,
142    open_questions: Vec<String>,
143    failures: Vec<String>,
144    next_actions: Vec<String>,
145}
146
147fn classify_text(text: &str, slots: &mut SummarySlots) {
148    for statement in statements(text) {
149        if is_diff_noise(&statement) {
150            continue;
151        }
152        let folded = statement.to_lowercase();
153        if contains_any(
154            &folded,
155            &[
156                "constraint",
157                "must",
158                "required",
159                "do not",
160                "should",
161                "约束",
162                "必须",
163                "不得",
164                "应当",
165            ],
166        ) {
167            push_unique(&mut slots.constraints, statement.clone());
168        }
169        if contains_any(
170            &folded,
171            &["decision", "decided", "selected", "choose", "决定", "选择"],
172        ) {
173            push_unique(&mut slots.decisions, statement.clone());
174        }
175        if contains_any(
176            &folded,
177            &[
178                "error",
179                "failed",
180                "failure",
181                "exception",
182                "timeout",
183                "错误",
184                "失败",
185                "异常",
186                "超时",
187            ],
188        ) {
189            push_unique(&mut slots.failures, statement.clone());
190        }
191        if statement.contains('?')
192            || statement.contains('?')
193            || contains_any(
194                &folded,
195                &["open question", "unresolved", "unknown", "待确认", "未解决"],
196            )
197        {
198            push_unique(&mut slots.open_questions, statement.clone());
199        }
200        if contains_any(
201            &folded,
202            &[
203                "next",
204                "todo",
205                "then",
206                "follow up",
207                "下一步",
208                "待办",
209                "随后",
210            ],
211        ) {
212            push_unique(&mut slots.next_actions, statement.clone());
213        }
214        if contains_any(&folded, &["artifact", "file", "output", "产物", "文件"])
215            || statement
216                .split_whitespace()
217                .any(|word| word.contains('/') || word.contains("://"))
218        {
219            push_unique(&mut slots.artifacts, statement);
220        }
221    }
222}
223
224fn statements(text: &str) -> Vec<String> {
225    let mut output = Vec::new();
226    let mut current = String::new();
227    let mut chars = text.chars().peekable();
228    while let Some(character) = chars.next() {
229        let boundary = match character {
230            '\n' | '!' | '?' | ';' | '。' | '!' | '?' | ';' => true,
231            // A '.' splits only at a sentence boundary (followed by whitespace or end of
232            // text). Splitting inside paths/versions shredded `src/auth.js` into useless
233            // `js b/src/auth` fragments that polluted every digest.
234            '.' => chars.peek().is_none_or(|next| next.is_whitespace()),
235            _ => false,
236        };
237        if boundary {
238            flush_statement(&mut current, &mut output);
239        } else {
240            current.push(character);
241        }
242    }
243    flush_statement(&mut current, &mut output);
244    output
245}
246
247fn flush_statement(current: &mut String, output: &mut Vec<String>) {
248    let statement = current.trim();
249    if !statement.is_empty() {
250        output.push(compact(statement, 240));
251    }
252    current.clear();
253}
254
255/// Structural diff/patch header lines carry no summarizable content — dropping them keeps
256/// digests dense so more real process state survives a given summary budget.
257fn is_diff_noise(statement: &str) -> bool {
258    let trimmed = statement.trim_start();
259    trimmed.starts_with("diff --git")
260        || trimmed.starts_with("+++")
261        || trimmed.starts_with("---")
262        || trimmed.starts_with("@@")
263        || trimmed.starts_with("index ")
264}
265
266fn contains_any(text: &str, markers: &[&str]) -> bool {
267    markers.iter().any(|marker| text.contains(marker))
268}
269
270fn push_unique(values: &mut Vec<String>, value: String) {
271    if !value.is_empty() && !values.contains(&value) {
272        values.push(value);
273    }
274}
275
276fn push_line(
277    output: &mut String,
278    line: &str,
279    max_tokens: u32,
280    engine: &ContextTokenEngine,
281) -> bool {
282    let candidate = if output.is_empty() {
283        line.to_string()
284    } else {
285        format!("{output}\n{line}")
286    };
287    if engine.count(&candidate) > max_tokens {
288        return false;
289    }
290    *output = candidate;
291    true
292}
293
294fn compact(text: &str, max_chars: usize) -> String {
295    let mut output = text.chars().take(max_chars).collect::<String>();
296    if text.chars().count() > max_chars {
297        output.push('…');
298    }
299    output
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::types::message::{ContentPart, ToolCall};
306
307    #[test]
308    fn summarize_does_not_panic_on_cjk_boundary() {
309        let long_cjk = "规范".repeat(100);
310        assert!(!long_cjk.is_char_boundary(200));
311        let msg = Message::assistant(format!("必须遵守约束:{long_cjk}"));
312        let out = RuleSummarizer.summarize(&[msg], PressureAction::AutoCompact, 1_000);
313        assert!(out.contains("规范"));
314        assert!(out.contains("constraints:"));
315    }
316
317    #[test]
318    fn emits_six_structured_slots_from_rules_tools_and_errors() {
319        let mut call = Message::assistant(
320            "DECISION: choose parser B. Must preserve schema. Open question: retry limit? Next: run tests.",
321        );
322        call.tool_calls.push(ToolCall {
323            id: "call-1".into(),
324            name: "write_file".into(),
325            arguments: serde_json::json!({"path": "/work/report.json"}),
326        });
327        let result = Message::tool(vec![ContentPart::ToolResult {
328            call_id: "call-1".into(),
329            output: "ERROR: write failed; artifact /work/report.json".into(),
330            is_error: true,
331            durable_content: None,
332        }]);
333        let out = RuleSummarizer.summarize(&[call, result], PressureAction::ContextCollapse, 1_000);
334        for slot in [
335            "constraints:",
336            "decisions:",
337            "artifacts:",
338            "open_questions:",
339            "failures:",
340            "next_actions:",
341        ] {
342            assert!(out.contains(slot), "missing {slot}: {out}");
343        }
344        assert!(out.contains("write_file"));
345        assert!(out.contains("write failed"));
346    }
347
348    #[test]
349    fn max_tokens_is_a_real_hard_upper_bound() {
350        let message = Message::assistant(
351            "DECISION: keep this. Must preserve that. Next: run many tests. ERROR: prior attempt failed."
352                .repeat(20),
353        );
354        for max_tokens in [1, 4, 8, 16, 32] {
355            let out = RuleSummarizer.summarize(
356                std::slice::from_ref(&message),
357                PressureAction::AutoCompact,
358                max_tokens,
359            );
360            assert!(
361                ContextTokenEngine::char_approx().count(&out) <= max_tokens,
362                "max={max_tokens}, output={out:?}"
363            );
364        }
365        assert_eq!(
366            RuleSummarizer.summarize(&[message], PressureAction::AutoCompact, 0),
367            ""
368        );
369    }
370}