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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user