diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 2cc1b10..cc3d767 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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 diff --git a/crates/application/src/conversation/record.rs b/crates/application/src/conversation/record.rs index b6d5109..ecb2030 100644 --- a/crates/application/src/conversation/record.rs +++ b/crates/application/src/conversation/record.rs @@ -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?; diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 0ce5897..0aea541 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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; + 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; + 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é). // diff --git a/crates/application/tests/conversation_record.rs b/crates/application/tests/conversation_record.rs index eac29c8..33820f1 100644 --- a/crates/application/tests/conversation_record.rs +++ b/crates/application/tests/conversation_record.rs @@ -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, 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, + } + #[async_trait] + impl HandoffSummarizer for CountingOversize { + async fn fold(&self, _prev: Option, 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"); +} diff --git a/crates/domain/src/conversation_log.rs b/crates/domain/src/conversation_log.rs index 188dfb6..8124f85 100644 --- a/crates/domain/src/conversation_log.rs +++ b/crates/domain/src/conversation_log.rs @@ -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 `- **:** `. 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 = 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 `- **:** ` 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 = (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 = (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(); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 2f604e2..81da3fa 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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::{ diff --git a/crates/infrastructure/src/conversation_log/mod.rs b/crates/infrastructure/src/conversation_log/mod.rs index b9dbcc1..a469a57 100644 --- a/crates/infrastructure/src/conversation_log/mod.rs +++ b/crates/infrastructure/src/conversation_log/mod.rs @@ -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"; diff --git a/crates/infrastructure/src/conversation_log/summarizer.rs b/crates/infrastructure/src/conversation_log/summarizer.rs index cd955c0..ee7358a 100644 --- a/crates/infrastructure/src/conversation_log/summarizer.rs +++ b/crates/infrastructure/src/conversation_log/summarizer.rs @@ -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::>().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 diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 9b4ce0b..bdc6105 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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; diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index a90f1ca..1cfe9fd 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -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