feat(session-limits): LS4 — service application + réconciliation T4
Orchestre la détection et la reprise au niveau application : - session_limit.rs (nouveau) : SessionLimitService + port AgentResumer + const RESUME_PROMPT. - structured.rs : réconciliation T4 — enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées. - agent/mod.rs + lib.rs : modules et re-exports. Tests QA (nouveaux) : tests/session_limit_service.rs (9) + tests/session_limit_t4.rs (7). `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux), zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ; builds domaine+infra+application 0 warning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,6 +19,31 @@ use domain::ids::AgentId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
|
||||
/// session avec le contrat « seul `Final` est terminal »).
|
||||
///
|
||||
/// Un tour se termine de deux façons **gracieuses** :
|
||||
/// - [`TurnOutcome::Completed`] : le flux a rendu son [`ReplyEvent::Final`] — fin
|
||||
/// déterministe normale (cas historique, contenu agrégé) ;
|
||||
/// - [`TurnOutcome::RateLimited`] : le flux s'est **clos sans `Final`** *parce que*
|
||||
/// l'agent est entré en **limite de session** (un [`ReplyEvent::RateLimited`] a été
|
||||
/// observé dans le tour). Ce n'est **pas** une erreur (§21.2-T4) : l'agent reste
|
||||
/// vivant, le service de limite (lot LS4) arme la reprise.
|
||||
///
|
||||
/// Un flux clos **sans `Final` ET sans `RateLimited`** reste une **erreur**
|
||||
/// [`AgentSessionError::Io`] (tour réellement interrompu) — comportement inchangé.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TurnOutcome {
|
||||
/// Tour terminé normalement par un `Final` ; porte le contenu agrégé.
|
||||
Completed(String),
|
||||
/// Tour clos sur une **limite de session** (sans `Final`) ; porte l'heure de
|
||||
/// reset éventuelle (époche-ms) telle que vue dans le dernier `RateLimited`.
|
||||
RateLimited {
|
||||
/// Instant de reset en époche-ms (`None` ⇒ heure inconnue ⇒ filet humain).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
@ -36,14 +61,23 @@ use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
|
||||
/// (échec de communication / décodage de la sortie structurée) ;
|
||||
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
|
||||
/// interrompu) ;
|
||||
/// interrompu) — y compris un tour clos sur une **limite de session** (le
|
||||
/// rendez-vous synchrone n'a pas de contenu à rendre ; cf. [`TurnOutcome`] et la
|
||||
/// variante riche [`drain_with_readiness_outcome`] pour exploiter la limite) ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||
pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
|
||||
match drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await? {
|
||||
TurnOutcome::Completed(content) => Ok(content),
|
||||
// Le rendez-vous synchrone (ask) attend un contenu : un tour limité n'en a pas
|
||||
// ⇒ on conserve le comportement historique (erreur), sans casser le contrat.
|
||||
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
|
||||
@ -61,7 +95,9 @@ pub async fn send_blocking(
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// timeout).
|
||||
/// timeout). Un tour clos sur une **limite de session** ⇒ [`AgentSessionError::Io`]
|
||||
/// **ici** (signature historique `Result<String>`, zéro régression pour l'appelant
|
||||
/// orchestrateur) ; utilise [`drain_with_readiness_outcome`] pour exploiter la limite.
|
||||
pub async fn drain_with_readiness(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
@ -69,6 +105,38 @@ pub async fn drain_with_readiness(
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? {
|
||||
TurnOutcome::Completed(content) => Ok(content),
|
||||
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Variante **riche** de [`drain_with_readiness`] : même branchement readiness, mais
|
||||
/// retourne le [`TurnOutcome`] complet au lieu de réduire la limite à une erreur.
|
||||
///
|
||||
/// C'est le point d'entrée du chemin **conscient de la limite** (réconciliation
|
||||
/// §21.2-T4) : sur un tour clos sans `Final` mais ayant vu un
|
||||
/// [`ReplyEvent::RateLimited`], il renvoie `Ok(`[`TurnOutcome::RateLimited`]`)` (fin
|
||||
/// gracieuse) plutôt qu'une `Io`. Le drain applicatif / le `SessionLimitService`
|
||||
/// (LS4) consomment cette issue pour armer la reprise. Le câblage de ce chemin sur
|
||||
/// le tour délégué de l'orchestrateur est du ressort de LS7.
|
||||
///
|
||||
/// **Readiness inchangée** : `mark_idle` reste piloté **uniquement** par le `Final`
|
||||
/// (`TurnEnded`) — un `RateLimited` ne marque **pas** l'agent `Idle` (§21.5 : « le
|
||||
/// `mark_idle`/FIFO reste piloté par `Final`/timeout »).
|
||||
///
|
||||
/// # Errors
|
||||
/// Comme [`drain_with_readiness`], **sauf** qu'un tour limité n'est plus une erreur
|
||||
/// (il devient [`TurnOutcome::RateLimited`]).
|
||||
pub async fn drain_with_readiness_outcome(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
|
||||
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
|
||||
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
|
||||
@ -84,6 +152,8 @@ pub async fn drain_with_readiness(
|
||||
}
|
||||
},
|
||||
|signal| {
|
||||
// Seul le `Final` marque `Idle` : un `RateLimited` ne fait PAS avancer la
|
||||
// FIFO (la reprise est gérée par le service de limite, §21.5).
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
@ -106,7 +176,7 @@ async fn drain_bounded_events(
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
@ -126,18 +196,27 @@ async fn drain_bounded_events(
|
||||
///
|
||||
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux s'épuise
|
||||
/// avant, l'issue dépend de ce qu'on a vu (réconciliation §21.2-T4) :
|
||||
/// - un [`ReplyEvent::RateLimited`] a été observé ⇒ fin **gracieuse**
|
||||
/// [`TurnOutcome::RateLimited`] (l'agent est limité, pas en erreur) ;
|
||||
/// - sinon ⇒ tour réellement interrompu → erreur [`AgentSessionError::Io`]
|
||||
/// (comportement **inchangé**).
|
||||
///
|
||||
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
|
||||
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`] ; un
|
||||
/// `RateLimited` ⇒ [`ReadinessSignal::RateLimited`]). Deltas, activités et heartbeats
|
||||
/// sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
) -> Result<TurnOutcome, AgentSessionError> {
|
||||
// Mémorise la dernière limite vue (§21.2-T4) : `Some(resets_at_ms)` dès qu'un
|
||||
// `RateLimited` traverse le flux. Sert UNIQUEMENT au cas « clos sans Final » —
|
||||
// un `Final` ultérieur l'emporte toujours (le tour a réellement abouti).
|
||||
let mut last_rate_limit: Option<Option<i64>> = None;
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
@ -146,14 +225,23 @@ async fn drain_to_final(
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
match event {
|
||||
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => {}
|
||||
}
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
// Flux clos sans `Final` : fin gracieuse SI une limite a été vue (§21.2-T4),
|
||||
// sinon erreur comme avant.
|
||||
match last_rate_limit {
|
||||
Some(resets_at_ms) => Ok(TurnOutcome::RateLimited { resets_at_ms }),
|
||||
None => Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user