1use crate::context::pressure::PressureAction;
2use crate::context::token_engine::ContextTokenEngine;
3use crate::types::message::{Content, ContentPart, Message};
4
5pub struct RuleSummarizer;
7
8impl RuleSummarizer {
9 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}