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

@ -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

View File

@ -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?;