Skip to main content

deepstrike_core/context/
summarizer.rs

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