merge(live-state): intègre LS5 (borne summary_md handoff + seam LLM)
Incrément cohérent et vert qui clôt le must-have perf du handoff : borne summary_md + élision de ligne de tour, seam LLM prêt mais non activé (ADR). Tests verts domain/infrastructure/application. LS6 (rétention/UI) et LS7 (UX) continuent sur la feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -23,11 +23,12 @@ use domain::ports::{
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
|
||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
|
||||
PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
|
||||
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use domain::live_state::WorkStatus;
|
||||
@ -1318,7 +1319,15 @@ impl LaunchAgent {
|
||||
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
|
||||
let store = provider.handoff_store_for(root)?;
|
||||
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
|
||||
store.load(conversation).await.ok().flatten()
|
||||
// Borne défensive LS5 : un `handoff.md` legacy non borné (écrit avant LS5) est
|
||||
// ramené sous `HANDOFF_SUMMARY_MAX_CHARS` **avant** d'entrer dans le contexte
|
||||
// composé — sans migration de données, idempotent et best-effort.
|
||||
store
|
||||
.load(conversation)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| bound_handoff_summary(h, HANDOFF_SUMMARY_MAX_CHARS))
|
||||
}
|
||||
|
||||
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
|
||||
@ -3738,6 +3747,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_with_normal_handoff_is_byte_identical_pre_and_post_ls5_bound() {
|
||||
// ZÉRO RÉGRESSION (LS5) : pour un handoff sous le plafond, le seul changement
|
||||
// LS5 du chemin d'injection — `bound_handoff_summary` dans `resolve_handoff` —
|
||||
// est l'identité (cf. domaine `bound_under_limit_is_unchanged`). Le document
|
||||
// composé est donc OCTET-IDENTIQUE avant/après LS5. Assertion stricte.
|
||||
let memory = [mem(
|
||||
"git-optional",
|
||||
"Git optionnel",
|
||||
"git reste un simple tool",
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let normal = handoff(
|
||||
"**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b",
|
||||
Some("livrer LS5"),
|
||||
);
|
||||
assert!(
|
||||
normal.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"pré-condition : handoff sous le plafond"
|
||||
);
|
||||
|
||||
// « Avant LS5 » : le composeur reçoit le handoff tel quel (aucune borne).
|
||||
let pre = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&normal),
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
// « Après LS5 » : le handoff passe d'abord par la borne universelle.
|
||||
let bounded = bound_handoff_summary(normal.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||
let post = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&bounded),
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
pre, post,
|
||||
"handoff normal ⇒ document composé octet-identique"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// État du projet (lot LS4) — live-state preview section in the composer.
|
||||
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
|
||||
use domain::{
|
||||
bound_handoff_summary, ConversationId, ConversationLog, ConversationTurn, HandoffStore,
|
||||
HandoffSummarizer, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -63,6 +66,9 @@ impl RecordTurn {
|
||||
/// 1. **append** the turn to the canonical log (source of truth) ;
|
||||
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
|
||||
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
|
||||
/// 3b. **bound** the folded handoff's summary to [`HANDOFF_SUMMARY_MAX_CHARS`] — a
|
||||
/// **universal cap independent of the summarizer** (heuristic *or* a future LLM):
|
||||
/// idempotent, never rejecting, so it never blocks the checkpoint (LS5) ;
|
||||
/// 4. **save** the advanced handoff.
|
||||
///
|
||||
/// The append happens **first** so the durable source of truth is never behind
|
||||
@ -92,6 +98,13 @@ impl RecordTurn {
|
||||
.fold(prev, std::slice::from_ref(&turn))
|
||||
.await;
|
||||
|
||||
// 3b. **Universal bound** (LS5): clamp the resume summary to
|
||||
// `HANDOFF_SUMMARY_MAX_CHARS`, *independently of the summarizer*. Whatever
|
||||
// produced the handoff — the heuristic or a future LLM summarizer — the
|
||||
// persisted (and later injected) summary is size-bounded. Idempotent and
|
||||
// never rejecting, so it can never block the checkpoint.
|
||||
let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
|
||||
|
||||
// 4. Persist the advanced handoff (overwrites the previous resume point).
|
||||
self.handoffs.save(conversation, handoff).await?;
|
||||
|
||||
|
||||
@ -2448,6 +2448,88 @@ async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||
);
|
||||
}
|
||||
|
||||
/// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé
|
||||
/// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté
|
||||
/// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On
|
||||
/// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir.
|
||||
#[tokio::test]
|
||||
async fn launch_bounds_oversize_legacy_handoff_resume_section() {
|
||||
// Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section
|
||||
// injectée = le seul summary, comparable directement au plafond).
|
||||
let mut summary = String::new();
|
||||
for i in 0..500u32 {
|
||||
if i > 0 {
|
||||
summary.push('\n');
|
||||
}
|
||||
summary.push_str(&format!(
|
||||
"- **Response:** tour numero {i} {}",
|
||||
"x".repeat(30)
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"pré-condition : le handoff legacy dépasse le plafond ({})",
|
||||
summary.chars().count()
|
||||
);
|
||||
|
||||
let conv = conv_id(123);
|
||||
let store = FakeHandoffStore::with(conv, handoff_for(&summary, None));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
let (launch, agent, fs) = launch_with_handoff(Some(provider));
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(Uuid::from_u128(123).to_string());
|
||||
launch.execute(input).await.expect("launch succeeds");
|
||||
|
||||
let doc = convention_doc(&fs);
|
||||
// La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu
|
||||
// (après l'entête) = le summary injecté, qui doit être borné.
|
||||
let section = doc
|
||||
.split("# Reprise de la conversation\n\n")
|
||||
.nth(1)
|
||||
.expect("resume section present")
|
||||
.trim_end();
|
||||
assert!(
|
||||
section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary legacy injecté borné, obtenu {}",
|
||||
section.chars().count()
|
||||
);
|
||||
// Distillation : les lignes les plus récentes survivent, les plus anciennes droppées.
|
||||
assert!(section.contains("tour numero 499"), "le plus récent reste");
|
||||
assert!(
|
||||
!section.contains("tour numero 0 "),
|
||||
"le plus ancien droppé : {section}"
|
||||
);
|
||||
// Reparsable : chaque ligne conservée garde le préfixe de tour.
|
||||
for line in section.lines().filter(|l| !l.is_empty()) {
|
||||
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté
|
||||
/// **verbatim** (la borne est l'identité), objectif et summary intacts.
|
||||
#[tokio::test]
|
||||
async fn launch_normal_handoff_injects_summary_verbatim() {
|
||||
let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b";
|
||||
assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS);
|
||||
let conv = conv_id(123);
|
||||
let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5")));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
let (launch, agent, fs) = launch_with_handoff(Some(provider));
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(Uuid::from_u128(123).to_string());
|
||||
launch.execute(input).await.expect("launch succeeds");
|
||||
|
||||
let doc = convention_doc(&fs);
|
||||
// Le summary normal est injecté tel quel (la borne ne l'a pas touché).
|
||||
assert!(
|
||||
doc.contains(summary),
|
||||
"handoff normal injecté verbatim (borne = identité) : {doc}"
|
||||
);
|
||||
assert!(doc.contains("**Objectif :** livrer LS5"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
|
||||
//
|
||||
|
||||
@ -20,7 +20,7 @@ use async_trait::async_trait;
|
||||
use domain::ports::StoreError;
|
||||
use domain::{
|
||||
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
InputSource, TurnId, TurnRole,
|
||||
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use application::{AppError, RecordTurn};
|
||||
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
|
||||
assert_eq!(hb.up_to, tb.id);
|
||||
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LS5 — la borne universelle s'applique côté `record`, indépendamment du résumeur.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Un résumeur **adversarial** qui renvoie toujours un `summary_md` largement
|
||||
/// surdimensionné (au-delà du plafond), au format reparsable, pour prouver que la
|
||||
/// garantie de borne vient de `record` (via `bound_handoff_summary`), PAS du résumeur.
|
||||
#[derive(Default)]
|
||||
struct OversizeSummarizer;
|
||||
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for OversizeSummarizer {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
// Beaucoup de lignes de tours bien formées ⇒ summary_md >> plafond.
|
||||
let mut s = String::from("**Objectif :** objectif preserve");
|
||||
for i in 0..1_000 {
|
||||
s.push_str(&format!(
|
||||
"\n- **Response:** ligne surdimensionnee numero {i}"
|
||||
));
|
||||
}
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("fold receives at least one turn");
|
||||
Handoff::new(s, up_to, Some("objectif preserve".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Avec un résumeur qui produit un summary géant, le handoff **persisté** est borné à
|
||||
/// `HANDOFF_SUMMARY_MAX_CHARS` (garantie universelle), tout en gardant l'ordre `append`-
|
||||
/// d'abord, le fold incrémental (un appel par tour), objectif et curseur préservés.
|
||||
#[tokio::test]
|
||||
async fn record_bounds_persisted_handoff_even_with_oversize_summarizer() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(OversizeSummarizer);
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "premier tour");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record ok");
|
||||
|
||||
// append-d'abord : le log canonique porte le tour.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
|
||||
// Le handoff persisté est BORNÉ malgré le résumeur surdimensionné.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary persisté borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
// Objectif + curseur jamais modifiés par la borne.
|
||||
assert_eq!(h.objective.as_deref(), Some("objectif preserve"));
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
// Reparsable : le préambule d'objectif survit, les lignes de tours sont préfixées.
|
||||
assert!(h.summary_md.contains("**Objectif :** objectif preserve"));
|
||||
for line in h.summary_md.lines().filter(|l| l.starts_with("- **")) {
|
||||
assert!(line.starts_with("- **"));
|
||||
}
|
||||
}
|
||||
|
||||
/// La borne au `record` reste incrémentale sur plusieurs tours : un `fold` par tour
|
||||
/// (jamais de relecture complète), et chaque checkpoint persiste un summary borné.
|
||||
#[tokio::test]
|
||||
async fn record_stays_incremental_and_bounded_across_turns() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Compte les appels `fold` pour prouver l'incrémentalité (un par tour).
|
||||
#[derive(Default)]
|
||||
struct CountingOversize {
|
||||
calls: Mutex<usize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for CountingOversize {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
// L'incrément ne porte qu'un seul tour à chaque checkpoint.
|
||||
assert_eq!(new_turns.len(), 1, "fold incrémental : un tour à la fois");
|
||||
let big = "g".repeat(HANDOFF_SUMMARY_MAX_CHARS * 3);
|
||||
let up_to = new_turns[0].id;
|
||||
Handoff::new(format!("- **Response:** {big}"), up_to, None)
|
||||
}
|
||||
}
|
||||
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(CountingOversize::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
for n in 1..=5u128 {
|
||||
uc.record(conv, turn(conv, turn_id(n), &format!("tour {n}")))
|
||||
.await
|
||||
.expect("record ok");
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"checkpoint {n} borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
}
|
||||
assert_eq!(*summarizer.calls.lock().unwrap(), 5, "un fold par tour");
|
||||
}
|
||||
|
||||
@ -206,6 +206,141 @@ impl Handoff {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borne dure (en **caractères**) du `summary_md` d'un [`Handoff`] (lot LS5).
|
||||
///
|
||||
/// Plafond universel **indépendant du résumeur** : qu'il soit produit par
|
||||
/// l'heuristique zéro-dépendance ou par un futur résumeur LLM, le résumé injecté dans
|
||||
/// le contexte d'un agent (et persisté) ne dépasse jamais cette taille. `4096`
|
||||
/// caractères tiennent largement un objectif + une fenêtre de tours déjà bornés
|
||||
/// individuellement, tout en gardant le contexte de reprise petit (chemin chaud).
|
||||
pub const HANDOFF_SUMMARY_MAX_CHARS: usize = 4096;
|
||||
|
||||
/// Préfixe d'une **ligne de tour** dans un `summary_md` rendu (cf. le résumeur
|
||||
/// heuristique d'infra) : une ligne `- **<role>:** <texte>`. Sert ici à séparer le
|
||||
/// **préambule d'objectif** des **lignes de tours** pour la troncature distillée.
|
||||
const TURN_LINE_PREFIX: &str = "- **";
|
||||
|
||||
/// Borne le `summary_md` d'un [`Handoff`] à `max_chars` caractères, **best-effort et
|
||||
/// idempotent** (lot LS5).
|
||||
///
|
||||
/// - Sous la borne (`summary_md.chars().count() <= max_chars`) ⇒ handoff **inchangé**
|
||||
/// (coût nul, cas chaud nominal).
|
||||
/// - Au-dessus ⇒ **troncature distillée** : on garde le **préambule d'objectif** (le
|
||||
/// 1er bloc, avant la première ligne de tour) PUIS le **maximum de lignes de tours
|
||||
/// les plus récentes** (fin de fenêtre) tenant sous `max_chars`, en **droppant les
|
||||
/// plus anciennes d'abord**. Si la ligne conservée la plus récente dépasse à elle
|
||||
/// seule le budget, sa **fin** est tronquée en gardant le préfixe `- **role:**`
|
||||
/// intact (le résultat reste **toujours reparsable** comme un `summary_md`).
|
||||
///
|
||||
/// [`Handoff::up_to`] et [`Handoff::objective`] ne sont **jamais** modifiés. La fonction
|
||||
/// ne **re-fold jamais**, ne **rejette jamais** (elle ne doit pas pouvoir bloquer un
|
||||
/// append/checkpoint) et garantit `bound(bound(x)) == bound(x)`.
|
||||
#[must_use]
|
||||
pub fn bound_handoff_summary(handoff: Handoff, max_chars: usize) -> Handoff {
|
||||
if handoff.summary_md.chars().count() <= max_chars {
|
||||
return handoff;
|
||||
}
|
||||
let summary_md = distill_summary(&handoff.summary_md, max_chars);
|
||||
Handoff {
|
||||
summary_md,
|
||||
up_to: handoff.up_to,
|
||||
objective: handoff.objective,
|
||||
}
|
||||
}
|
||||
|
||||
/// Troncature distillée d'un `summary_md` au-dessus de la borne (cf.
|
||||
/// [`bound_handoff_summary`]). Sépare le préambule d'objectif des lignes de tours, garde
|
||||
/// le préambule puis le suffixe le plus récent de tours tenant sous `max_chars`.
|
||||
fn distill_summary(summary: &str, max_chars: usize) -> String {
|
||||
// Préambule d'objectif = lignes de tête jusqu'à la 1re ligne de tour ; lignes de
|
||||
// tours = celles préfixées `- **` (les seules reparsées par le résumeur). Toute
|
||||
// ligne non-tour APRÈS la 1re ligne de tour est du bruit inter-tours, droppée.
|
||||
let mut objective_block: Vec<&str> = Vec::new();
|
||||
let mut turn_lines: Vec<&str> = Vec::new();
|
||||
let mut seen_turn = false;
|
||||
for line in summary.lines() {
|
||||
if line.starts_with(TURN_LINE_PREFIX) {
|
||||
seen_turn = true;
|
||||
turn_lines.push(line);
|
||||
} else if !seen_turn {
|
||||
objective_block.push(line);
|
||||
}
|
||||
}
|
||||
while objective_block.last().is_some_and(|l| l.trim().is_empty()) {
|
||||
objective_block.pop();
|
||||
}
|
||||
let objective_md = objective_block.join("\n");
|
||||
let objective_len = objective_md.chars().count();
|
||||
// Séparateur `\n\n` entre l'objectif et la fenêtre, seulement si les deux existent.
|
||||
let separator_len = if objective_md.is_empty() { 0 } else { 2 };
|
||||
let budget_for_turns = max_chars.saturating_sub(objective_len + separator_len);
|
||||
|
||||
// Garder les lignes de tours les plus RÉCENTES (fin) tenant sous le budget ; dropper
|
||||
// les plus anciennes d'abord. On parcourt depuis la fin et on s'arrête au 1er dépassement.
|
||||
let mut kept_rev: Vec<String> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
for line in turn_lines.iter().rev() {
|
||||
let join_cost = usize::from(!kept_rev.is_empty()); // le `\n` de jointure
|
||||
let line_len = line.chars().count();
|
||||
if used + join_cost + line_len <= budget_for_turns {
|
||||
used += join_cost + line_len;
|
||||
kept_rev.push((*line).to_owned());
|
||||
} else {
|
||||
// La ligne la plus récente ne tient pas même seule ⇒ hard-truncate sa fin
|
||||
// en gardant le préfixe `- **role:**` intact (reparse toujours valide).
|
||||
if kept_rev.is_empty() {
|
||||
let truncated = truncate_turn_line_keeping_prefix(line, budget_for_turns);
|
||||
if !truncated.is_empty() {
|
||||
kept_rev.push(truncated);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
kept_rev.reverse();
|
||||
|
||||
let mut out = String::new();
|
||||
if !objective_md.is_empty() {
|
||||
out.push_str(&objective_md);
|
||||
if !kept_rev.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
if !kept_rev.is_empty() {
|
||||
out.push_str(&kept_rev.join("\n"));
|
||||
}
|
||||
// Clamp final de sûreté : garantit `out.chars().count() <= max_chars` (donc
|
||||
// l'idempotence via la garde d'entrée) même sur une entrée pathologique (objectif
|
||||
// gigantesque, aucune ligne de tour). Char-boundary safe.
|
||||
if out.chars().count() > max_chars {
|
||||
out = out.chars().take(max_chars).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Tronque la **fin** d'une ligne de tour à `budget` caractères en gardant intact le
|
||||
/// préfixe `- **<role>:** ` pour que la ligne reste reparsable comme un tour.
|
||||
fn truncate_turn_line_keeping_prefix(line: &str, budget: usize) -> String {
|
||||
if line.chars().count() <= budget {
|
||||
return line.to_owned();
|
||||
}
|
||||
// Préfixe = `- **Role:** ` (inclus l'espace) quand présent, sinon a minima `- **`.
|
||||
let prefix: &str = match line.find(":** ") {
|
||||
Some(pos) => &line[..pos + 4],
|
||||
None => TURN_LINE_PREFIX,
|
||||
};
|
||||
let prefix_len = prefix.chars().count();
|
||||
if budget <= prefix_len {
|
||||
// Budget trop petit pour le moindre texte : on garde juste le préfixe reparsable.
|
||||
return prefix.to_owned();
|
||||
}
|
||||
let tail: String = line[prefix.len()..]
|
||||
.chars()
|
||||
.take(budget - prefix_len)
|
||||
.collect();
|
||||
format!("{prefix}{tail}")
|
||||
}
|
||||
|
||||
/// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3).
|
||||
///
|
||||
/// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
|
||||
@ -483,6 +618,95 @@ mod tests {
|
||||
// Port contract — via the in-memory double
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// bound_handoff_summary (lot LS5) — borne universelle du summary_md
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn handoff(summary: &str) -> Handoff {
|
||||
Handoff::new(summary, turn_id(1), Some("garder l'objectif".to_owned()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_under_limit_is_unchanged() {
|
||||
let h = handoff("**Objectif :** x\n\n- **Prompt:** a\n- **Response:** b");
|
||||
let bounded = bound_handoff_summary(h.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||
assert_eq!(bounded, h, "sous la borne ⇒ inchangé (coût nul)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_preserves_objective_and_cursor_and_drops_oldest_turns() {
|
||||
// 10 lignes de tours ; borne minuscule ⇒ on garde l'objectif + le suffixe récent.
|
||||
let mut s = String::from("**Objectif :** garder ceci");
|
||||
for i in 0..10 {
|
||||
s.push_str(&format!("\n\n"));
|
||||
s.push_str(&format!("- **Prompt:** tour-{i}"));
|
||||
}
|
||||
// Reconstruire au bon format (objectif + lignes jointes par \n).
|
||||
let summary = {
|
||||
let mut out = String::from("**Objectif :** garder ceci\n\n");
|
||||
let lines: Vec<String> = (0..10).map(|i| format!("- **Prompt:** tour-{i}")).collect();
|
||||
out.push_str(&lines.join("\n"));
|
||||
out
|
||||
};
|
||||
let h = Handoff::new(summary, turn_id(7), Some("garder ceci".to_owned()));
|
||||
let bounded = bound_handoff_summary(h.clone(), 60);
|
||||
|
||||
assert!(bounded.summary_md.chars().count() <= 60);
|
||||
// Objectif + curseur jamais modifiés.
|
||||
assert_eq!(bounded.objective, h.objective);
|
||||
assert_eq!(bounded.up_to, h.up_to);
|
||||
assert!(bounded.summary_md.contains("**Objectif :** garder ceci"));
|
||||
// Les plus récents survivent, les plus anciens sont droppés.
|
||||
assert!(
|
||||
bounded.summary_md.contains("tour-9"),
|
||||
"le plus récent reste"
|
||||
);
|
||||
assert!(
|
||||
!bounded.summary_md.contains("tour-0"),
|
||||
"le plus ancien droppé"
|
||||
);
|
||||
// Chaque ligne de tour conservée reste reparsable.
|
||||
for line in bounded.summary_md.lines().filter(|l| l.starts_with("- **")) {
|
||||
assert!(line.starts_with("- **"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_hard_truncates_a_single_oversize_line_keeping_prefix() {
|
||||
let long = "x".repeat(500);
|
||||
let summary = format!("- **Response:** {long}");
|
||||
let h = Handoff::new(summary, turn_id(3), None);
|
||||
let bounded = bound_handoff_summary(h, 60);
|
||||
assert!(bounded.summary_md.chars().count() <= 60);
|
||||
assert!(
|
||||
bounded.summary_md.starts_with("- **Response:** "),
|
||||
"préfixe de tour intact ⇒ reparse valide : {}",
|
||||
bounded.summary_md
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_is_idempotent() {
|
||||
let summary = {
|
||||
let mut out = String::from("**Objectif :** garder ceci\n\n");
|
||||
let lines: Vec<String> = (0..30)
|
||||
.map(|i| {
|
||||
format!(
|
||||
"- **Prompt:** un tour relativement long numero {i} {}",
|
||||
"y".repeat(50)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&lines.join("\n"));
|
||||
out
|
||||
};
|
||||
let h = Handoff::new(summary, turn_id(9), Some("garder ceci".to_owned()));
|
||||
let once = bound_handoff_summary(h, 200);
|
||||
let twice = bound_handoff_summary(once.clone(), 200);
|
||||
assert_eq!(once, twice, "bound(bound(x)) == bound(x)");
|
||||
assert!(once.summary_md.chars().count() <= 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_then_read_all_preserves_insertion_order() {
|
||||
let log = InMemoryConversationLog::default();
|
||||
|
||||
@ -103,8 +103,8 @@ pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
ProviderSessionStore, TurnId, TurnRole,
|
||||
bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
|
||||
HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
pub use fileguard::{
|
||||
|
||||
@ -49,7 +49,7 @@ mod summarizer;
|
||||
|
||||
pub use handoff::FsHandoffStore;
|
||||
pub use providers::FsProviderSessionStore;
|
||||
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
|
||||
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};
|
||||
|
||||
/// Dossier `.ideai/` à la racine d'un project root.
|
||||
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
@ -51,6 +51,14 @@ pub const WINDOW: usize = 20;
|
||||
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
||||
const OBJECTIVE_MAX_CHARS: usize = 200;
|
||||
|
||||
/// Longueur maximale (en caractères) du **texte aplati** d'un tour rendu sur une ligne
|
||||
/// (lot LS5). C'est le correctif central du trou de perf : sans borne, un gros
|
||||
/// `Response` produit une ligne géante qui gonfle le `summary_md` (et donc le contexte
|
||||
/// injecté à chaque lancement). Au-delà, le texte est tronqué avec une élision `…` ; le
|
||||
/// préfixe `- **role:**` reste **intact** (la ligne reste reparsable). Borne **publique**
|
||||
/// pour que l'agent Test la vérifie sans la deviner.
|
||||
pub const TURN_LINE_MAX_CHARS: usize = 240;
|
||||
|
||||
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
|
||||
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
||||
|
||||
@ -80,7 +88,9 @@ fn render_turn(turn: &ConversationTurn) -> String {
|
||||
TurnRole::Response => "Response",
|
||||
TurnRole::ToolActivity => "Tool",
|
||||
};
|
||||
let flat = flatten(&turn.text);
|
||||
// Borne LS5 : le texte aplati est tronqué à `TURN_LINE_MAX_CHARS` (élision `…`) pour
|
||||
// éviter qu'un gros Response ne produise une ligne géante. Le préfixe reste intact.
|
||||
let flat = elide(&flatten(&turn.text), TURN_LINE_MAX_CHARS);
|
||||
format!("- **{label}:** {flat}")
|
||||
}
|
||||
|
||||
@ -89,6 +99,17 @@ fn flatten(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Tronque `text` à `max_chars` caractères (char-boundary safe), en ajoutant une
|
||||
/// élision `…` quand il a effectivement été coupé. En deçà, renvoie le texte tel quel.
|
||||
fn elide(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
text.to_owned()
|
||||
} else {
|
||||
let kept: String = text.chars().take(max_chars).collect();
|
||||
format!("{kept}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparse les lignes de tours déjà rendues dans un `summary_md` précédent.
|
||||
///
|
||||
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
|
||||
|
||||
@ -39,7 +39,8 @@ pub mod timeparse;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
TURN_LINE_MAX_CHARS, WINDOW,
|
||||
};
|
||||
pub use eventbus::TokioBroadcastEventBus;
|
||||
pub use fileguard::RwFileGuard;
|
||||
|
||||
@ -18,14 +18,15 @@ use std::path::PathBuf;
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
ProviderSessionStore, TurnId, TurnRole,
|
||||
bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
|
||||
HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
use domain::input::InputSource;
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::{
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW,
|
||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
||||
TURN_LINE_MAX_CHARS, WINDOW,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -900,6 +901,156 @@ async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||
assert_eq!(h2.up_to, turn_id(3));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LS5 — borne par-ligne (`render_turn`/`elide`) + boundedness sur 100 replis +
|
||||
// round-trip Fs réel avec `summary_md` borné. Le préfixe de ligne de tour, mesuré
|
||||
// une fois pour les assertions de borne structurelle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Le préfixe `- **Response:** ` (en caractères), partagé par les bornes structurelles.
|
||||
const RESPONSE_PREFIX: &str = "- **Response:** ";
|
||||
|
||||
/// LS5 — un tour `Response` énorme : sa ligne rendue est **élidée** (le texte aplati est
|
||||
/// borné à [`TURN_LINE_MAX_CHARS`] + le marqueur `…`), le préfixe `- **role:**` reste
|
||||
/// intact, et la ligne reste reparsable. Testé via `fold` (`render_turn` est privé).
|
||||
#[tokio::test]
|
||||
async fn fold_elides_a_huge_response_line_keeping_prefix() {
|
||||
let s = HeuristicHandoffSummarizer::new();
|
||||
let c = conv_id(1);
|
||||
let huge = "z".repeat(10_000);
|
||||
let h = s
|
||||
.fold(None, &[turn(c, turn_id(1), TurnRole::Response, &huge)])
|
||||
.await;
|
||||
|
||||
let line = turn_lines(&h.summary_md)[0];
|
||||
// Préfixe intact ⇒ ligne reparsable.
|
||||
assert!(
|
||||
line.starts_with(RESPONSE_PREFIX),
|
||||
"préfixe de tour intact : {line}"
|
||||
);
|
||||
// Élidée : marqueur `…` final, et la ligne n'a pas conservé les 10 000 caractères.
|
||||
assert!(line.ends_with('…'), "marqueur d'élision présent : {line}");
|
||||
// Borne structurelle : préfixe + TURN_LINE_MAX_CHARS (texte borné) + 1 pour `…`.
|
||||
let max_line = RESPONSE_PREFIX.chars().count() + TURN_LINE_MAX_CHARS + 1;
|
||||
assert!(
|
||||
line.chars().count() <= max_line,
|
||||
"ligne bornée à {max_line} car., obtenu {} : {line}",
|
||||
line.chars().count()
|
||||
);
|
||||
// Le texte aplati lui-même (hors préfixe, hors `…`) est borné à TURN_LINE_MAX_CHARS.
|
||||
let text_chars = line.chars().count() - RESPONSE_PREFIX.chars().count() - 1;
|
||||
assert_eq!(
|
||||
text_chars, TURN_LINE_MAX_CHARS,
|
||||
"texte aplati borné à TURN_LINE_MAX_CHARS"
|
||||
);
|
||||
}
|
||||
|
||||
/// LS5 — 100 replis incrémentaux : la `WINDOW` borne le nombre de lignes (la croissance
|
||||
/// ne s'accumule pas), l'objectif extrait du 1er prompt est **figé**, la fenêtre reparse
|
||||
/// proprement, et — pour des tours de taille réaliste — le `summary_md` reste sous le
|
||||
/// plafond universel [`HANDOFF_SUMMARY_MAX_CHARS`]. (Le plafond *dur*, valable même pour
|
||||
/// un résumeur pathologique/LLM, est garanti séparément par `bound_handoff_summary` —
|
||||
/// cf. les tests domaine/application.)
|
||||
#[tokio::test]
|
||||
async fn fold_a_hundred_increments_stays_bounded_and_window_reparses() {
|
||||
let s = HeuristicHandoffSummarizer::new();
|
||||
let c = conv_id(1);
|
||||
|
||||
// 1er tour = Prompt énonçant l'objectif ; replis incrémentaux ensuite.
|
||||
let mut handoff = s
|
||||
.fold(
|
||||
None,
|
||||
&[turn(
|
||||
c,
|
||||
turn_id(1),
|
||||
TurnRole::Prompt,
|
||||
"objectif initial figé",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
for n in 2..=100u128 {
|
||||
let text = format!("tour numero {n} {}", "détail ".repeat(15)); // ~120 car.
|
||||
handoff = s
|
||||
.fold(
|
||||
Some(handoff),
|
||||
&[turn(c, turn_id(n), TurnRole::Response, &text)],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// WINDOW respectée : jamais plus de WINDOW lignes-tours, malgré 100 replis.
|
||||
let lines = turn_lines(&handoff.summary_md);
|
||||
assert_eq!(lines.len(), WINDOW, "WINDOW borne le nombre de lignes");
|
||||
// Reparse stable : chaque ligne conservée est une ligne de tour bien formée.
|
||||
for line in &lines {
|
||||
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
|
||||
}
|
||||
// Objectif figé depuis le 1er prompt.
|
||||
assert_eq!(handoff.objective.as_deref(), Some("objectif initial figé"));
|
||||
assert!(handoff
|
||||
.summary_md
|
||||
.contains("**Objectif :** objectif initial figé"));
|
||||
// Curseur = dernier tour intégré.
|
||||
assert_eq!(handoff.up_to, turn_id(100));
|
||||
// Borné sous le plafond universel (tours réalistes ⇒ la WINDOW suffit).
|
||||
assert!(
|
||||
handoff.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary borné à HANDOFF_SUMMARY_MAX_CHARS, obtenu {}",
|
||||
handoff.summary_md.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
/// LS5 — round-trip `FsHandoffStore` **réel** : après un fold multi-tours (texte volumineux
|
||||
/// ⇒ summary potentiellement > plafond) PUIS `bound_handoff_summary`, le `handoff.md` écrit
|
||||
/// sur disque relit un `summary_md` ≤ [`HANDOFF_SUMMARY_MAX_CHARS`], objectif/curseur
|
||||
/// préservés, et reste reparsable.
|
||||
#[tokio::test]
|
||||
async fn fs_handoff_round_trip_persists_bounded_summary() {
|
||||
let tmp = TempDir::new();
|
||||
let store = FsHandoffStore::new(&tmp.project_path());
|
||||
let s = HeuristicHandoffSummarizer::new();
|
||||
let c = conv_id(1);
|
||||
|
||||
// Fold de tours VOLUMINEUX : chaque ligne est élidée à ~257 car., 20 lignes ⇒ le
|
||||
// summary heuristique dépasse le plafond universel (≈ 5 000 > 4096).
|
||||
let mut handoff = s
|
||||
.fold(None, &[turn(c, turn_id(1), TurnRole::Prompt, "objectif")])
|
||||
.await;
|
||||
for n in 2..=60u128 {
|
||||
let big = "m".repeat(3_000);
|
||||
handoff = s
|
||||
.fold(
|
||||
Some(handoff),
|
||||
&[turn(c, turn_id(n), TurnRole::Response, &big)],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
handoff.summary_md.chars().count() > HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"pré-condition : le fold heuristique seul dépasse le plafond ({})",
|
||||
handoff.summary_md.chars().count()
|
||||
);
|
||||
|
||||
// Pipeline réel : borne universelle AVANT persistance.
|
||||
let bounded = bound_handoff_summary(handoff.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||
store.save(c, bounded.clone()).await.expect("save ok");
|
||||
|
||||
let loaded = store.load(c).await.expect("load ok").expect("present");
|
||||
assert!(
|
||||
loaded.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"handoff.md sur disque borné, obtenu {}",
|
||||
loaded.summary_md.chars().count()
|
||||
);
|
||||
assert_eq!(loaded.objective, bounded.objective, "objectif préservé");
|
||||
assert_eq!(loaded.up_to, bounded.up_to, "curseur préservé");
|
||||
// Reparsable : au moins une ligne de tour, toutes bien préfixées.
|
||||
let kept = turn_lines(&loaded.summary_md);
|
||||
assert!(!kept.is_empty(), "au moins une ligne de tour conservée");
|
||||
for line in kept {
|
||||
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
|
||||
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison
|
||||
|
||||
38
docs/adr/LS5-handoff-summary-bound-and-llm-seam.md
Normal file
38
docs/adr/LS5-handoff-summary-bound-and-llm-seam.md
Normal file
@ -0,0 +1,38 @@
|
||||
# ADR LS5 — Borne du résumé de handoff & seam résumeur LLM
|
||||
|
||||
Statut : accepté (lot LS5, branche `feature/agent-live-state`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Le résumé de reprise (`Handoff::summary_md`) est injecté dans le contexte d'un agent
|
||||
à **chaque lancement** et persisté à **chaque checkpoint**. Sans borne, un gros tour
|
||||
`Response` produit une ligne géante qui gonfle le résumé → coût sur le **chemin chaud**
|
||||
(append/checkpoint + contexte injecté). Priorité produit : gagner en perf **sans** mettre
|
||||
de LLM sur ce chemin chaud.
|
||||
|
||||
## Décision (implémentée)
|
||||
|
||||
1. **Borne par ligne (infra)** : `render_turn` tronque le texte aplati de chaque tour à
|
||||
`TURN_LINE_MAX_CHARS = 240` (élision `…`), préfixe `- **role:**` conservé. Correctif
|
||||
central du trou de perf.
|
||||
2. **Borne universelle (domaine)** : `bound_handoff_summary(h, HANDOFF_SUMMARY_MAX_CHARS=4096)`,
|
||||
pure, idempotente, sans rejet — garde l'objectif + le suffixe de tours le plus récent,
|
||||
droppe les plus anciens, hard-truncate la fin d'une ligne trop longue sans casser son
|
||||
préfixe. `up_to`/`objective` jamais modifiés.
|
||||
3. **Application** : la borne universelle est posée **entre `fold` et `save`** (écriture)
|
||||
et **avant `compose_convention_file`** (injection, protège les `handoff.md` legacy).
|
||||
Indépendante du résumeur.
|
||||
|
||||
## Seam LLM — futur, NON implémenté
|
||||
|
||||
Le port `HandoffSummarizer::fold` reste **inchangé** (async, sans `Result`, best-effort) ;
|
||||
le défaut câblé reste `HeuristicHandoffSummarizer`. Un futur `LlmHandoffSummarizer` devra
|
||||
respecter le contrat suivant :
|
||||
|
||||
- **Hors chemin chaud, déclenchement froid** : jamais par tour. Calcul à la reprise / à la
|
||||
rotation / sur débounce (coalescer plusieurs tours rapides), pas à chaque `record`.
|
||||
- **Timeout + fallback heuristique** : un appel réseau lent/échoué retombe sur le résumé
|
||||
heuristique (ou `prev`), jamais d'erreur propagée, jamais de blocage du checkpoint.
|
||||
- **Sélection par flag**, défaut **heuristique** (zéro dépendance réseau par défaut).
|
||||
- La **borne universelle** s'applique de toute façon à sa sortie (garantie indépendante
|
||||
du résumeur).
|
||||
Reference in New Issue
Block a user