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:
2026-06-22 12:29:52 +02:00
11 changed files with 711 additions and 16 deletions

View File

@ -23,11 +23,12 @@ use domain::ports::{
use domain::profile::{McpConfigStrategy, StructuredAdapter}; use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{ use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry, ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project, Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
SessionId, SessionKind, SessionStatus, Skill, TerminalSession, ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
}; };
use domain::live_state::WorkStatus; use domain::live_state::WorkStatus;
@ -1318,7 +1319,15 @@ impl LaunchAgent {
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?); let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
let store = provider.handoff_store_for(root)?; let store = provider.handoff_store_for(root)?;
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort). // 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) à /// 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. // État du projet (lot LS4) — live-state preview section in the composer.
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s // The composer is pure: it renders the already-distilled `InjectedLiveRow`s

View File

@ -2,7 +2,10 @@
use std::sync::Arc; 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; use crate::error::AppError;
@ -63,6 +66,9 @@ impl RecordTurn {
/// 1. **append** the turn to the canonical log (source of truth) ; /// 1. **append** the turn to the canonical log (source of truth) ;
/// 2. **load** the previous handoff (`None` on a first checkpoint) ; /// 2. **load** the previous handoff (`None` on a first checkpoint) ;
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ; /// 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. /// 4. **save** the advanced handoff.
/// ///
/// The append happens **first** so the durable source of truth is never behind /// 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)) .fold(prev, std::slice::from_ref(&turn))
.await; .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). // 4. Persist the advanced handoff (overwrites the previous resume point).
self.handoffs.save(conversation, handoff).await?; self.handoffs.save(conversation, handoff).await?;

View File

@ -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é). // Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
// //

View File

@ -20,7 +20,7 @@ use async_trait::async_trait;
use domain::ports::StoreError; use domain::ports::StoreError;
use domain::{ use domain::{
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
InputSource, TurnId, TurnRole, InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
}; };
use application::{AppError, RecordTurn}; use application::{AppError, RecordTurn};
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
assert_eq!(hb.up_to, tb.id); assert_eq!(hb.up_to, tb.id);
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha")); 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");
}

View File

@ -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). /// 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`] /// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
@ -483,6 +618,95 @@ mod tests {
// Port contract — via the in-memory double // 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] #[tokio::test]
async fn append_then_read_all_preserves_insertion_order() { async fn append_then_read_all_preserves_insertion_order() {
let log = InMemoryConversationLog::default(); let log = InMemoryConversationLog::default();

View File

@ -103,8 +103,8 @@ pub use readiness::{ReadinessPolicy, ReadinessSignal};
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
pub use conversation_log::{ pub use conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
ProviderSessionStore, TurnId, TurnRole, HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
}; };
pub use fileguard::{ pub use fileguard::{

View File

@ -49,7 +49,7 @@ mod summarizer;
pub use handoff::FsHandoffStore; pub use handoff::FsHandoffStore;
pub use providers::FsProviderSessionStore; 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. /// Dossier `.ideai/` à la racine d'un project root.
pub(crate) const IDEAI_DIR: &str = ".ideai"; pub(crate) const IDEAI_DIR: &str = ".ideai";

View File

@ -51,6 +51,14 @@ pub const WINDOW: usize = 20;
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt. /// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
const OBJECTIVE_MAX_CHARS: usize = 200; 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). /// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
const OBJECTIVE_PREFIX: &str = "**Objectif :** "; const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
@ -80,7 +88,9 @@ fn render_turn(turn: &ConversationTurn) -> String {
TurnRole::Response => "Response", TurnRole::Response => "Response",
TurnRole::ToolActivity => "Tool", 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}") format!("- **{label}:** {flat}")
} }
@ -89,6 +99,17 @@ fn flatten(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ") 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. /// 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 /// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et

View File

@ -39,7 +39,8 @@ pub mod timeparse;
pub use clock::SystemClock; pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry; pub use conversation::InMemoryConversationRegistry;
pub use conversation_log::{ pub use conversation_log::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW, FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
}; };
pub use eventbus::TokioBroadcastEventBus; pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard; pub use fileguard::RwFileGuard;

View File

@ -18,14 +18,15 @@ use std::path::PathBuf;
use domain::conversation::ConversationId; use domain::conversation::ConversationId;
use domain::conversation_log::{ use domain::conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, bound_handoff_summary, ConversationLog, ConversationTurn, Handoff, HandoffStore,
ProviderSessionStore, TurnId, TurnRole, HandoffSummarizer, ProviderSessionStore, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
}; };
use domain::input::InputSource; use domain::input::InputSource;
use domain::ports::StoreError; use domain::ports::StoreError;
use domain::project::ProjectPath; use domain::project::ProjectPath;
use infrastructure::{ use infrastructure::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, WINDOW, FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
}; };
use uuid::Uuid; 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)); 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 // P5 — FsProviderSessionStore (providers.json): le `resumable_id` rangé par
// (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison // (conversation, provider). Mêmes helpers L2 que P2/P3/P4 — `TempDir` maison

View 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).