Skip to main content

lean_ctx_proxy/
pre_optimize.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
16/// Classify the latest user request and trim sufficiently old, low-relevance
17/// message text before the main prompt optimizer runs.
18pub 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    // Under protection a cut needs a recovery copy bound to the lineage of
154    // the text. Host-authored turns have none, so they stay whole and the
155    // reason is counted; text the Engine delivered is cut with its stub.
156    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}