1use std::sync::Arc;
14
15use crate::llm::types::{CompletionRequest, ContentBlock, Message};
16use crate::llm::{BoxedProvider, LlmProvider};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RequestMode {
22 Answer,
24 Execute,
26 Study,
29 Clarify,
32}
33
34impl RequestMode {
35 pub fn label(self) -> &'static str {
37 match self {
38 RequestMode::Answer => "answer",
39 RequestMode::Execute => "execute",
40 RequestMode::Study => "study",
41 RequestMode::Clarify => "clarify",
42 }
43 }
44
45 pub fn parse(s: &str) -> Option<Self> {
47 match s.trim().to_lowercase().as_str() {
48 "answer" => Some(RequestMode::Answer),
49 "execute" => Some(RequestMode::Execute),
50 "study" => Some(RequestMode::Study),
51 "clarify" => Some(RequestMode::Clarify),
52 _ => None,
53 }
54 }
55}
56
57const GO_TOKENS: &[&str] = &[
62 "vas-y",
63 "vas y",
64 "fais-le",
65 "fais le",
66 "do it",
67 "just do it",
68 "just build it",
69 "go ahead",
70 "lance-toi",
71 "code directement",
72 "code-le",
73 "implémente directement",
74 "implemente directement",
75];
76
77const WISH_MARKERS: &[&str] = &[
79 "je souhaite",
80 "j'aimerais",
81 "je voudrais",
82 "il faudrait",
83 "ce serait bien",
84 "j'apprécierais",
85 "j'aurais besoin",
86 "i'd like",
87 "i would like",
88 "it would be nice",
89 "could you maybe",
90 "i wish",
91];
92
93const STUDY_MARKERS: &[&str] = &[
95 "regarde si",
96 "étudie",
97 "etudie",
98 "analyse",
99 "explore",
100 "réfléchis",
101 "reflechis",
102 "compare les options",
103 "est-ce qu'on peut",
104 "est-ce que l'on peut",
105 "peut-on",
106 "serait-il possible",
107 "is it possible",
108 "can we",
109 "could we",
110 "investigate",
111 "look into",
112 "deep research",
113 "que penses-tu",
114 "what do you think",
115 "évalue",
116 "evalue",
117 "étudier",
125 "etudier",
126 "étudiez",
127 "etudiez",
128 "analyser",
129 "analysez",
130 "analyze",
131 "évaluer",
132 "evaluer",
133 "évaluez",
134 "evaluez",
135 "réfléchir",
136 "reflechir",
137];
138
139const DESIGN_NOUNS: &[&str] = &[
142 "crm",
143 "app",
144 "application",
145 "site",
146 "dashboard",
147 "api",
148 "service",
149 "bot",
150 "jeu",
151 "game",
152 "outil",
153 "tool",
154 "feature",
155 "fonctionnalité",
156 "fonctionnalite",
157 "module",
158 "système",
159 "systeme",
160 "plateforme",
161 "platform",
162 "interface",
163];
164
165const QUESTION_OPENERS: &[&str] = &[
167 "que ",
168 "qu'est-ce",
169 "quoi ",
170 "comment ",
171 "pourquoi ",
172 "quel ",
173 "quelle ",
174 "quels ",
175 "quelles ",
176 "what ",
177 "why ",
178 "how ",
179 "which ",
180 "explique",
181 "explain",
182 "c'est quoi",
183];
184
185fn has_concrete_anchor(lower: &str) -> bool {
189 if lower.contains('`') {
190 return true;
191 }
192 if lower.split_whitespace().any(|w| {
195 (w.contains('/') && w.len() > 3)
196 || w.ends_with(".rs")
197 || w.ends_with(".py")
198 || w.ends_with(".ts")
199 || w.ends_with(".js")
200 || w.ends_with(".toml")
201 || w.ends_with(".md")
202 || w.ends_with(".json")
203 || w.ends_with(".txt")
204 || w.ends_with(".html")
205 || w.ends_with(".css")
206 }) {
207 return true;
208 }
209 false
210}
211
212fn has_fr_conditional_volition(lower: &str) -> bool {
215 const STEMS: &[&str] = &[
216 "voudr",
217 "aimer",
218 "faudr",
219 "pourr",
220 "souhaiter",
221 "apprécier",
222 "apprecier",
223 ];
224 lower.split_whitespace().any(|w| {
225 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
226 (w.ends_with("rais") || w.ends_with("rait") || w.ends_with("raient"))
227 && STEMS.iter().any(|s| w.starts_with(s))
228 })
229}
230
231fn contains_any(lower: &str, markers: &[&str]) -> bool {
232 markers.iter().any(|m| lower.contains(m))
233}
234
235fn contains_any_word(lower: &str, markers: &[&str]) -> bool {
241 markers.iter().any(|m| contains_word(lower, m))
242}
243
244fn contains_word(lower: &str, marker: &str) -> bool {
245 let mut from = 0;
246 while let Some(pos) = lower[from..].find(marker) {
247 let start = from + pos;
248 let end = start + marker.len();
249 let left_ok = lower[..start]
250 .chars()
251 .next_back()
252 .is_none_or(|c| !c.is_alphanumeric());
253 let right_ok = lower[end..]
254 .chars()
255 .next()
256 .is_none_or(|c| !c.is_alphanumeric());
257 if left_ok && right_ok {
258 return true;
259 }
260 from = end;
261 }
262 false
263}
264
265const GO_FILLER: &[&str] = &[
268 "ok", "oui", "yes", "yeah", "yep", "bon", "alors", "then", "et", "and", "please", "stp", "svp",
269 "d'accord", "parfait",
270];
271
272fn is_explicit_go(trimmed: &str) -> bool {
278 let normalized: String = trimmed
281 .chars()
282 .map(|c| {
283 if c.is_alphanumeric() || c == '-' || c == '\'' {
284 c
285 } else {
286 ' '
287 }
288 })
289 .collect();
290 let words: Vec<&str> = normalized.split_whitespace().collect();
291 if words.is_empty() {
292 return false;
293 }
294 let go_phrases: Vec<Vec<&str>> = GO_TOKENS
295 .iter()
296 .map(|t| t.split_whitespace().collect())
297 .collect();
298 let mut i = 0;
299 let mut saw_go = false;
300 'words: while i < words.len() {
301 for phrase in &go_phrases {
302 if words[i..].starts_with(phrase) {
303 i += phrase.len();
304 saw_go = true;
305 continue 'words;
306 }
307 }
308 if GO_FILLER.contains(&words[i]) {
309 i += 1;
310 continue;
311 }
312 return false;
313 }
314 saw_go
315}
316
317pub fn classify_l0(text: &str) -> Option<RequestMode> {
320 let lower = text.to_lowercase();
321 let trimmed = lower.trim();
322
323 if is_explicit_go(trimmed) {
327 return Some(RequestMode::Execute);
328 }
329
330 let wish = contains_any(trimmed, WISH_MARKERS) || has_fr_conditional_volition(trimmed);
331 let study = contains_any_word(trimmed, STUDY_MARKERS);
332 let design = contains_any(trimmed, DESIGN_NOUNS);
333 let anchored = has_concrete_anchor(trimmed);
334 let interrogative = QUESTION_OPENERS.iter().any(|q| trimmed.starts_with(q))
335 || (trimmed.ends_with('?') && !wish && !design);
336
337 if study {
341 return Some(RequestMode::Study);
342 }
343 if interrogative && !design {
345 return Some(RequestMode::Answer);
346 }
347 if wish {
349 if anchored && !design {
351 return Some(RequestMode::Execute);
352 }
353 if design && !anchored {
355 return Some(RequestMode::Clarify);
356 }
357 return None;
359 }
360 if design && !anchored {
362 return Some(RequestMode::Clarify);
364 }
365 if anchored {
366 return Some(RequestMode::Execute);
368 }
369 None
370}
371
372pub fn is_bare_affirmation(text: &str) -> bool {
376 let lower = text.trim().to_lowercase();
377 if lower.chars().count() > 40 {
378 return false;
379 }
380 const AFFIRMATIONS: &[&str] = &[
381 "ok",
382 "oui",
383 "yes",
384 "go",
385 "vas-y",
386 "vas y",
387 "fais-le",
388 "fais le",
389 "do it",
390 "continue",
391 "d'accord",
392 "daccord",
393 "parfait",
394 "ça marche",
395 "ca marche",
396 "allons-y",
397 "allons y",
398 "let's go",
399 "lets go",
400 "lance",
401 "ok vas-y",
402 "yep",
403 "yeah",
404 "sure",
405 "proceed",
406 ];
407 let stripped: String = lower
409 .chars()
410 .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '\'')
411 .collect();
412 let s = stripped.trim();
413 if s.is_empty() {
414 return false;
415 }
416 AFFIRMATIONS.contains(&s)
417 || s.split_whitespace()
418 .all(|w| AFFIRMATIONS.contains(&w) || matches!(w, "et" | "and" | "alors" | "then"))
419}
420
421const CLASSIFIER_SYSTEM: &str = "\
423You classify a user request sent to a coding agent into the RESPONSE MODE it \
424calls for. Reply with STRICT JSON only: {\"mode\":\"answer|execute|study|clarify\",\
425\"confidence\":0.0-1.0}.\n\
426- answer: an information question; no work requested.\n\
427- execute: a directive with a clear, specified target — act now.\n\
428- study: an investigation/feasibility/comparison request — propose, don't build.\n\
429- clarify: work is requested but key design choices are unspecified (what kind, \
430which interface, what data) — ask before building.\n\
431Judge the ILLOCUTIONARY FORCE (a hedged wish like \"je souhaite…\" over an \
432unspecified object is clarify, not execute) and the COMPLETENESS of the spec.";
433
434const CONFIDENCE_THRESHOLD: f32 = 0.6;
436
437pub struct RequestRouter {
441 fast: Option<Arc<BoxedProvider>>,
442 pin: Option<Arc<std::sync::atomic::AtomicU8>>,
444}
445
446impl RequestMode {
447 pub fn as_pin_u8(self) -> u8 {
449 match self {
450 RequestMode::Answer => 1,
451 RequestMode::Execute => 2,
452 RequestMode::Study => 3,
453 RequestMode::Clarify => 4,
454 }
455 }
456
457 pub fn from_pin_u8(v: u8) -> Option<Self> {
459 match v {
460 1 => Some(RequestMode::Answer),
461 2 => Some(RequestMode::Execute),
462 3 => Some(RequestMode::Study),
463 4 => Some(RequestMode::Clarify),
464 _ => None,
465 }
466 }
467}
468
469impl RequestRouter {
470 pub fn new(fast: Option<Arc<BoxedProvider>>) -> Self {
473 Self { fast, pin: None }
474 }
475
476 pub fn with_pin(mut self, pin: Arc<std::sync::atomic::AtomicU8>) -> Self {
479 self.pin = Some(pin);
480 self
481 }
482
483 pub fn pinned_mode(&self) -> Option<RequestMode> {
487 self.pin
488 .as_ref()
489 .and_then(|p| RequestMode::from_pin_u8(p.load(std::sync::atomic::Ordering::Relaxed)))
490 }
491
492 pub async fn route(&self, text: &str) -> RoutedMode {
496 if let Some(pin) = &self.pin
497 && let Some(mode) =
498 RequestMode::from_pin_u8(pin.load(std::sync::atomic::Ordering::Relaxed))
499 {
500 return RoutedMode {
501 mode,
502 source: RouteSource::Pinned,
503 confidence: 1.0,
504 };
505 }
506 if let Some(mode) = classify_l0(text) {
507 return RoutedMode {
508 mode,
509 source: RouteSource::Markers,
510 confidence: 1.0,
511 };
512 }
513 if let Some(fast) = &self.fast
514 && let Some((mode, confidence)) = self.classify_l1(fast, text).await
515 && confidence >= CONFIDENCE_THRESHOLD
516 {
517 return RoutedMode {
518 mode,
519 source: RouteSource::Classifier,
520 confidence,
521 };
522 }
523 let lower = text.to_lowercase();
526 let mode = if contains_any(&lower, DESIGN_NOUNS) {
527 RequestMode::Clarify
528 } else {
529 RequestMode::Study
530 };
531 RoutedMode {
532 mode,
533 source: RouteSource::SafeDefault,
534 confidence: 0.0,
535 }
536 }
537
538 async fn classify_l1(
539 &self,
540 fast: &Arc<BoxedProvider>,
541 text: &str,
542 ) -> Option<(RequestMode, f32)> {
543 let request = CompletionRequest {
544 system: CLASSIFIER_SYSTEM.to_string(),
545 messages: vec![Message::user(format!("REQUEST:\n{text}"))],
546 tools: Vec::new(),
547 max_tokens: 64,
548 tool_choice: None,
549 reasoning_effort: None,
550 };
551 let response = fast.complete(request).await.ok()?;
552 let body: String = response
553 .content
554 .iter()
555 .filter_map(|b| match b {
556 ContentBlock::Text { text } => Some(text.as_str()),
557 _ => None,
558 })
559 .collect();
560 let start = body.find('{')?;
563 let end = body.rfind('}')?;
564 if end <= start {
565 return None;
566 }
567 let v: serde_json::Value = serde_json::from_str(&body[start..=end]).ok()?;
568 let mode = RequestMode::parse(v.get("mode")?.as_str()?)?;
569 let confidence = v.get("confidence")?.as_f64().unwrap_or(0.0) as f32;
570 Some((mode, confidence))
571 }
572}
573
574#[derive(Debug, Clone, Copy, PartialEq)]
576pub struct RoutedMode {
577 pub mode: RequestMode,
579 pub source: RouteSource,
581 pub confidence: f32,
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub enum RouteSource {
588 Markers,
590 Classifier,
592 SafeDefault,
594 Pinned,
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 const L0_FIXTURES: &[(&str, RequestMode)] = &[
605 (
607 "je souhaite créer un petit crm dans un répertoire temporaire",
608 RequestMode::Clarify,
609 ),
610 ("j'aimerais une petite app de notes", RequestMode::Clarify),
611 (
612 "je voudrais un dashboard pour suivre mes ventes",
613 RequestMode::Clarify,
614 ),
615 ("il faudrait un outil de migration", RequestMode::Clarify),
616 ("i'd like a small crm for my contacts", RequestMode::Clarify),
617 ("construis-moi un crm", RequestMode::Clarify),
619 ("crée une app de todo", RequestMode::Clarify),
620 ("build me a dashboard", RequestMode::Clarify),
621 (
623 "j'aimerais que tu renommes la fonction `foo` en `bar`",
624 RequestMode::Execute,
625 ),
626 (
627 "je voudrais corriger la typo dans src/main.rs",
628 RequestMode::Execute,
629 ),
630 (
631 "i'd like you to fix the import in lib.rs",
632 RequestMode::Execute,
633 ),
634 ("renomme foo en bar dans src/app.rs", RequestMode::Execute),
636 ("corrige la typo dans README.md", RequestMode::Execute),
637 (
638 "ajoute un test dans crates/core/tests.rs",
639 RequestMode::Execute,
640 ),
641 ("vas-y", RequestMode::Execute),
644 ("ok vas-y !", RequestMode::Execute),
645 ("just do it", RequestMode::Execute),
646 ("go ahead", RequestMode::Execute),
647 ("vas-y code directement le crm", RequestMode::Clarify),
651 ("je souhaite un crm — just build it", RequestMode::Clarify),
652 (
654 "corrige l'analyseur dans src/parse.rs",
655 RequestMode::Execute,
656 ),
657 (
658 "add tests for the explorer view in src/ui.rs",
659 RequestMode::Execute,
660 ),
661 ("analyse les options de cache", RequestMode::Study),
662 ("regarde si on peut accélérer le build", RequestMode::Study),
664 (
665 "étudie les options de persistance pour le module",
666 RequestMode::Study,
667 ),
668 (
669 "est-ce qu'on peut paralléliser les tests ?",
670 RequestMode::Study,
671 ),
672 ("can we speed up the ci pipeline?", RequestMode::Study),
673 (
674 "que penses-tu d'une migration vers axum ?",
675 RequestMode::Study,
676 ),
677 ("que sais-tu faire ?", RequestMode::Answer),
679 ("comment fonctionne le scheduler ?", RequestMode::Answer),
680 ("explique-moi le rôle du pruner", RequestMode::Answer),
681 ("what does the doom loop detector do?", RequestMode::Answer),
682 ("pourquoi le build est-il si lent ?", RequestMode::Answer),
683 ];
684
685 #[test]
686 fn l0_fixture_set_routes_native_requests() {
687 let mut failures = Vec::new();
688 for (text, expected) in L0_FIXTURES {
689 match classify_l0(text) {
690 Some(mode) if mode == *expected => {}
691 got => failures.push(format!("{text:?} → {got:?}, expected {expected:?}")),
692 }
693 }
694 assert!(
695 failures.is_empty(),
696 "L0 misroutes ({}/{}):\n{}",
697 failures.len(),
698 L0_FIXTURES.len(),
699 failures.join("\n")
700 );
701 }
702
703 const L0_AMBIGUOUS: &[&str] = &[
705 "je souhaite améliorer les choses",
706 "fais quelque chose de bien",
707 "on pourrait faire mieux",
708 "help me with the project",
709 ];
710
711 #[test]
712 fn l0_does_not_guess_on_ambiguity() {
713 for text in L0_AMBIGUOUS {
714 assert_eq!(
715 classify_l0(text),
716 None,
717 "{text:?} must fall through to L1/L2"
718 );
719 }
720 }
721
722 #[test]
726 fn explicit_go_requires_the_whole_message() {
727 for s in [
728 "vas-y",
729 "Vas-y !",
730 "ok vas-y",
731 "fais-le.",
732 "do it",
733 "go ahead",
734 "oui, fais-le alors",
735 "vas y",
736 ] {
737 assert_eq!(
738 classify_l0(s),
739 Some(RequestMode::Execute),
740 "{s:?} is an explicit go"
741 );
742 }
743 }
744
745 #[test]
746 fn embedded_or_negated_go_does_not_promote() {
747 for s in [
748 "don't do it yet, first show me the plan",
749 "before you go ahead, explain the tradeoffs",
750 "tu vas y arriver",
751 "ne le fais pas tout de suite, vas-y étape par étape",
752 ] {
753 assert_ne!(
754 classify_l0(s),
755 Some(RequestMode::Execute),
756 "{s:?} must NOT promote to EXECUTE"
757 );
758 }
759 }
760
761 #[test]
765 fn study_stems_do_not_match_inside_words() {
766 for s in [
767 "corrige l'analyseur dans src/parse.rs",
768 "add tests for the explorer view in src/ui.rs",
769 "améliore l'exploitation des logs dans src/log.rs",
770 ] {
771 assert_eq!(
772 classify_l0(s),
773 Some(RequestMode::Execute),
774 "{s:?} is an anchored directive, not a study"
775 );
776 }
777 for s in [
779 "analyse les options de cache",
780 "explore les alternatives à kafka",
781 ] {
782 assert_eq!(
783 classify_l0(s),
784 Some(RequestMode::Study),
785 "{s:?} is a study request"
786 );
787 }
788 }
789
790 #[test]
796 fn inflected_study_verbs_with_anchor_route_study_not_execute() {
797 for s in [
798 "analyser le module dans src/cache.rs",
799 "étudier l'option de persistance dans src/db.rs",
800 "évaluer les alternatives à kafka dans src/bus.rs",
801 "analysez le coût du parsing dans src/parse.rs",
802 ] {
803 assert_eq!(
804 classify_l0(s),
805 Some(RequestMode::Study),
806 "{s:?} is an inflected study verb — must route STUDY, never EXECUTE"
807 );
808 }
809 for s in [
812 "corrige l'analyseur dans src/parse.rs",
813 "répare l'évaluateur d'expressions dans src/eval.rs",
814 ] {
815 assert_eq!(
816 classify_l0(s),
817 Some(RequestMode::Execute),
818 "{s:?} names a noun, not a study verb"
819 );
820 }
821 }
822
823 #[test]
824 fn bare_affirmations_detected_long_content_not() {
825 for s in ["ok", "vas-y", "ok vas-y", "oui continue", "go", "d'accord"] {
826 assert!(is_bare_affirmation(s), "{s:?} is a bare affirmation");
827 }
828 for s in [
829 "vas-y mais utilise plutôt sqlite et ajoute une api rest",
830 "ok mais d'abord explique-moi le schéma",
831 "",
832 ] {
833 assert!(!is_bare_affirmation(s), "{s:?} is NOT bare");
834 }
835 }
836
837 #[test]
838 fn mode_labels_roundtrip() {
839 for m in [
840 RequestMode::Answer,
841 RequestMode::Execute,
842 RequestMode::Study,
843 RequestMode::Clarify,
844 ] {
845 assert_eq!(RequestMode::parse(m.label()), Some(m));
846 }
847 assert_eq!(RequestMode::parse("nonsense"), None);
848 }
849
850 struct StubFast {
853 reply: String,
854 }
855 impl LlmProvider for StubFast {
856 async fn complete(
857 &self,
858 _r: CompletionRequest,
859 ) -> Result<crate::llm::types::CompletionResponse, crate::error::Error> {
860 Ok(crate::llm::types::CompletionResponse {
861 content: vec![ContentBlock::Text {
862 text: self.reply.clone(),
863 }],
864 stop_reason: crate::llm::types::StopReason::EndTurn,
865 reasoning: None,
866 usage: crate::llm::types::TokenUsage::default(),
867 model: None,
868 })
869 }
870 }
871
872 fn router_with(reply: &str) -> RequestRouter {
873 RequestRouter::new(Some(Arc::new(BoxedProvider::from_arc(Arc::new(
874 StubFast {
875 reply: reply.into(),
876 },
877 )))))
878 }
879
880 #[tokio::test]
881 async fn pinned_mode_overrides_routing() {
882 let pin = Arc::new(std::sync::atomic::AtomicU8::new(
883 RequestMode::Study.as_pin_u8(),
884 ));
885 let r = RequestRouter::new(None).with_pin(pin.clone());
886 let routed = r.route("corrige la typo dans src/a.rs").await;
888 assert_eq!(routed.mode, RequestMode::Study);
889 assert_eq!(routed.source, RouteSource::Pinned);
890 pin.store(0, std::sync::atomic::Ordering::Relaxed);
892 let routed = r.route("corrige la typo dans src/a.rs").await;
893 assert_eq!(routed.mode, RequestMode::Execute);
894 }
895
896 #[tokio::test]
897 async fn l1_classifier_routes_the_ambiguous_residue() {
898 let r = router_with(r#"{"mode":"study","confidence":0.9}"#);
899 let routed = r.route("je souhaite améliorer les choses").await;
900 assert_eq!(routed.mode, RequestMode::Study);
901 assert_eq!(routed.source, RouteSource::Classifier);
902 }
903
904 #[tokio::test]
905 async fn low_confidence_falls_back_to_safe_default() {
906 let r = router_with(r#"{"mode":"execute","confidence":0.3}"#);
907 let routed = r.route("fais quelque chose de bien").await;
908 assert_eq!(routed.source, RouteSource::SafeDefault);
909 assert_eq!(routed.mode, RequestMode::Study, "non-design text → STUDY");
910 }
911
912 #[tokio::test]
913 async fn degraded_no_fast_path_safe_defaults() {
914 let r = RequestRouter::new(None);
915 let routed = r
919 .route("je souhaite un crm comme décrit dans specs/crm.md")
920 .await;
921 assert_eq!(routed.source, RouteSource::SafeDefault);
922 assert_eq!(
923 routed.mode,
924 RequestMode::Clarify,
925 "design-ish ambiguous text → CLARIFY"
926 );
927 }
928
929 #[tokio::test]
933 async fn classifier_reply_with_brace_before_open_does_not_panic() {
934 let r = router_with("nope} I cannot classify this {");
935 let routed = r.route("fais quelque chose de bien").await;
936 assert_eq!(routed.source, RouteSource::SafeDefault);
937 }
938
939 #[tokio::test]
940 async fn garbage_classifier_reply_safe_defaults() {
941 let r = router_with("i refuse to answer in json");
942 let routed = r.route("fais quelque chose de bien").await;
943 assert_eq!(routed.source, RouteSource::SafeDefault);
944 }
945
946 #[tokio::test]
947 async fn l0_confident_skips_the_classifier() {
948 let r = router_with(r#"{"mode":"execute","confidence":0.99}"#);
951 let routed = r.route("je souhaite créer un petit crm").await;
952 assert_eq!(routed.mode, RequestMode::Clarify);
953 assert_eq!(routed.source, RouteSource::Markers);
954 }
955}