Skip to main content

deepstrike_core/context/
summarizer.rs

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