//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier //! readiness/heartbeat, lot 1). //! //! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal //! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que //! l'application puisse décider de marquer un agent `Idle` //! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe** //! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply` //! explicite ou d'un sniff littéral de prompt PTY. //! //! # Hiérarchie des signaux de fin-de-tour (rappel cadrage) //! //! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par //! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`). //! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`]. //! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP //! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1. //! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de //! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]). //! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents //! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé). //! //! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la //! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) : //! elles existent dans le vocabulaire mais ne sont **pas** produites par //! [`ReadinessPolicy::classify`] dans ce lot. use crate::ports::ReplyEvent; /// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`] /// déduit d'un événement observable du tour. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReadinessSignal { /// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`. /// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et /// marquer l'agent `Idle`. TurnEnded, /// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit /// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il /// est porté par le chemin de délégation, présent ici pour compléter le /// vocabulaire et le rendre explicite. ExplicitReply, /// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par /// `classify`, présent pour nommer le signal de repli legacy. PromptReady, /// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place /// réservée au **lot 2** — non produit dans ce lot. Stalled, /// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non /// produit dans ce lot. TimedOut, /// L'agent est en **limite de session/débit** (ARCHITECTURE §21) : le moteur l'a /// suspendu pour cause de quota. Déduit de [`ReplyEvent::RateLimited`] par /// [`ReadinessPolicy::classify`]. **Pas terminal** au sens « fin de tour » : il ne /// marque pas le tour comme `TurnEnded` (l'agent n'a pas rendu son `Final`) — il /// signale un 3ᵉ axe d'état orthogonal (« limité jusqu'à T »), que l'application /// traduit en planification de reprise. /// /// `Copy` préservé : `Option` est `Copy`, donc l'enum le reste (cf. §21.3). RateLimited { /// Instant de reset en **époche-millisecondes** (cf. /// [`ReplyEvent::RateLimited::resets_at_ms`]). `None` ⇒ pas d'heure connue /// (filet humain, pas de reprise auto). resets_at_ms: Option, }, } /// Politique **pure** de classification d'un événement de tour en /// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de /// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? » /// vive dans le **domaine** et reste testable sans process ni réseau. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ReadinessPolicy; impl ReadinessPolicy { /// Classe un [`ReplyEvent`] en signal de readiness. /// /// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul /// événement terminal, il signe la fin de tour déterministe. /// - [`ReplyEvent::RateLimited`] ⇒ /// `Some(`[`ReadinessSignal::RateLimited`]`{..})` : **non terminal** (l'agent /// n'a pas rendu son `Final`), mais porteur d'un signal exploitable par /// l'application (planifier la reprise à `resets_at_ms`). L'heure de reset est /// propagée telle quelle. /// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] / /// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux /// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour. #[must_use] pub const fn classify(event: &ReplyEvent) -> Option { match event { ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded), ReplyEvent::RateLimited { resets_at_ms } => { Some(ReadinessSignal::RateLimited { resets_at_ms: *resets_at_ms, }) } ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. } | ReplyEvent::Heartbeat => None, } } } #[cfg(test)] mod tests { use super::*; #[test] fn final_classifies_as_turn_ended() { let ev = ReplyEvent::Final { content: "fini".to_owned(), }; assert_eq!( ReadinessPolicy::classify(&ev), Some(ReadinessSignal::TurnEnded) ); } #[test] fn deltas_activities_and_heartbeats_are_non_terminal() { assert_eq!( ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }), None ); assert_eq!( ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }), None ); assert_eq!( ReadinessPolicy::classify(&ReplyEvent::Heartbeat), None, "un heartbeat prouve la vivacité mais ne termine JAMAIS le tour" ); } // -- §21 : RateLimited ⇒ ReadinessSignal::RateLimited (heure propagée) ------- #[test] fn rate_limited_with_known_reset_classifies_and_propagates_time() { let ev = ReplyEvent::RateLimited { resets_at_ms: Some(1_700_000_000_000), }; assert_eq!( ReadinessPolicy::classify(&ev), Some(ReadinessSignal::RateLimited { resets_at_ms: Some(1_700_000_000_000), }), "l'heure de reset doit être propagée telle quelle" ); } #[test] fn rate_limited_without_reset_classifies_with_none() { let ev = ReplyEvent::RateLimited { resets_at_ms: None }; assert_eq!( ReadinessPolicy::classify(&ev), Some(ReadinessSignal::RateLimited { resets_at_ms: None }), "heure inconnue ⇒ None propagé (filet humain en aval)" ); } #[test] fn rate_limited_is_not_classified_as_turn_ended() { // Non terminal : un RateLimited ne marque JAMAIS le tour comme fini. let signal = ReadinessPolicy::classify(&ReplyEvent::RateLimited { resets_at_ms: Some(42), }); assert_ne!(signal, Some(ReadinessSignal::TurnEnded)); } #[test] fn readiness_signal_is_copy() { // Test de compilation : `ReadinessSignal` reste `Copy` (cf. §21.3). Si le // type cessait d'être `Copy`, l'usage après le `copy` ci-dessous (move // implicite) ne compilerait plus. let sig = ReadinessSignal::RateLimited { resets_at_ms: Some(7), }; let copy = sig; // copie implicite, pas un move assert_eq!(sig, copy); } }