1use std::cmp::Ordering;
4use std::collections::BTreeSet;
5use std::ops::Range;
6
7use super::token_engine::ContextTokenEngine;
8use super::units::unit_boundaries;
9use crate::types::message::{Content, ContentPart, Message};
10
11pub struct UtilitySelectionContext<'a> {
12 pub goal: &'a str,
13 pub criteria: &'a [String],
14 pub preserved_refs: &'a [String],
15 pub active_directives: &'a [String],
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UtilityUnitScore {
20 pub range: Range<usize>,
21 pub tokens: u32,
22 pub mandatory: bool,
23 pub goal_overlap: u32,
24 pub has_unresolved: bool,
25 pub referenced_later: bool,
26 pub is_error_or_decision: bool,
27 pub recency: u32,
28 pub token_cost: u32,
29 pub prefix_invalidation_cost: u32,
30 pub utility: i64,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct UtilityArchivePlan {
35 pub archived_ranges: Vec<Range<usize>>,
36 pub retained_ranges: Vec<Range<usize>>,
37 pub archived_tokens: u32,
38 pub retained_tokens: u32,
39 pub scores: Vec<UtilityUnitScore>,
40}
41
42pub fn plan_utility_archive(
48 messages: &[Message],
49 total_tokens: u32,
50 target_tokens: u32,
51 preserve_recent_units: usize,
52 engine: &ContextTokenEngine,
53 context: &UtilitySelectionContext<'_>,
54) -> UtilityArchivePlan {
55 let ranges = unit_boundaries(messages);
56 if ranges.is_empty() {
57 return UtilityArchivePlan::default();
58 }
59 let unit_texts = ranges
60 .iter()
61 .map(|range| unit_text(&messages[range.clone()]))
62 .collect::<Vec<_>>();
63 let goal_terms = terms(
64 std::iter::once(context.goal)
65 .chain(context.criteria.iter().map(String::as_str))
66 .collect::<Vec<_>>()
67 .join(" ")
68 .as_str(),
69 );
70 let recent_start = ranges.len().saturating_sub(preserve_recent_units);
71 let denominator = total_tokens.max(1);
72 let unit_count = ranges.len().max(1) as u32;
73 let mut scores = Vec::with_capacity(ranges.len());
74
75 for (index, range) in ranges.iter().enumerate() {
76 let slice = &messages[range.clone()];
77 let text = &unit_texts[index];
78 let tokens = slice
79 .iter()
80 .map(|message| {
81 message
82 .token_count
83 .unwrap_or_else(|| engine.count_message(message))
84 })
85 .sum::<u32>();
86 let goal_overlap = overlap_count(&terms(text), &goal_terms);
87 let has_unresolved = has_unresolved(slice, text);
88 let referenced_later = unit_referenced_later(slice, text, &unit_texts[index + 1..]);
89 let is_error_or_decision = is_error_or_decision(slice, text);
90 let dependency = context
91 .preserved_refs
92 .iter()
93 .any(|reference| contains_folded(text, reference))
94 || context
95 .active_directives
96 .iter()
97 .any(|directive| directive_dependency(text, directive));
98 let mandatory = index >= recent_start || has_unresolved || dependency;
99 let recency = ((index as u64 + 1) * 1_000 / u64::from(unit_count)) as u32;
100 let token_cost = (u64::from(tokens) * 1_000 / u64::from(denominator)) as u32;
101 let prefix_invalidation_cost =
102 ((ranges.len() - index) as u64 * 1_000 / u64::from(unit_count)) as u32;
103 let utility = i64::from(goal_overlap) * 4_000
104 + if has_unresolved { 20_000 } else { 0 }
105 + if referenced_later { 5_000 } else { 0 }
106 + if is_error_or_decision { 6_000 } else { 0 }
107 + i64::from(recency) * 2
108 - i64::from(token_cost) * 2
109 - i64::from(prefix_invalidation_cost);
110 scores.push(UtilityUnitScore {
111 range: range.clone(),
112 tokens,
113 mandatory,
114 goal_overlap,
115 has_unresolved,
116 referenced_later,
117 is_error_or_decision,
118 recency,
119 token_cost,
120 prefix_invalidation_cost,
121 utility,
122 });
123 }
124
125 if total_tokens <= target_tokens {
126 return UtilityArchivePlan {
127 archived_ranges: Vec::new(),
128 retained_ranges: ranges,
129 archived_tokens: 0,
130 retained_tokens: scores.iter().map(|score| score.tokens).sum(),
131 scores,
132 };
133 }
134
135 let mut retained = scores
136 .iter()
137 .enumerate()
138 .filter_map(|(index, score)| score.mandatory.then_some(index))
139 .collect::<BTreeSet<_>>();
140 let mut retained_tokens = retained
141 .iter()
142 .map(|index| scores[*index].tokens)
143 .sum::<u32>();
144 let mut optional = scores
145 .iter()
146 .enumerate()
147 .filter_map(|(index, score)| (!score.mandatory).then_some(index))
148 .collect::<Vec<_>>();
149 optional.sort_by(|left, right| compare_density(&scores[*right], &scores[*left]));
150 for index in optional {
151 let tokens = scores[index].tokens;
152 if retained_tokens.saturating_add(tokens) <= target_tokens {
153 retained.insert(index);
154 retained_tokens = retained_tokens.saturating_add(tokens);
155 }
156 }
157
158 let retained_ranges = ranges
159 .iter()
160 .enumerate()
161 .filter_map(|(index, range)| retained.contains(&index).then_some(range.clone()))
162 .collect::<Vec<_>>();
163 let archived_ranges = ranges
164 .iter()
165 .enumerate()
166 .filter_map(|(index, range)| (!retained.contains(&index)).then_some(range.clone()))
167 .collect::<Vec<_>>();
168 let archived_tokens = scores
169 .iter()
170 .enumerate()
171 .filter_map(|(index, score)| (!retained.contains(&index)).then_some(score.tokens))
172 .sum();
173 UtilityArchivePlan {
174 archived_ranges,
175 retained_ranges,
176 archived_tokens,
177 retained_tokens,
178 scores,
179 }
180}
181
182fn compare_density(left: &UtilityUnitScore, right: &UtilityUnitScore) -> Ordering {
183 let left_density = i128::from(left.utility) * i128::from(right.tokens.max(1));
184 let right_density = i128::from(right.utility) * i128::from(left.tokens.max(1));
185 left_density
186 .cmp(&right_density)
187 .then_with(|| left.utility.cmp(&right.utility))
188 .then_with(|| left.range.start.cmp(&right.range.start))
189}
190
191fn unit_text(messages: &[Message]) -> String {
192 let mut parts = Vec::new();
193 for message in messages {
194 match &message.content {
195 Content::Text(text) => parts.push(text.clone()),
196 Content::Parts(content_parts) => {
197 for part in content_parts {
198 match part {
199 ContentPart::Text { text } => parts.push(text.clone()),
200 ContentPart::ToolResult {
201 call_id, output, ..
202 } => parts.push(format!("{call_id} {output}")),
203 ContentPart::Image { url, .. } => {
204 parts.push(url.clone().unwrap_or_default())
205 }
206 ContentPart::Audio { .. } => parts.push("audio".into()),
207 }
208 }
209 }
210 }
211 for call in &message.tool_calls {
212 parts.push(format!("{} {} {}", call.id, call.name, call.arguments));
213 }
214 }
215 parts.join("\n")
216}
217
218fn terms(text: &str) -> BTreeSet<String> {
219 let mut output = BTreeSet::new();
220 let mut ascii = String::new();
221 for character in text.chars().flat_map(char::to_lowercase) {
222 if character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '/' | '.' | ':') {
223 ascii.push(character);
224 continue;
225 }
226 if !ascii.is_empty() {
227 if ascii.chars().count() > 1 {
228 output.insert(std::mem::take(&mut ascii));
229 } else {
230 ascii.clear();
231 }
232 }
233 if !character.is_whitespace() && !character.is_ascii_punctuation() {
234 output.insert(character.to_string());
235 }
236 }
237 if ascii.chars().count() > 1 {
238 output.insert(ascii);
239 }
240 output
241}
242
243fn overlap_count(left: &BTreeSet<String>, right: &BTreeSet<String>) -> u32 {
244 left.intersection(right).count() as u32
245}
246
247fn contains_folded(text: &str, pattern: &str) -> bool {
248 !pattern.trim().is_empty() && text.to_lowercase().contains(&pattern.to_lowercase())
249}
250
251fn directive_dependency(text: &str, directive: &str) -> bool {
252 if contains_folded(text, directive) {
253 return true;
254 }
255 let directive_terms = terms(directive);
256 if directive_terms.is_empty() {
257 return false;
258 }
259 let threshold = directive_terms.len().min(2);
260 terms(text).intersection(&directive_terms).count() >= threshold
261}
262
263fn has_unresolved(messages: &[Message], text: &str) -> bool {
264 let mut opened = BTreeSet::new();
265 let mut resolved = BTreeSet::new();
266 for message in messages {
267 for call in &message.tool_calls {
268 opened.insert(call.id.to_string());
269 }
270 if let Content::Parts(parts) = &message.content {
271 for part in parts {
272 if let ContentPart::ToolResult {
273 call_id, is_error, ..
274 } = part
275 {
276 if *is_error {
277 return true;
278 }
279 resolved.insert(call_id.to_string());
280 }
281 }
282 }
283 }
284 opened.iter().any(|call_id| !resolved.contains(call_id))
285 || marker(
286 text,
287 &[
288 "unresolved",
289 "open question",
290 "retry",
291 "blocked",
292 "待确认",
293 "未解决",
294 "重试",
295 "阻塞",
296 ],
297 )
298}
299
300fn is_error_or_decision(messages: &[Message], text: &str) -> bool {
301 messages.iter().any(|message| {
302 matches!(&message.content, Content::Parts(parts) if parts.iter().any(|part| matches!(part, ContentPart::ToolResult { is_error: true, .. })))
303 }) || marker(
304 text,
305 &[
306 "error", "failed", "failure", "exception", "decision", "decided", "must", "should",
307 "错误", "失败", "异常", "决定", "选择", "必须", "应当",
308 ],
309 )
310}
311
312fn marker(text: &str, markers: &[&str]) -> bool {
313 markers.iter().any(|marker| contains_folded(text, marker))
314}
315
316fn unit_referenced_later(messages: &[Message], text: &str, later: &[String]) -> bool {
317 let mut references = messages
318 .iter()
319 .flat_map(|message| message.tool_calls.iter().map(|call| call.id.to_string()))
320 .collect::<BTreeSet<_>>();
321 references.extend(
322 text.split_whitespace()
323 .map(|token| token.trim_matches(|character: char| character.is_ascii_punctuation()))
324 .filter(|token| token.contains('/') || token.contains("://"))
325 .filter(|token| token.len() > 3)
326 .map(str::to_string),
327 );
328 references.iter().any(|reference| {
329 later
330 .iter()
331 .any(|later_text| contains_folded(later_text, reference))
332 })
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::types::message::{ContentPart, ToolCall};
339
340 #[test]
341 fn unresolved_tool_unit_is_mandatory() {
342 let mut call = Message::assistant("working");
343 call.tool_calls.push(ToolCall {
344 id: "call-1".into(),
345 name: "read".into(),
346 arguments: serde_json::json!({"path": "/work/a"}),
347 });
348 call.token_count = Some(20);
349 let mut recent = Message::user("recent");
350 recent.token_count = Some(20);
351 let messages = vec![call, recent];
352 let plan = plan_utility_archive(
353 &messages,
354 40,
355 20,
356 1,
357 &ContextTokenEngine::char_approx(),
358 &UtilitySelectionContext {
359 goal: "",
360 criteria: &[],
361 preserved_refs: &[],
362 active_directives: &[],
363 },
364 );
365 assert!(plan.scores[0].mandatory);
366 assert!(plan.scores[0].has_unresolved);
367 assert_eq!(plan.retained_tokens, 40);
368 }
369
370 #[test]
371 fn preserved_ref_keeps_complete_tool_unit() {
372 let mut call = Message::assistant("read artifact");
373 call.tool_calls.push(ToolCall {
374 id: "call-keep".into(),
375 name: "read".into(),
376 arguments: serde_json::json!({}),
377 });
378 call.token_count = Some(20);
379 let mut result = Message::tool(vec![ContentPart::ToolResult {
380 call_id: "call-keep".into(),
381 output: "artifact".into(),
382 is_error: false,
383 }]);
384 result.token_count = Some(20);
385 let messages = vec![call, result];
386 let plan = plan_utility_archive(
387 &messages,
388 40,
389 0,
390 0,
391 &ContextTokenEngine::char_approx(),
392 &UtilitySelectionContext {
393 goal: "",
394 criteria: &[],
395 preserved_refs: &["call-keep".into()],
396 active_directives: &[],
397 },
398 );
399 assert!(plan.scores[0].mandatory);
400 assert_eq!(plan.archived_ranges, Vec::<Range<usize>>::new());
401 }
402}