1use crate::context::pressure::PressureAction;
2use crate::context::token_engine::ContextTokenEngine;
3use crate::types::message::{Content, ContentPart, Message};
4
5pub struct RuleSummarizer;
7
8const SLOT_ITEM_CAP: usize = 6;
12
13impl RuleSummarizer {
14 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 '.' => 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
248fn 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}