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/// Snapshot routing statistics.
184pub fn stats() -> RoutingStats {
185    RoutingStats {
186        routine_count: ROUTINE_COUNT.load(Ordering::Relaxed),
187        full_count: FULL_COUNT.load(Ordering::Relaxed),
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Internal classification helpers
193// ---------------------------------------------------------------------------
194
195fn classify_tool_result(msg: &Value, _all_messages: &[Value]) -> TurnClass {
196    let content = msg.get("content").and_then(Value::as_str).unwrap_or("");
197
198    if contains_error_indicators(content) {
199        return TurnClass::Full;
200    }
201
202    if is_routine_tool_output(content) {
203        return TurnClass::Routine;
204    }
205
206    TurnClass::Full
207}
208
209/// Heuristic: does this tool output look like a routine, successful result?
210fn is_routine_tool_output(content: &str) -> bool {
211    if content.is_empty() || content.len() < 10 {
212        return false;
213    }
214
215    // Error indicators → not routine.
216    if contains_error_indicators(content) {
217        return false;
218    }
219
220    // Very large outputs (>8000 chars) likely need careful processing.
221    if content.len() > 8000 {
222        return false;
223    }
224
225    // Positive signals for routine:
226    let routine_signals = [
227        // File read results (lean-ctx or native).
228        "deps ",      // lean-ctx read header
229        "[unchanged", // cached re-read
230        "[lean-ctx]", // lean-ctx footer
231        "lines:",     // line count indicators
232        // Shell success patterns.
233        "exit_code: 0",
234        "Command completed",
235        "0 errors",
236        "All tests passed",
237        "no changes",
238        "nothing to commit",
239        "Already up to date",
240        "Build succeeded",
241        // Search results.
242        "matches in",
243        "0 matches",
244    ];
245
246    routine_signals.iter().any(|sig| content.contains(sig))
247}
248
249/// Check if content contains error/failure indicators that need full thinking.
250fn contains_error_indicators(content: &str) -> bool {
251    let lower = content.to_ascii_lowercase();
252    let indicators = [
253        "error",
254        "failed",
255        "failure",
256        "fatal",
257        "panic",
258        "exception",
259        "traceback",
260        "stack trace",
261        "segfault",
262        "abort",
263        "denied",
264        "permission",
265        "not found",
266        "timed out",
267        "exit_code: 1",
268        "exit code 1",
269        "compilation error",
270        "syntax error",
271        "type error",
272    ];
273
274    indicators.iter().any(|ind| lower.contains(ind))
275}
276
277/// Extract text from Anthropic content blocks.
278fn extract_text_from_content(content: &Value) -> Option<&str> {
279    if let Some(arr) = content.as_array() {
280        for block in arr {
281            if block.get("type").and_then(Value::as_str) == Some("text") {
282                return block.get("text").and_then(Value::as_str);
283            }
284        }
285    }
286    None
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use serde_json::json;
293
294    #[test]
295    fn user_message_is_always_full() {
296        let messages = json!([
297            {"role": "user", "content": "What does this function do?"}
298        ]);
299        assert_eq!(classify_turn(&messages), TurnClass::Full);
300    }
301
302    #[test]
303    fn successful_file_read_is_routine() {
304        let messages = json!([
305            {"role": "assistant", "content": "Let me read that file."},
306            {"role": "tool", "content": "main.rs 50L\n  deps serde\n[lean-ctx] full source: ..."}
307        ]);
308        assert_eq!(classify_turn(&messages), TurnClass::Routine);
309    }
310
311    #[test]
312    fn error_tool_result_is_full() {
313        let messages = json!([
314            {"role": "tool", "content": "error[E0308]: mismatched types\n  --> src/main.rs:5:12"}
315        ]);
316        assert_eq!(classify_turn(&messages), TurnClass::Full);
317    }
318
319    #[test]
320    fn successful_shell_is_routine() {
321        let messages = json!([
322            {"role": "tool", "content": "Command completed in 150ms\nexit_code: 0\nAll tests passed"}
323        ]);
324        assert_eq!(classify_turn(&messages), TurnClass::Routine);
325    }
326
327    #[test]
328    fn anthropic_tool_result_routine() {
329        let messages = json!([
330            {"role": "user", "content": [
331                {"type": "tool_result", "tool_use_id": "abc", "content": "[unchanged 5L]\n[lean-ctx] cached"}
332            ]}
333        ]);
334        assert_eq!(classify_turn_anthropic(&messages), TurnClass::Routine);
335    }
336
337    #[test]
338    fn anthropic_tool_result_with_error() {
339        let messages = json!([
340            {"role": "user", "content": [
341                {"type": "tool_result", "tool_use_id": "abc", "is_error": true, "content": "Tool failed"}
342            ]}
343        ]);
344        assert_eq!(classify_turn_anthropic(&messages), TurnClass::Full);
345    }
346
347    #[test]
348    fn effort_mapping() {
349        assert_eq!(
350            effort_for_turn(TurnClass::Routine, Effort::High),
351            Effort::Minimal
352        );
353        assert_eq!(effort_for_turn(TurnClass::Full, Effort::High), Effort::High);
354        assert_eq!(
355            effort_for_turn(TurnClass::Full, Effort::Medium),
356            Effort::Medium
357        );
358    }
359
360    #[test]
361    fn empty_messages_is_full() {
362        assert_eq!(classify_turn(&json!([])), TurnClass::Full);
363        assert_eq!(classify_turn(&json!(null)), TurnClass::Full);
364    }
365
366    #[test]
367    fn deterministic_classification() {
368        let messages = json!([
369            {"role": "tool", "content": "Build succeeded\nexit_code: 0\nCommand completed in 2s"}
370        ]);
371        let c1 = classify_turn(&messages);
372        let c2 = classify_turn(&messages);
373        assert_eq!(c1, c2, "classification must be deterministic");
374    }
375}