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
8impl RuleSummarizer {
9    /// Produce a structured summary whose char-approx token count never exceeds
10    /// `max_tokens`. Slot order is the deterministic truncation priority.
11    pub fn summarize(
12        &self,
13        messages: &[Message],
14        action: PressureAction,
15        max_tokens: u32,
16    ) -> String {
17        if max_tokens == 0 {
18            return String::new();
19        }
20        let engine = ContextTokenEngine::char_approx();
21        let archived_tokens = messages
22            .iter()
23            .map(|message| {
24                message
25                    .token_count
26                    .unwrap_or_else(|| engine.count_message(message))
27            })
28            .sum::<u32>();
29        let mut slots = SummarySlots::default();
30        for message in messages {
31            for call in &message.tool_calls {
32                push_unique(
33                    &mut slots.artifacts,
34                    format!("tool {} args {}", call.name, call.arguments),
35                );
36            }
37            match &message.content {
38                Content::Text(text) => classify_text(text, &mut slots),
39                Content::Parts(parts) => {
40                    for part in parts {
41                        match part {
42                            ContentPart::Text { text } => classify_text(text, &mut slots),
43                            ContentPart::ToolResult {
44                                call_id,
45                                output,
46                                is_error,
47                            } => {
48                                if *is_error {
49                                    push_unique(
50                                        &mut slots.failures,
51                                        format!("tool {call_id}: {}", compact(output, 240)),
52                                    );
53                                }
54                                classify_text(output, &mut slots);
55                            }
56                            ContentPart::Image { url, .. } => {
57                                if let Some(url) = url {
58                                    push_unique(&mut slots.artifacts, url.clone());
59                                }
60                            }
61                            ContentPart::Audio { .. } => {}
62                        }
63                    }
64                }
65            }
66        }
67
68        let mut output = String::new();
69        push_line(
70            &mut output,
71            &format!("[Compressed: {}]", action.label()),
72            max_tokens,
73            &engine,
74        );
75        push_line(
76            &mut output,
77            &format!(
78                "archived_messages: {}; archived_tokens: {archived_tokens}",
79                messages.len()
80            ),
81            max_tokens,
82            &engine,
83        );
84        for (name, values) in [
85            ("constraints", slots.constraints),
86            ("decisions", slots.decisions),
87            ("artifacts", slots.artifacts),
88            ("open_questions", slots.open_questions),
89            ("failures", slots.failures),
90            ("next_actions", slots.next_actions),
91        ] {
92            if !push_line(&mut output, &format!("{name}:"), max_tokens, &engine) {
93                break;
94            }
95            if values.is_empty() {
96                push_line(&mut output, "- none", max_tokens, &engine);
97            } else {
98                for value in values {
99                    push_line(
100                        &mut output,
101                        &format!("- {}", compact(&value, 240)),
102                        max_tokens,
103                        &engine,
104                    );
105                }
106            }
107        }
108
109        if engine.count(&output) > max_tokens {
110            engine.truncate(&output, max_tokens).to_string()
111        } else {
112            output
113        }
114    }
115}
116
117#[derive(Default)]
118struct SummarySlots {
119    constraints: Vec<String>,
120    decisions: Vec<String>,
121    artifacts: Vec<String>,
122    open_questions: Vec<String>,
123    failures: Vec<String>,
124    next_actions: Vec<String>,
125}
126
127fn classify_text(text: &str, slots: &mut SummarySlots) {
128    for statement in statements(text) {
129        let folded = statement.to_lowercase();
130        if contains_any(
131            &folded,
132            &[
133                "constraint",
134                "must",
135                "required",
136                "do not",
137                "should",
138                "约束",
139                "必须",
140                "不得",
141                "应当",
142            ],
143        ) {
144            push_unique(&mut slots.constraints, statement.clone());
145        }
146        if contains_any(
147            &folded,
148            &["decision", "decided", "selected", "choose", "决定", "选择"],
149        ) {
150            push_unique(&mut slots.decisions, statement.clone());
151        }
152        if contains_any(
153            &folded,
154            &[
155                "error",
156                "failed",
157                "failure",
158                "exception",
159                "timeout",
160                "错误",
161                "失败",
162                "异常",
163                "超时",
164            ],
165        ) {
166            push_unique(&mut slots.failures, statement.clone());
167        }
168        if statement.contains('?')
169            || statement.contains('?')
170            || contains_any(
171                &folded,
172                &["open question", "unresolved", "unknown", "待确认", "未解决"],
173            )
174        {
175            push_unique(&mut slots.open_questions, statement.clone());
176        }
177        if contains_any(
178            &folded,
179            &[
180                "next",
181                "todo",
182                "then",
183                "follow up",
184                "下一步",
185                "待办",
186                "随后",
187            ],
188        ) {
189            push_unique(&mut slots.next_actions, statement.clone());
190        }
191        if contains_any(&folded, &["artifact", "file", "output", "产物", "文件"])
192            || statement
193                .split_whitespace()
194                .any(|word| word.contains('/') || word.contains("://"))
195        {
196            push_unique(&mut slots.artifacts, statement);
197        }
198    }
199}
200
201fn statements(text: &str) -> Vec<String> {
202    text.split(['\n', '.', '!', '?', ';', '。', '!', '?', ';'])
203        .map(str::trim)
204        .filter(|statement| !statement.is_empty())
205        .map(|statement| compact(statement, 240))
206        .collect()
207}
208
209fn contains_any(text: &str, markers: &[&str]) -> bool {
210    markers.iter().any(|marker| text.contains(marker))
211}
212
213fn push_unique(values: &mut Vec<String>, value: String) {
214    if !value.is_empty() && !values.contains(&value) {
215        values.push(value);
216    }
217}
218
219fn push_line(
220    output: &mut String,
221    line: &str,
222    max_tokens: u32,
223    engine: &ContextTokenEngine,
224) -> bool {
225    let candidate = if output.is_empty() {
226        line.to_string()
227    } else {
228        format!("{output}\n{line}")
229    };
230    if engine.count(&candidate) > max_tokens {
231        return false;
232    }
233    *output = candidate;
234    true
235}
236
237fn compact(text: &str, max_chars: usize) -> String {
238    let mut output = text.chars().take(max_chars).collect::<String>();
239    if text.chars().count() > max_chars {
240        output.push('…');
241    }
242    output
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::types::message::{ContentPart, ToolCall};
249
250    #[test]
251    fn summarize_does_not_panic_on_cjk_boundary() {
252        let long_cjk = "规范".repeat(100);
253        assert!(!long_cjk.is_char_boundary(200));
254        let msg = Message::assistant(format!("必须遵守约束:{long_cjk}"));
255        let out = RuleSummarizer.summarize(&[msg], PressureAction::AutoCompact, 1_000);
256        assert!(out.contains("规范"));
257        assert!(out.contains("constraints:"));
258    }
259
260    #[test]
261    fn emits_six_structured_slots_from_rules_tools_and_errors() {
262        let mut call = Message::assistant(
263            "DECISION: choose parser B. Must preserve schema. Open question: retry limit? Next: run tests.",
264        );
265        call.tool_calls.push(ToolCall {
266            id: "call-1".into(),
267            name: "write_file".into(),
268            arguments: serde_json::json!({"path": "/work/report.json"}),
269        });
270        let result = Message::tool(vec![ContentPart::ToolResult {
271            call_id: "call-1".into(),
272            output: "ERROR: write failed; artifact /work/report.json".into(),
273            is_error: true,
274        }]);
275        let out = RuleSummarizer.summarize(&[call, result], PressureAction::ContextCollapse, 1_000);
276        for slot in [
277            "constraints:",
278            "decisions:",
279            "artifacts:",
280            "open_questions:",
281            "failures:",
282            "next_actions:",
283        ] {
284            assert!(out.contains(slot), "missing {slot}: {out}");
285        }
286        assert!(out.contains("write_file"));
287        assert!(out.contains("write failed"));
288    }
289
290    #[test]
291    fn max_tokens_is_a_real_hard_upper_bound() {
292        let message = Message::assistant(
293            "DECISION: keep this. Must preserve that. Next: run many tests. ERROR: prior attempt failed."
294                .repeat(20),
295        );
296        for max_tokens in [1, 4, 8, 16, 32] {
297            let out = RuleSummarizer.summarize(
298                std::slice::from_ref(&message),
299                PressureAction::AutoCompact,
300                max_tokens,
301            );
302            assert!(
303                ContextTokenEngine::char_approx().count(&out) <= max_tokens,
304                "max={max_tokens}, output={out:?}"
305            );
306        }
307        assert_eq!(
308            RuleSummarizer.summarize(&[message], PressureAction::AutoCompact, 0),
309            ""
310        );
311    }
312}