1use std::sync::OnceLock;
2
3use crate::context::pressure::PressureAction;
4use crate::context::token_engine::ContextTokenEngine;
5use crate::types::message::{Content, ContentPart, Message};
6
7pub struct RuleSummarizer;
9
10const SLOT_ITEM_CAP: usize = 6;
14
15static SUMMARY_ENGINE: OnceLock<ContextTokenEngine> = OnceLock::new();
16
17impl RuleSummarizer {
18 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 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 '.' => 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
255fn 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}