feat(domain,infra,app): borne summary_md du handoff + durcissement seam LLM (LS5)

Clôt le must-have perf du handoff :
- domain : HANDOFF_SUMMARY_MAX_CHARS + bound_handoff_summary (helpers privés), re-export lib.
- infrastructure : TURN_LINE_MAX_CHARS + élision dans render_turn (summarizer), re-exports.
- application : borne entre fold et save (conversation/record), borne défensive
  dans resolve_handoff (agent/lifecycle) + module test.
- seam LLM prêt mais NON activé (aucun LLM câblé).
- tests (QA, verts) : conversation_log, conversation_record, agent_lifecycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:29:44 +02:00
parent 533a9c57f3
commit 13a953cb05
10 changed files with 673 additions and 16 deletions

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).
///
/// 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();