//! Détecteur de limite de session **niveau 2 — déclaratif** (ARCHITECTURE §21, //! niveau 2 de la hiérarchie §21.1). Pour les agents **PTY/TUI sans adapter //! structuré**, on n'a pas de flux machine : on **observe la sortie texte** et on y //! cherche le motif déclaré par le profil ([`RateLimitPattern`], LS1). //! //! # Frontière T2 respectée //! //! Le **domaine** ne porte que la *donnée* du motif ; le **moteur regex** et le //! **parsing d'heure** vivent **ici** (§21.2-T2). Aucune `regex` ne franchit la //! frontière domaine : ce module produit un [`SessionLimit`] (valeur domaine pure). //! //! # Robustesse (« solide même pour un novice ») //! //! Une regex **invalide** dans un profil mal configuré ⇒ [`RateLimitParser::new`] //! renvoie `None` (pas de détecteur) : **jamais** de panique ni d'erreur fatale. Un //! profil pourri ne doit pas faire tomber IdeA. //! //! # Anti-double-détection niveau 1 / niveau 2 (§21.10-4) //! //! Le niveau 2 ne s'applique **qu'aux agents sans adapter structuré** : voir //! [`applies`]. Un agent qui a un `structured_adapter` détecte sa limite par le //! niveau 1 (flux machine, `session/claude.rs`) ; on ne lui arme **pas** de parser //! niveau 2 ⇒ pas de double détection. C'est la **règle de sélection** que le câblage //! (LS7) doit appliquer pour décider d'instancier — ou non — un [`RateLimitParser`]. use regex::Regex; use domain::profile::{AgentProfile, RateLimitPattern}; use domain::session_limit::{RateLimitSource, SessionLimit}; use crate::timeparse; /// Règle de sélection (§21.10-4) : le détecteur niveau 2 ne s'applique qu'aux agents /// **sans adapter structuré** (sinon le niveau 1 détecte déjà) **et** dont le profil /// déclare un `rate_limit_pattern`. Source **unique** de la règle anti-double- /// détection, à appeler par le câblage PTY (LS7) avant d'instancier un parser. #[must_use] pub fn applies(profile: &AgentProfile) -> bool { profile.structured_adapter.is_none() && profile.rate_limit_pattern.is_some() } /// Stratégie d'interprétation de l'heure capturée, déduite **une seule fois** du /// champ `time_format` du profil (donnée opaque au domaine, comprise ici). /// /// Jeux de jetons reconnus (insensibles à la casse) ; tout le reste ⇒ [`Self::Auto`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ResetTimeFormat { /// `None`/inconnu : best-effort **absolu** (epoch entier/float, ou ISO-8601). Auto, /// `"epoch_s"` / `"epoch_seconds"` / `"unix_s"` : entier epoch en **secondes**. EpochSeconds, /// `"epoch_ms"` / `"epoch_millis"` / `"unix_ms"` : entier epoch en **ms**. EpochMillis, /// `"iso8601"` / `"rfc3339"` / `"iso"` : chaîne ISO-8601 absolue. Iso8601, /// `"relative_s"` / `"relative_seconds"` / `"duration_s"` / `"retry_after_s"` : /// **délai** en secondes ⇒ `now + delta`. RelativeSeconds, /// `"relative_ms"` / `"relative_millis"` : **délai** en ms ⇒ `now + delta`. RelativeMillis, /// `"wall"` / `"wall_clock"` / `"local"` / `"hh:mm"` : **heure murale locale** /// (« 3pm », « 15:00 ») ⇒ aujourd'hui à cette heure, demain si déjà passée. WallClock, } impl ResetTimeFormat { /// Déduit la stratégie depuis le `time_format` déclaratif (ou `Auto` si absent). fn from_opt(time_format: Option<&str>) -> Self { let Some(raw) = time_format else { return Self::Auto; }; match raw.trim().to_ascii_lowercase().as_str() { "epoch_s" | "epoch_seconds" | "unix_s" => Self::EpochSeconds, "epoch_ms" | "epoch_millis" | "unix_ms" => Self::EpochMillis, "iso8601" | "rfc3339" | "iso" => Self::Iso8601, "relative_s" | "relative_seconds" | "duration_s" | "retry_after_s" => { Self::RelativeSeconds } "relative_ms" | "relative_millis" => Self::RelativeMillis, "wall" | "wall_clock" | "local" | "hh:mm" => Self::WallClock, _ => Self::Auto, } } /// Convertit la chaîne capturée en époche-ms selon la stratégie, en référence à /// `now_ms` pour les heures relatives/murales. `None` si la capture est /// inexploitable (⇒ détection sans heure ⇒ filet humain en aval). fn resolve(self, captured: &str, now_ms: i64) -> Option { let c = captured.trim(); match self { Self::Auto => timeparse::parse_absolute_ms(c), Self::EpochSeconds => c.parse::().ok().map(|s| s.saturating_mul(1000)), Self::EpochMillis => c.parse::().ok(), Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c), Self::RelativeSeconds => c.parse::().ok().map(|d| now_ms + (d * 1000.0) as i64), Self::RelativeMillis => c.parse::().ok().map(|d| now_ms + d), Self::WallClock => { let (h, m, s) = timeparse::parse_wall_clock(c)?; Some(timeparse::wall_clock_to_ms(now_ms, h, m, s)) } } } } /// Détecteur niveau 2 : un **motif regex compilé une fois** + sa stratégie d'heure. /// /// À instancier **par agent** (au câblage PTY, LS7) à partir de son /// `rate_limit_pattern`, puis à nourrir avec chaque fragment de sortie via /// [`detect`](Self::detect). La compilation (coûteuse) est faite **une seule fois** /// à la construction, pas à chaque fragment. pub struct RateLimitParser { /// Motif compilé recherché dans la sortie. regex: Regex, /// Nom (ou index décimal) du groupe de capture d'où extraire l'heure de reset. /// `None` ⇒ détection **sans** extraction d'heure (⇒ `resets_at_ms: None`). reset_capture: Option, /// Stratégie d'interprétation de la capture, pré-calculée. time_format: ResetTimeFormat, } impl RateLimitParser { /// Compile le motif du profil. `None` si la regex est **invalide** (profil mal /// configuré) — robustesse : jamais de panique, l'agent tourne juste sans /// détection niveau 2. #[must_use] pub fn new(pattern: &RateLimitPattern) -> Option { let regex = Regex::new(&pattern.pattern).ok()?; Some(Self { regex, reset_capture: pattern.reset_capture.clone(), time_format: ResetTimeFormat::from_opt(pattern.time_format.as_deref()), }) } /// Analyse un fragment de sortie texte. /// /// - **pas de match** ⇒ `None` (pas de limite) ; /// - **match** ⇒ `Some(SessionLimit)` avec `source = Pattern`, /// `detected_at_ms = now_ms`, et `resets_at_ms` extrait du groupe de capture /// `reset_capture` interprété selon `time_format` — `None` si pas de capture, /// groupe absent, ou heure inexploitable (détection **utile sans heure** ⇒ filet /// humain en aval, §21). #[must_use] pub fn detect(&self, text: &str, now_ms: i64) -> Option { let caps = self.regex.captures(text)?; let resets_at_ms = self.reset_capture.as_deref().and_then(|name| { // Groupe nommé en priorité ; repli sur un index décimal si `name` en est un. let raw = caps .name(name) .or_else(|| name.parse::().ok().and_then(|i| caps.get(i)))?; self.time_format.resolve(raw.as_str(), now_ms) }); Some(SessionLimit::new( resets_at_ms, now_ms, RateLimitSource::Pattern, )) } } #[cfg(test)] mod tests { use super::*; use domain::ids::ProfileId; use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; /// Instant de référence (epoch-ms) : 2023-11-14T22:13:20Z = 1_700_000_000 s. const NOW: i64 = 1_700_000_000_000; /// Début de journée UTC du 2023-11-14 (= 19675 × 86_400_000), pour les heures murales. const DAY_START: i64 = 1_699_920_000_000; fn pattern( pat: &str, reset_capture: Option<&str>, time_format: Option<&str>, ) -> RateLimitPattern { RateLimitPattern::new( pat.to_owned(), reset_capture.map(str::to_owned), time_format.map(str::to_owned), ) .expect("motif valide (non vide)") } // -- new : robustesse regex ---------------------------------------------- #[test] fn new_returns_none_on_invalid_regex() { // Parenthèse ouvrante non fermée ⇒ regex invalide ⇒ None (jamais de panique). let p = pattern("rate limit (", None, None); assert!(RateLimitParser::new(&p).is_none()); } // -- detect : pas de match ----------------------------------------------- #[test] fn detect_returns_none_when_pattern_does_not_match() { let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap(); assert!(parser.detect("tout va bien", NOW).is_none()); } // -- detect : match sans reset_capture ----------------------------------- #[test] fn detect_match_without_reset_capture_has_no_time() { let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap(); let limit = parser .detect("oops: rate limit reached!", NOW) .expect("détecté"); assert_eq!(limit.resets_at_ms, None); assert_eq!(limit.detected_at_ms, NOW); assert_eq!(limit.source, RateLimitSource::Pattern); } // -- detect : formats ABSOLUS -------------------------------------------- #[test] fn detect_epoch_seconds_format() { let parser = RateLimitParser::new(&pattern( r"resets at (?P\d+)", Some("reset"), Some("epoch_s"), )) .unwrap(); let limit = parser .detect("limit, resets at 1700000000 ok", NOW) .expect("détecté"); assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); assert_eq!(limit.source, RateLimitSource::Pattern); } #[test] fn detect_epoch_millis_format() { let parser = RateLimitParser::new(&pattern( r"resets at (?P\d+)", Some("reset"), Some("epoch_ms"), )) .unwrap(); let limit = parser .detect("resets at 1700000000000", NOW) .expect("détecté"); assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); } #[test] fn detect_iso8601_format() { let parser = RateLimitParser::new(&pattern( r"resets at (?P\S+)", Some("reset"), Some("iso8601"), )) .unwrap(); let limit = parser .detect("resets at 2023-11-14T22:13:20Z", NOW) .expect("détecté"); assert_eq!(limit.resets_at_ms, Some(1_700_000_000_000)); } // -- detect : format RELATIF --------------------------------------------- #[test] fn detect_relative_seconds_format_uses_now() { let parser = RateLimitParser::new(&pattern( r"retry after (?P\d+)s", Some("reset"), Some("relative_s"), )) .unwrap(); let limit = parser.detect("retry after 600s", NOW).expect("détecté"); assert_eq!(limit.resets_at_ms, Some(NOW + 600_000)); assert_eq!(limit.source, RateLimitSource::Pattern); } // -- detect : format MURAL (passage de minuit) --------------------------- /// « 3pm » alors qu'il est 10 h ⇒ 15 h AUJOURD'HUI (même jour UTC). #[test] fn detect_wall_clock_same_day_when_future() { let parser = RateLimitParser::new(&pattern( r"resets at (?P.+)", Some("reset"), Some("wall"), )) .unwrap(); let now_10h = DAY_START + 10 * 3_600_000; // 10:00 UTC ce jour-là let limit = parser.detect("resets at 3pm", now_10h).expect("détecté"); // 15:00 le même jour. assert_eq!(limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000)); } /// « 3pm » alors qu'il est 16 h ⇒ 15 h DEMAIN (déjà passé ⇒ +24 h). #[test] fn detect_wall_clock_next_day_when_past() { let parser = RateLimitParser::new(&pattern( r"resets at (?P.+)", Some("reset"), Some("wall"), )) .unwrap(); let now_16h = DAY_START + 16 * 3_600_000; // 16:00 UTC let limit = parser.detect("resets at 3pm", now_16h).expect("détecté"); // 15:00 le LENDEMAIN. assert_eq!( limit.resets_at_ms, Some(DAY_START + 15 * 3_600_000 + 86_400_000) ); } // -- detect : match mais capture inexploitable ⇒ détection sans heure ----- #[test] fn detect_match_with_missing_capture_group_has_no_time() { // reset_capture nommé "reset" mais le motif ne définit aucun groupe "reset". let parser = RateLimitParser::new(&pattern("rate limited", Some("reset"), Some("epoch_s"))).unwrap(); let limit = parser.detect("rate limited", NOW).expect("détecté"); assert_eq!(limit.resets_at_ms, None); assert_eq!(limit.source, RateLimitSource::Pattern); } #[test] fn detect_match_with_unparsable_value_has_no_time() { // Groupe présent mais valeur non numérique pour un format epoch_s ⇒ None. let parser = RateLimitParser::new(&pattern( r"reset (?P\w+)", Some("reset"), Some("epoch_s"), )) .unwrap(); let limit = parser.detect("reset abc", NOW).expect("détecté"); assert_eq!(limit.resets_at_ms, None); } // -- detect : appelable plusieurs fois (regex compilé une seule fois) ----- #[test] fn detect_can_be_called_multiple_times() { let parser = RateLimitParser::new(&pattern( r"resets at (?P\d+)", Some("reset"), Some("epoch_s"), )) .unwrap(); let a = parser .detect("resets at 1700000000", NOW) .expect("1er détecté"); assert!(parser.detect("rien ici", NOW).is_none()); let b = parser .detect("resets at 1700000000", NOW) .expect("2e détecté"); assert_eq!(a.resets_at_ms, b.resets_at_ms); assert_eq!(a.resets_at_ms, Some(1_700_000_000_000)); } // -- applies(profile) : règle anti-double-détection (§21.10-4) ------------ fn base_profile() -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::nil()), "Dev", "claude", Vec::new(), ContextInjection::convention_file("CLAUDE.md").expect("valide"), None, "{agentRunDir}", None, ) .expect("profil valide") } #[test] fn applies_false_for_structured_profile_even_with_pattern() { let profile = base_profile() .with_structured_adapter(StructuredAdapter::Claude) .with_rate_limit_pattern(pattern("rate limit", None, None)); assert!( !applies(&profile), "un agent structuré détecte par le niveau 1" ); } #[test] fn applies_true_for_pty_profile_with_pattern() { let profile = base_profile().with_rate_limit_pattern(pattern("rate limit", None, None)); assert!(applies(&profile)); } #[test] fn applies_false_for_pty_profile_without_pattern() { assert!(!applies(&base_profile())); } }