1use serde_json::Value;
39use std::sync::atomic::{AtomicU64, Ordering};
40
41use crate::core::config::Effort;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TurnClass {
46 Routine,
48 Full,
50}
51
52#[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
62pub 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
84pub 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 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
107pub 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 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 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 if blocks.len() > 3 {
146 return TurnClass::Full;
147 }
148
149 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
167pub fn effort_for_turn(class: TurnClass, base: Effort) -> Effort {
170 match class {
171 TurnClass::Routine => {
172 ROUTINE_COUNT.fetch_add(1, Ordering::Relaxed);
173 Effort::Minimal
175 }
176 TurnClass::Full => {
177 FULL_COUNT.fetch_add(1, Ordering::Relaxed);
178 base
179 }
180 }
181}
182
183pub 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
221pub fn stats() -> RoutingStats {
223 RoutingStats {
224 routine_count: ROUTINE_COUNT.load(Ordering::Relaxed),
225 full_count: FULL_COUNT.load(Ordering::Relaxed),
226 }
227}
228
229fn 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
247fn is_routine_tool_output(content: &str) -> bool {
249 if content.is_empty() || content.len() < 10 {
250 return false;
251 }
252
253 if contains_error_indicators(content) {
255 return false;
256 }
257
258 if content.len() > 8000 {
260 return false;
261 }
262
263 let routine_signals = [
265 "deps ", "[unchanged", "[lean-ctx]", "lines:", "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 "matches in",
281 "0 matches",
282 ];
283
284 routine_signals.iter().any(|sig| content.contains(sig))
285}
286
287fn 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
315fn 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}