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
8pub struct RuleSummarizer;
10
11const SLOT_ITEM_CAP: usize = 6;
15
16static SUMMARY_ENGINE: OnceLock<ContextTokenEngine> = OnceLock::new();
17
18impl RuleSummarizer {
19 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 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 '.' => 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
261fn 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}