1use lean_ctx::core::triage::{TaskAnalysisInput, TriageEngine};
3use serde_json::Value;
4
5const TRUNCATION_SCORE: f64 = 0.3;
6const MAX_PRUNED_CHARS: usize = 200;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct PreOptimizeResult {
10 pub task_class: String,
11 pub complexity: String,
12 pub tokens_pruned: usize,
13 pub original_token_estimate: usize,
14}
15
16pub fn pre_optimize(body: &mut Value) -> Option<PreOptimizeResult> {
19 let messages = body.get_mut("messages")?.as_array_mut()?;
20 let last_user = messages
21 .iter()
22 .rposition(|message| message_role(message) == Some("user"))?;
23 let triage = TriageEngine::default();
24 let (task_class, complexity) =
25 classify_and_complexity_with_triage(message_text(&messages[last_user]), &triage);
26 #[cfg(feature = "enterprise")]
27 lean_ctx::core::context_prefetch::plan_after_triage(&task_class);
28 let original_token_estimate = estimate_tokens(messages.iter().map(message_char_count).sum());
29
30 let cached_prefix = super::history_prune::cached_prefix_len(messages);
31 for (index, message) in messages.iter_mut().enumerate() {
32 if index < cached_prefix {
33 continue;
34 }
35 if relevance_score(message_role(message), index, last_user) < TRUNCATION_SCORE {
36 truncate_message(message);
37 }
38 }
39
40 let optimized_token_estimate = estimate_tokens(messages.iter().map(message_char_count).sum());
41 Some(PreOptimizeResult {
42 complexity,
43 task_class: task_class.clone(),
44 tokens_pruned: original_token_estimate.saturating_sub(optimized_token_estimate),
45 original_token_estimate,
46 })
47}
48
49pub fn classify_task(message: Option<&str>) -> &'static str {
50 let text = message.unwrap_or_default().to_ascii_lowercase();
51
52 if contains_any(&text, &["refactor", "cleanup", "clean up", "restructure"]) {
53 "refactor"
54 } else if contains_any(&text, &["debug", "diagnose", "trace", "investigate"]) {
55 "debug"
56 } else if contains_any(
57 &text,
58 &["fix", "bug", "broken", "regression", "repair", "error"],
59 ) {
60 "coding_fix"
61 } else if contains_any(
62 &text,
63 &["implement", "add", "create", "build", "feature", "write"],
64 ) {
65 "coding_new"
66 } else {
67 "question"
68 }
69}
70
71#[cfg(test)]
72fn classify_with_triage(message: Option<&str>, engine: &TriageEngine) -> String {
73 engine
74 .analyze(&TaskAnalysisInput {
75 query: message.unwrap_or_default().to_owned(),
76 ..TaskAnalysisInput::default()
77 })
78 .map(|analysis| {
79 task_class_for_policy(&analysis.profile.task_class, &analysis.profile.intent)
80 })
81 .unwrap_or_else(|_| classify_task(message).to_owned())
82}
83
84fn classify_and_complexity_with_triage(
85 message: Option<&str>,
86 engine: &TriageEngine,
87) -> (String, String) {
88 engine
89 .analyze(&TaskAnalysisInput {
90 query: message.unwrap_or_default().to_owned(),
91 ..TaskAnalysisInput::default()
92 })
93 .map(|analysis| {
94 (
95 task_class_for_policy(&analysis.profile.task_class, &analysis.profile.intent),
96 analysis.profile.complexity,
97 )
98 })
99 .unwrap_or_else(|_| (classify_task(message).to_owned(), "low".to_owned()))
100}
101
102fn task_class_for_policy(profile_task_class: &str, intent: &str) -> String {
103 match intent {
104 "simple" => "simple".to_owned(),
105 "coding_fix" | "coding_new" | "refactor" => intent.to_owned(),
106 "debug" | "debugging" => "debugging".to_owned(),
107 "explore" | "exploration" | "research" => "exploration".to_owned(),
108 _ => match profile_task_class {
109 "coding" | "coding_fix" | "coding_new" | "refactor" | "debugging" | "exploration"
110 | "research" => profile_task_class.to_owned(),
111 _ => "coding".to_owned(),
112 },
113 }
114}
115
116fn contains_any(text: &str, keywords: &[&str]) -> bool {
117 keywords.iter().any(|keyword| text.contains(keyword))
118}
119
120fn relevance_score(role: Option<&str>, index: usize, last_user: usize) -> f64 {
121 if role == Some("system") || index == last_user {
122 return 1.0;
123 }
124
125 let base = if role == Some("assistant") { 0.8 } else { 1.0 };
126 let age = last_user.saturating_sub(index) as i32;
127 base * 0.9_f64.powi(age)
128}
129
130fn message_role(message: &Value) -> Option<&str> {
131 message.get("role")?.as_str()
132}
133
134fn message_text(message: &Value) -> Option<&str> {
135 message.get("content")?.as_str()
136}
137
138fn message_char_count(message: &Value) -> usize {
139 message_text(message).map_or(0, |content| content.chars().count())
140}
141
142fn estimate_tokens(characters: usize) -> usize {
143 characters.div_ceil(4)
144}
145
146fn truncate_message(message: &mut Value) {
147 let Some(Value::String(content)) = message.get_mut("content") else {
148 return;
149 };
150 let Some((byte_index, _)) = content.char_indices().nth(MAX_PRUNED_CHARS) else {
151 return;
152 };
153 if super::compress::protected() {
157 if let Some(cut) = super::compress::shorten_with_copy(content, &content[..byte_index]) {
158 *content = cut;
159 }
160 return;
161 }
162 content.truncate(byte_index);
163}
164
165#[cfg(test)]
166mod tests {
167 use serde_json::json;
168
169 use super::*;
170
171 #[test]
172 fn returns_none_without_a_user_message() {
173 let mut body = json!({"messages": [{"role": "assistant", "content": "hello"}]});
174
175 assert_eq!(pre_optimize(&mut body), None);
176 }
177
178 #[test]
179 fn classifies_task_kinds() {
180 for (content, _expected_category) in [
181 ("Please fix this broken endpoint", "coding_fix"),
182 ("Implement a new endpoint", "coding"),
183 ("Refactor the request pipeline", "refactor"),
184 ("What does this endpoint do?", "exploration"),
185 ("Debug this intermittent failure", "debug"),
186 ] {
187 let mut body = json!({"messages": [{"role": "user", "content": content}]});
188 let result = pre_optimize(&mut body);
189 assert!(result.is_some(), "should classify: {content}");
190 }
191 }
192
193 #[derive(Debug)]
194 struct FailingAnalyzer;
195
196 impl lean_ctx::core::triage::TaskAnalyzer for FailingAnalyzer {
197 fn analyze(
198 &self,
199 _: &lean_ctx::core::triage::TaskAnalysisInput,
200 ) -> Result<lean_ctx::core::triage::ProfileHypothesis, lean_ctx::core::triage::TriageError>
201 {
202 Err(lean_ctx::core::triage::TriageError::InternalError(
203 "forced failure".to_owned(),
204 ))
205 }
206
207 fn name(&self) -> &'static str {
208 "failing"
209 }
210 }
211
212 #[test]
213 fn uses_triage_intent_for_adaptive_policy_class() {
214 let engine = TriageEngine::with_rules();
215
216 assert_eq!(
217 classify_with_triage(Some("Explain how this works"), &engine),
218 "exploration"
219 );
220 }
221
222 #[test]
223 fn falls_back_to_heuristic_when_triage_fails() {
224 let engine = TriageEngine::new(vec![Box::new(FailingAnalyzer)]);
225
226 assert_eq!(
227 classify_with_triage(Some("Implement a new endpoint"), &engine),
228 "coding_new"
229 );
230 }
231
232 #[test]
233 fn classifies_the_last_user_message() {
234 let mut body = json!({
235 "messages": [
236 {"role": "user", "content": "Implement an endpoint"},
237 {"role": "assistant", "content": "Which endpoint?"},
238 {"role": "user", "content": "Why does it return 404?"}
239 ]
240 });
241
242 assert!(pre_optimize(&mut body).is_some());
243 }
244
245 #[test]
246 fn prunes_old_low_relevance_messages_and_counts_tokens() {
247 let _data = lean_ctx::core::data_dir::isolated_data_dir();
248 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
249 let old_content = "a".repeat(300);
250 let mut messages: Vec<Value> = (0..12)
251 .map(|_| json!({"role": "assistant", "content": old_content}))
252 .collect();
253 messages.push(json!({"role": "user", "content": "What changed?"}));
254 let mut body = json!({"messages": messages});
255
256 let result = pre_optimize(&mut body).unwrap();
257
258 assert_eq!(
259 body["messages"][0]["content"]
260 .as_str()
261 .unwrap()
262 .chars()
263 .count(),
264 200
265 );
266 assert_eq!(
267 body["messages"][4]["content"]
268 .as_str()
269 .unwrap()
270 .chars()
271 .count(),
272 300
273 );
274 assert!(result.tokens_pruned > 0);
275 assert!(result.original_token_estimate > result.tokens_pruned);
276 }
277
278 #[test]
279 fn preserves_system_and_latest_user_messages() {
280 let system = "s".repeat(400);
281 let user = "u".repeat(400);
282 let mut body = json!({
283 "messages": [
284 {"role": "system", "content": system},
285 {"role": "assistant", "content": "a".repeat(400)},
286 {"role": "user", "content": user}
287 ]
288 });
289
290 pre_optimize(&mut body).unwrap();
291
292 assert_eq!(
293 body["messages"][0]["content"]
294 .as_str()
295 .unwrap()
296 .chars()
297 .count(),
298 400
299 );
300 assert_eq!(
301 body["messages"][2]["content"]
302 .as_str()
303 .unwrap()
304 .chars()
305 .count(),
306 400
307 );
308 }
309
310 #[test]
311 #[ignore = "requires enterprise implementation (GitLab)"]
312 fn triage_creates_a_prefetch_plan_from_live_read_trajectory() {
313 let first = "src/r4_prefetch_trajectory_first.rs";
314 let predicted = "src/r4_prefetch_trajectory_predicted.rs";
315 for path in [first, predicted, first] {
316 lean_ctx::core::context_prefetch::record_file_read(path);
317 }
318
319 let mut body = json!({
320 "messages": [{"role": "user", "content": "Please fix this endpoint"}]
321 });
322
323 assert!(pre_optimize(&mut body).is_some());
324 assert!(lean_ctx::core::context_prefetch::is_prefetch_prediction(
325 predicted
326 ));
327 }
328
329 #[test]
330 fn benchmark_pre_optimize() {
331 use std::time::Instant;
332
333 let messages: Vec<Value> = (0..20)
334 .map(|index| {
335 let role = if index == 19 { "user" } else { "assistant" };
336 let content = if index == 19 {
337 "Please fix this broken endpoint".to_owned()
338 } else {
339 "a".repeat(400)
340 };
341 json!({"role": role, "content": content})
342 })
343 .collect();
344 let body = json!({"messages": messages});
345 let mut bodies = vec![body; 1_000];
346
347 let started = Instant::now();
348 for body in &mut bodies {
349 assert!(pre_optimize(body).is_some());
350 }
351 let average = started.elapsed() / 1_000;
352 println!("benchmark_pre_optimize: {average:?} per call");
353 }
354
355 #[test]
356 fn cache_controlled_messages_are_never_truncated() {
357 let old_msg = "x".repeat(1000);
358 let mut body = json!({
359 "messages": [
360 {"role": "system", "content": [{"type": "text", "text": "rules", "cache_control": {"type": "ephemeral"}}]},
361 {"role": "user", "content": old_msg},
362 {"role": "assistant", "content": old_msg},
363 {"role": "user", "content": old_msg},
364 {"role": "assistant", "content": old_msg},
365 {"role": "user", "content": old_msg},
366 {"role": "assistant", "content": old_msg},
367 {"role": "user", "content": old_msg},
368 {"role": "assistant", "content": old_msg},
369 {"role": "user", "content": old_msg},
370 {"role": "assistant", "content": old_msg},
371 {"role": "user", "content": old_msg},
372 {"role": "assistant", "content": [{"type": "text", "text": old_msg, "cache_control": {"type": "ephemeral"}}]},
373 {"role": "user", "content": old_msg},
374 {"role": "assistant", "content": old_msg},
375 {"role": "user", "content": "what is this?"}
376 ]
377 });
378 let cached_before: Vec<_> = body["messages"].as_array().unwrap()[..13]
379 .iter()
380 .map(ToString::to_string)
381 .collect();
382
383 pre_optimize(&mut body);
384
385 let cached_after: Vec<_> = body["messages"].as_array().unwrap()[..13]
386 .iter()
387 .map(ToString::to_string)
388 .collect();
389 assert_eq!(
390 cached_before, cached_after,
391 "messages within cache_control boundary must not be modified"
392 );
393 }
394}