Skip to main content

lean_ctx/proxy/
effort_routing.rs

1//! Per-turn effort routing (#1148, opt-in dynamic thinking budget).
2//!
3//! Unlike the static `effort.rs` which applies a constant reasoning level
4//! across all turns (for cache stability), this module classifies each turn
5//! and adjusts thinking effort dynamically.
6//!
7//! **Opt-in only** (`proxy.effort_routing = true`). When disabled, the static
8//! `effort.rs` path remains the sole controller. When enabled, this module
9//! overrides the static level with a per-turn classification.
10//!
11//! ## Cache stability tradeoff
12//!
13//! Provider prompt caches (Anthropic `cache_control`, OpenAI prefix caching)
14//! break when reasoning parameters change. This module accepts that tradeoff
15//! because:
16//! 1. Output tokens on Opus-class models cost **5x** input tokens — savings
17//!    from reduced thinking often exceed the cache-miss penalty.
18//! 2. Routine turns (file reads, passing tests) generate disproportionate
19//!    thinking waste for trivial tool-result acknowledgements.
20//! 3. The module uses a **two-level** strategy (not N levels) to minimize cache
21//!    key diversity: `routine` or `full` — only two cache prefixes to warm.
22//!
23//! ## Classification
24//!
25//! A turn is classified as **routine** when the last assistant message was a
26//! tool call and the tool result indicates success on a non-complex operation:
27//! - File read (tool_use with `ctx_read`, `read_file`, `Read`)
28//! - Successful shell command (exit_code == 0, no error indicators)
29//! - Search results (grep/glob/find)
30//! - Status checks (git status, test passing)
31//!
32//! A turn is classified as **full** (keep maximum thinking) when:
33//! - The user sent a new message (requires understanding intent)
34//! - The tool result contains errors/failures
35//! - Multiple tool results arrived (complex multi-step)
36//! - The content is architecturally complex (refactoring, debugging)
37
38use serde_json::Value;
39use std::sync::atomic::{AtomicU64, Ordering};
40
41use crate::core::config::Effort;
42
43/// Turn classification result.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TurnClass {
46    /// Routine tool-result acknowledgement — minimize thinking.
47    Routine,
48    /// Full complexity — keep maximum thinking effort.
49    Full,
50}
51
52/// Statistics for monitoring effort routing effectiveness.
53#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
54pub struct RoutingStats {
55    pub routine_count: u64,
56    pub full_count: u64,
57}
58
59static ROUTINE_COUNT: AtomicU64 = AtomicU64::new(0);
60static FULL_COUNT: AtomicU64 = AtomicU64::new(0);
61
62/// Classify the current turn based on the message array.
63/// Returns `Routine` if the latest context is a simple tool-result
64/// acknowledgement, `Full` otherwise.
65pub fn classify_turn(messages: &Value) -> TurnClass {
66    let Some(arr) = messages.as_array() else {
67        return TurnClass::Full;
68    };
69
70    if arr.is_empty() {
71        return TurnClass::Full;
72    }
73
74    let last = &arr[arr.len() - 1];
75    let role = last.get("role").and_then(Value::as_str).unwrap_or("");
76
77    if role == "tool" {
78        classify_tool_result(last, arr)
79    } else {
80        TurnClass::Full
81    }
82}
83
84/// Classify based on OpenAI Responses API `input` array (different structure).
85pub fn classify_turn_responses(input: &Value) -> TurnClass {
86    let Some(arr) = input.as_array() else {
87        return TurnClass::Full;
88    };
89    if arr.is_empty() {
90        return TurnClass::Full;
91    }
92
93    // In Responses API, look for the last item's type.
94    let last = &arr[arr.len() - 1];
95    let item_type = last.get("type").and_then(Value::as_str).unwrap_or("");
96
97    if item_type == "function_call_output" {
98        let output = last.get("output").and_then(Value::as_str).unwrap_or("");
99        if is_routine_tool_output(output) {
100            return TurnClass::Routine;
101        }
102    }
103
104    TurnClass::Full
105}
106
107/// Classify based on Anthropic messages structure.
108pub fn classify_turn_anthropic(messages: &Value) -> TurnClass {
109    let Some(arr) = messages.as_array() else {
110        return TurnClass::Full;
111    };
112    if arr.is_empty() {
113        return TurnClass::Full;
114    }
115
116    let last = &arr[arr.len() - 1];
117    let role = last.get("role").and_then(Value::as_str).unwrap_or("");
118
119    if role != "user" {
120        return TurnClass::Full;
121    }
122
123    // Anthropic puts tool_results in user messages with content array.
124    let content = last.get("content");
125    if let Some(Value::Array(blocks)) = content {
126        let all_tool_results = !blocks.is_empty()
127            && blocks
128                .iter()
129                .all(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"));
130
131        if all_tool_results {
132            // Check if any tool result has errors.
133            let has_errors = blocks.iter().any(|b| {
134                b.get("is_error") == Some(&Value::Bool(true))
135                    || b.get("content")
136                        .and_then(|c| c.as_str().or_else(|| extract_text_from_content(c)))
137                        .is_some_and(contains_error_indicators)
138            });
139
140            if has_errors {
141                return TurnClass::Full;
142            }
143
144            // Multiple tool results → likely complex multi-step.
145            if blocks.len() > 3 {
146                return TurnClass::Full;
147            }
148
149            // Check individual results.
150            let all_routine = blocks.iter().all(|b| {
151                let text = b
152                    .get("content")
153                    .and_then(|c| c.as_str().or_else(|| extract_text_from_content(c)))
154                    .unwrap_or("");
155                is_routine_tool_output(text)
156            });
157
158            if all_routine {
159                return TurnClass::Routine;
160            }
161        }
162    }
163
164    TurnClass::Full
165}
166
167/// Map a turn classification to the effort level to apply.
168/// `base` is the operator's configured static effort level.
169pub fn effort_for_turn(class: TurnClass, base: Effort) -> Effort {
170    match class {
171        TurnClass::Routine => {
172            ROUTINE_COUNT.fetch_add(1, Ordering::Relaxed);
173            // Routine turns get minimal thinking regardless of base.
174            Effort::Minimal
175        }
176        TurnClass::Full => {
177            FULL_COUNT.fetch_add(1, Ordering::Relaxed);
178            base
179        }
180    }
181}
182
183/// Adjust effort from a classifier intent while preserving the configured
184/// effort for intents that are not clearly simple reads or coding work.
185pub fn intent_aware_effort(intent: &str, base_effort: Effort) -> Effort {
186    let intent = intent.to_ascii_lowercase();
187    if [
188        "code",
189        "coding",
190        "fix",
191        "implement",
192        "refactor",
193        "debug",
194        "build",
195        "patch",
196        "test",
197    ]
198    .iter()
199    .any(|term| intent.contains(term))
200    {
201        Effort::High
202    } else if [
203        "read",
204        "list",
205        "show",
206        "explain",
207        "summarize",
208        "status",
209        "search",
210        "lookup",
211    ]
212    .iter()
213    .any(|term| intent.contains(term))
214    {
215        Effort::Minimal
216    } else {
217        base_effort
218    }
219}
220
221/// Snapshot routing statistics.
222pub fn stats() -> RoutingStats {
223    RoutingStats {
224        routine_count: ROUTINE_COUNT.load(Ordering::Relaxed),
225        full_count: FULL_COUNT.load(Ordering::Relaxed),
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Internal classification helpers
231// ---------------------------------------------------------------------------
232
233fn classify_tool_result(msg: &Value, _all_messages: &[Value]) -> TurnClass {
234    let content = msg.get("content").and_then(Value::as_str).unwrap_or("");
235
236    if contains_error_indicators(content) {
237        return TurnClass::Full;
238    }
239
240    if is_routine_tool_output(content) {
241        return TurnClass::Routine;
242    }
243
244    TurnClass::Full
245}
246
247/// Heuristic: does this tool output look like a routine, successful result?
248fn is_routine_tool_output(content: &str) -> bool {
249    if content.is_empty() || content.len() < 10 {
250        return false;
251    }
252
253    // Error indicators → not routine.
254    if contains_error_indicators(content) {
255        return false;
256    }
257
258    // Very large outputs (>8000 chars) likely need careful processing.
259    if content.len() > 8000 {
260        return false;
261    }
262
263    // Positive signals for routine:
264    let routine_signals = [
265        // File read results (lean-ctx or native).
266        "deps ",      // lean-ctx read header
267        "[unchanged", // cached re-read
268        "[lean-ctx]", // lean-ctx footer
269        "lines:",     // line count indicators
270        // Shell success patterns.
271        "exit_code: 0",
272        "Command completed",
273        "0 errors",
274        "All tests passed",
275        "no changes",
276        "nothing to commit",
277        "Already up to date",
278        "Build succeeded",
279        // Search results.
280        "matches in",
281        "0 matches",
282    ];
283
284    routine_signals.iter().any(|sig| content.contains(sig))
285}
286
287/// Check if content contains error/failure indicators that need full thinking.
288fn contains_error_indicators(content: &str) -> bool {
289    let lower = content.to_ascii_lowercase();
290    let indicators = [
291        "error",
292        "failed",
293        "failure",
294        "fatal",
295        "panic",
296        "exception",
297        "traceback",
298        "stack trace",
299        "segfault",
300        "abort",
301        "denied",
302        "permission",
303        "not found",
304        "timed out",
305        "exit_code: 1",
306        "exit code 1",
307        "compilation error",
308        "syntax error",
309        "type error",
310    ];
311
312    indicators.iter().any(|ind| lower.contains(ind))
313}
314
315/// Extract text from Anthropic content blocks.
316fn extract_text_from_content(content: &Value) -> Option<&str> {
317    if let Some(arr) = content.as_array() {
318        for block in arr {
319            if block.get("type").and_then(Value::as_str) == Some("text") {
320                return block.get("text").and_then(Value::as_str);
321            }
322        }
323    }
324    None
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331
332    #[test]
333    fn user_message_is_always_full() {
334        let messages = json!([
335            {"role": "user", "content": "What does this function do?"}
336        ]);
337        assert_eq!(classify_turn(&messages), TurnClass::Full);
338    }
339
340    #[test]
341    fn successful_file_read_is_routine() {
342        let messages = json!([
343            {"role": "assistant", "content": "Let me read that file."},
344            {"role": "tool", "content": "main.rs 50L\n  deps serde\n[lean-ctx] full source: ..."}
345        ]);
346        assert_eq!(classify_turn(&messages), TurnClass::Routine);
347    }
348
349    #[test]
350    fn error_tool_result_is_full() {
351        let messages = json!([
352            {"role": "tool", "content": "error[E0308]: mismatched types\n  --> src/main.rs:5:12"}
353        ]);
354        assert_eq!(classify_turn(&messages), TurnClass::Full);
355    }
356
357    #[test]
358    fn successful_shell_is_routine() {
359        let messages = json!([
360            {"role": "tool", "content": "Command completed in 150ms\nexit_code: 0\nAll tests passed"}
361        ]);
362        assert_eq!(classify_turn(&messages), TurnClass::Routine);
363    }
364
365    #[test]
366    fn anthropic_tool_result_routine() {
367        let messages = json!([
368            {"role": "user", "content": [
369                {"type": "tool_result", "tool_use_id": "abc", "content": "[unchanged 5L]\n[lean-ctx] cached"}
370            ]}
371        ]);
372        assert_eq!(classify_turn_anthropic(&messages), TurnClass::Routine);
373    }
374
375    #[test]
376    fn anthropic_tool_result_with_error() {
377        let messages = json!([
378            {"role": "user", "content": [
379                {"type": "tool_result", "tool_use_id": "abc", "is_error": true, "content": "Tool failed"}
380            ]}
381        ]);
382        assert_eq!(classify_turn_anthropic(&messages), TurnClass::Full);
383    }
384
385    #[test]
386    fn effort_mapping() {
387        assert_eq!(
388            effort_for_turn(TurnClass::Routine, Effort::High),
389            Effort::Minimal
390        );
391        assert_eq!(effort_for_turn(TurnClass::Full, Effort::High), Effort::High);
392        assert_eq!(
393            effort_for_turn(TurnClass::Full, Effort::Medium),
394            Effort::Medium
395        );
396    }
397
398    #[test]
399    fn intent_adjusts_effort_by_task_complexity() {
400        assert_eq!(
401            intent_aware_effort("fix the parser", Effort::Low),
402            Effort::High
403        );
404        assert_eq!(
405            intent_aware_effort("read the config", Effort::High),
406            Effort::Minimal
407        );
408        assert_eq!(intent_aware_effort("chat", Effort::Medium), Effort::Medium);
409    }
410
411    #[test]
412    fn empty_messages_is_full() {
413        assert_eq!(classify_turn(&json!([])), TurnClass::Full);
414        assert_eq!(classify_turn(&json!(null)), TurnClass::Full);
415    }
416
417    #[test]
418    fn deterministic_classification() {
419        let messages = json!([
420            {"role": "tool", "content": "Build succeeded\nexit_code: 0\nCommand completed in 2s"}
421        ]);
422        let c1 = classify_turn(&messages);
423        let c2 = classify_turn(&messages);
424        assert_eq!(c1, c2, "classification must be deterministic");
425    }
426}