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:
@ -23,11 +23,12 @@ use domain::ports::{
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project,
|
||||
ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize,
|
||||
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||
Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId,
|
||||
PermissionProjector, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill,
|
||||
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use domain::live_state::WorkStatus;
|
||||
@ -1318,7 +1319,15 @@ impl LaunchAgent {
|
||||
let conversation = ConversationId::from_uuid(uuid::Uuid::parse_str(raw).ok()?);
|
||||
let store = provider.handoff_store_for(root)?;
|
||||
// Toute erreur de lecture/désérialisation ⇒ pas d'injection (best-effort).
|
||||
store.load(conversation).await.ok().flatten()
|
||||
// Borne défensive LS5 : un `handoff.md` legacy non borné (écrit avant LS5) est
|
||||
// ramené sous `HANDOFF_SUMMARY_MAX_CHARS` **avant** d'entrer dans le contexte
|
||||
// composé — sans migration de données, idempotent et best-effort.
|
||||
store
|
||||
.load(conversation)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| bound_handoff_summary(h, HANDOFF_SUMMARY_MAX_CHARS))
|
||||
}
|
||||
|
||||
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
|
||||
@ -3738,6 +3747,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_with_normal_handoff_is_byte_identical_pre_and_post_ls5_bound() {
|
||||
// ZÉRO RÉGRESSION (LS5) : pour un handoff sous le plafond, le seul changement
|
||||
// LS5 du chemin d'injection — `bound_handoff_summary` dans `resolve_handoff` —
|
||||
// est l'identité (cf. domaine `bound_under_limit_is_unchanged`). Le document
|
||||
// composé est donc OCTET-IDENTIQUE avant/après LS5. Assertion stricte.
|
||||
let memory = [mem(
|
||||
"git-optional",
|
||||
"Git optionnel",
|
||||
"git reste un simple tool",
|
||||
MemoryType::Project,
|
||||
)];
|
||||
let normal = handoff(
|
||||
"**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b",
|
||||
Some("livrer LS5"),
|
||||
);
|
||||
assert!(
|
||||
normal.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"pré-condition : handoff sous le plafond"
|
||||
);
|
||||
|
||||
// « Avant LS5 » : le composeur reçoit le handoff tel quel (aucune borne).
|
||||
let pre = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&normal),
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
// « Après LS5 » : le handoff passe d'abord par la borne universelle.
|
||||
let bounded = bound_handoff_summary(normal.clone(), HANDOFF_SUMMARY_MAX_CHARS);
|
||||
let post = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&memory,
|
||||
Some(&bounded),
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
pre, post,
|
||||
"handoff normal ⇒ document composé octet-identique"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// État du projet (lot LS4) — live-state preview section in the composer.
|
||||
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
|
||||
use domain::{
|
||||
bound_handoff_summary, ConversationId, ConversationLog, ConversationTurn, HandoffStore,
|
||||
HandoffSummarizer, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -63,6 +66,9 @@ impl RecordTurn {
|
||||
/// 1. **append** the turn to the canonical log (source of truth) ;
|
||||
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
|
||||
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
|
||||
/// 3b. **bound** the folded handoff's summary to [`HANDOFF_SUMMARY_MAX_CHARS`] — a
|
||||
/// **universal cap independent of the summarizer** (heuristic *or* a future LLM):
|
||||
/// idempotent, never rejecting, so it never blocks the checkpoint (LS5) ;
|
||||
/// 4. **save** the advanced handoff.
|
||||
///
|
||||
/// The append happens **first** so the durable source of truth is never behind
|
||||
@ -92,6 +98,13 @@ impl RecordTurn {
|
||||
.fold(prev, std::slice::from_ref(&turn))
|
||||
.await;
|
||||
|
||||
// 3b. **Universal bound** (LS5): clamp the resume summary to
|
||||
// `HANDOFF_SUMMARY_MAX_CHARS`, *independently of the summarizer*. Whatever
|
||||
// produced the handoff — the heuristic or a future LLM summarizer — the
|
||||
// persisted (and later injected) summary is size-bounded. Idempotent and
|
||||
// never rejecting, so it can never block the checkpoint.
|
||||
let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
|
||||
|
||||
// 4. Persist the advanced handoff (overwrites the previous resume point).
|
||||
self.handoffs.save(conversation, handoff).await?;
|
||||
|
||||
|
||||
@ -2448,6 +2448,88 @@ async fn launch_with_store_without_handoff_omits_resume_section() {
|
||||
);
|
||||
}
|
||||
|
||||
/// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé
|
||||
/// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté
|
||||
/// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On
|
||||
/// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir.
|
||||
#[tokio::test]
|
||||
async fn launch_bounds_oversize_legacy_handoff_resume_section() {
|
||||
// Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section
|
||||
// injectée = le seul summary, comparable directement au plafond).
|
||||
let mut summary = String::new();
|
||||
for i in 0..500u32 {
|
||||
if i > 0 {
|
||||
summary.push('\n');
|
||||
}
|
||||
summary.push_str(&format!(
|
||||
"- **Response:** tour numero {i} {}",
|
||||
"x".repeat(30)
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"pré-condition : le handoff legacy dépasse le plafond ({})",
|
||||
summary.chars().count()
|
||||
);
|
||||
|
||||
let conv = conv_id(123);
|
||||
let store = FakeHandoffStore::with(conv, handoff_for(&summary, None));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
let (launch, agent, fs) = launch_with_handoff(Some(provider));
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(Uuid::from_u128(123).to_string());
|
||||
launch.execute(input).await.expect("launch succeeds");
|
||||
|
||||
let doc = convention_doc(&fs);
|
||||
// La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu
|
||||
// (après l'entête) = le summary injecté, qui doit être borné.
|
||||
let section = doc
|
||||
.split("# Reprise de la conversation\n\n")
|
||||
.nth(1)
|
||||
.expect("resume section present")
|
||||
.trim_end();
|
||||
assert!(
|
||||
section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary legacy injecté borné, obtenu {}",
|
||||
section.chars().count()
|
||||
);
|
||||
// Distillation : les lignes les plus récentes survivent, les plus anciennes droppées.
|
||||
assert!(section.contains("tour numero 499"), "le plus récent reste");
|
||||
assert!(
|
||||
!section.contains("tour numero 0 "),
|
||||
"le plus ancien droppé : {section}"
|
||||
);
|
||||
// Reparsable : chaque ligne conservée garde le préfixe de tour.
|
||||
for line in section.lines().filter(|l| !l.is_empty()) {
|
||||
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté
|
||||
/// **verbatim** (la borne est l'identité), objectif et summary intacts.
|
||||
#[tokio::test]
|
||||
async fn launch_normal_handoff_injects_summary_verbatim() {
|
||||
let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b";
|
||||
assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS);
|
||||
let conv = conv_id(123);
|
||||
let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5")));
|
||||
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
|
||||
let (launch, agent, fs) = launch_with_handoff(Some(provider));
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.conversation_id = Some(Uuid::from_u128(123).to_string());
|
||||
launch.execute(input).await.expect("launch succeeds");
|
||||
|
||||
let doc = convention_doc(&fs);
|
||||
// Le summary normal est injecté tel quel (la borne ne l'a pas touché).
|
||||
assert!(
|
||||
doc.contains(summary),
|
||||
"handoff normal injecté verbatim (borne = identité) : {doc}"
|
||||
);
|
||||
assert!(doc.contains("**Objectif :** livrer LS5"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
|
||||
//
|
||||
|
||||
@ -20,7 +20,7 @@ use async_trait::async_trait;
|
||||
use domain::ports::StoreError;
|
||||
use domain::{
|
||||
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
InputSource, TurnId, TurnRole,
|
||||
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use application::{AppError, RecordTurn};
|
||||
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
|
||||
assert_eq!(hb.up_to, tb.id);
|
||||
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LS5 — la borne universelle s'applique côté `record`, indépendamment du résumeur.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Un résumeur **adversarial** qui renvoie toujours un `summary_md` largement
|
||||
/// surdimensionné (au-delà du plafond), au format reparsable, pour prouver que la
|
||||
/// garantie de borne vient de `record` (via `bound_handoff_summary`), PAS du résumeur.
|
||||
#[derive(Default)]
|
||||
struct OversizeSummarizer;
|
||||
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for OversizeSummarizer {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
// Beaucoup de lignes de tours bien formées ⇒ summary_md >> plafond.
|
||||
let mut s = String::from("**Objectif :** objectif preserve");
|
||||
for i in 0..1_000 {
|
||||
s.push_str(&format!(
|
||||
"\n- **Response:** ligne surdimensionnee numero {i}"
|
||||
));
|
||||
}
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("fold receives at least one turn");
|
||||
Handoff::new(s, up_to, Some("objectif preserve".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Avec un résumeur qui produit un summary géant, le handoff **persisté** est borné à
|
||||
/// `HANDOFF_SUMMARY_MAX_CHARS` (garantie universelle), tout en gardant l'ordre `append`-
|
||||
/// d'abord, le fold incrémental (un appel par tour), objectif et curseur préservés.
|
||||
#[tokio::test]
|
||||
async fn record_bounds_persisted_handoff_even_with_oversize_summarizer() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(OversizeSummarizer);
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "premier tour");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record ok");
|
||||
|
||||
// append-d'abord : le log canonique porte le tour.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
|
||||
// Le handoff persisté est BORNÉ malgré le résumeur surdimensionné.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary persisté borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
// Objectif + curseur jamais modifiés par la borne.
|
||||
assert_eq!(h.objective.as_deref(), Some("objectif preserve"));
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
// Reparsable : le préambule d'objectif survit, les lignes de tours sont préfixées.
|
||||
assert!(h.summary_md.contains("**Objectif :** objectif preserve"));
|
||||
for line in h.summary_md.lines().filter(|l| l.starts_with("- **")) {
|
||||
assert!(line.starts_with("- **"));
|
||||
}
|
||||
}
|
||||
|
||||
/// La borne au `record` reste incrémentale sur plusieurs tours : un `fold` par tour
|
||||
/// (jamais de relecture complète), et chaque checkpoint persiste un summary borné.
|
||||
#[tokio::test]
|
||||
async fn record_stays_incremental_and_bounded_across_turns() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Compte les appels `fold` pour prouver l'incrémentalité (un par tour).
|
||||
#[derive(Default)]
|
||||
struct CountingOversize {
|
||||
calls: Mutex<usize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for CountingOversize {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
// L'incrément ne porte qu'un seul tour à chaque checkpoint.
|
||||
assert_eq!(new_turns.len(), 1, "fold incrémental : un tour à la fois");
|
||||
let big = "g".repeat(HANDOFF_SUMMARY_MAX_CHARS * 3);
|
||||
let up_to = new_turns[0].id;
|
||||
Handoff::new(format!("- **Response:** {big}"), up_to, None)
|
||||
}
|
||||
}
|
||||
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(CountingOversize::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
for n in 1..=5u128 {
|
||||
uc.record(conv, turn(conv, turn_id(n), &format!("tour {n}")))
|
||||
.await
|
||||
.expect("record ok");
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"checkpoint {n} borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
}
|
||||
assert_eq!(*summarizer.calls.lock().unwrap(), 5, "un fold par tour");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user