feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé
Ajoute un détecteur niveau 2 (RateLimitParser, regex confiné à l'infra) qui repère les mentions de limite de session dans la sortie textuelle, et factorise le parsing d'heures dans un module pur (timeparse) partagé entre les niveaux 1 et 2. Le détecteur Claude niveau 1 est refactoré vers timeparse (~-121 lignes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
384
crates/infrastructure/src/ratelimit/mod.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! 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<i64> {
|
||||
let c = captured.trim();
|
||||
match self {
|
||||
Self::Auto => timeparse::parse_absolute_ms(c),
|
||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||
Self::RelativeSeconds => c
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeMillis => c.parse::<i64>().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<String>,
|
||||
/// 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<Self> {
|
||||
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<SessionLimit> {
|
||||
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::<usize>().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<reset>\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<reset>\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<reset>\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<reset>\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<reset>.+)",
|
||||
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<reset>.+)",
|
||||
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<reset>\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<reset>\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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user