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>
188 lines
8.2 KiB
Rust
188 lines
8.2 KiB
Rust
//! [`HeuristicHandoffSummarizer`] — l'adapter zéro-dépendance du port
|
|
//! [`HandoffSummarizer`] (cadrage « persistance conversationnelle », lot P4).
|
|
//!
|
|
//! Replie un [`Handoff`] de façon **incrémentale**, **déterministe**, **sans I/O** et
|
|
//! **sans modèle** (ARCHITECTURE §19.2/§19.3, ligne P4 du §19.6). C'est le repli
|
|
//! universel zéro-dépendance ; un `LlmHandoffSummarizer` plus riche viendra le substituer
|
|
//! en P10 (OCP — le port async est figé pour ça, cf. [`HandoffSummarizer`]).
|
|
//!
|
|
//! ## Stratégie heuristique
|
|
//!
|
|
//! Le résumé garde deux choses, en ne lisant que **l'incrément** (`new_turns`) — jamais
|
|
//! tout le fil depuis zéro :
|
|
//!
|
|
//! 1. **Un objectif courant** ([`Handoff::objective`]) : repris **tel quel** de `prev`.
|
|
//! Si `prev` n'en a pas (ou est `None`), on en **extrait** un depuis le **premier
|
|
//! tour `Prompt`** de l'incrément (sa première ligne non vide, tronquée) — règle simple
|
|
//! et déterministe : le premier prompt d'un fil énonce typiquement la tâche. Une fois
|
|
//! fixé, l'objectif ne change plus (on ne le réécrit pas à chaque tour).
|
|
//!
|
|
//! 2. **Une fenêtre des [`WINDOW`] derniers tours** rendue en Markdown. Comme on ne
|
|
//! dispose pas de tout le fil dans `fold`, on **reconstitue** cette fenêtre à partir des
|
|
//! tours déjà rendus dans `prev.summary_md` (reparsés) **concaténés** à `new_turns`,
|
|
//! puis on **tronque** aux [`WINDOW`] derniers. La borne est donc toujours respectée,
|
|
//! même après de nombreux replis successifs.
|
|
//!
|
|
//! Le `summary_md` résultant = (ligne d'objectif si présent) + les [`WINDOW`] derniers
|
|
//! tours formatés. Format d'un tour stable et reparsable (cf. [`render_turn`]).
|
|
//!
|
|
//! ## Curseur (`up_to`)
|
|
//!
|
|
//! - `new_turns` non vide ⇒ l'id du **dernier** tour de l'incrément.
|
|
//! - `new_turns` vide ⇒ on renvoie `prev` **inchangé** (rien de neuf à intégrer).
|
|
//! - `prev = None` et `new_turns` vide ⇒ handoff vide, curseur = **nil UUID**
|
|
//! ([`TurnId::from_uuid(Uuid::nil())`]) : sentinelle sûre « aucun tour couvert ».
|
|
//!
|
|
//! ## Déterminisme
|
|
//!
|
|
//! Mêmes entrées ⇒ même sortie (aucune horloge, aucun aléa, aucune I/O), donc trivialement
|
|
//! testable.
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::conversation_log::{ConversationTurn, Handoff, HandoffSummarizer, TurnId, TurnRole};
|
|
|
|
/// Nombre maximum de tours conservés dans la fenêtre Markdown du résumé.
|
|
///
|
|
/// Borne **publique** (et donc lisible par l'agent Test pour vérifier la troncature sans
|
|
/// la deviner) : au-delà de `WINDOW` tours, seuls les `WINDOW` derniers sont rendus.
|
|
pub const WINDOW: usize = 20;
|
|
|
|
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
|
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).
|
|
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
|
|
|
/// Adapter heuristique zéro-dépendance du résumeur de handoff.
|
|
///
|
|
/// Sans état (aucun champ) : une seule instance sert toutes les conversations. Construit
|
|
/// via [`HeuristicHandoffSummarizer::new`] ou [`Default`].
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct HeuristicHandoffSummarizer;
|
|
|
|
impl HeuristicHandoffSummarizer {
|
|
/// Construit le résumeur heuristique (sans état).
|
|
#[must_use]
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
}
|
|
|
|
/// Rend un tour en une ligne Markdown stable et **reparsable** (cf. [`parse_rendered`]).
|
|
///
|
|
/// Format : `- **<role>:** <texte sur une ligne>`. Le texte est aplati (sauts de ligne →
|
|
/// espaces) pour garder une ligne par tour, ce qui rend la fenêtre reconstituable depuis
|
|
/// `prev.summary_md`.
|
|
fn render_turn(turn: &ConversationTurn) -> String {
|
|
let label = match turn.role {
|
|
TurnRole::Prompt => "Prompt",
|
|
TurnRole::Response => "Response",
|
|
TurnRole::ToolActivity => "Tool",
|
|
};
|
|
// 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}")
|
|
}
|
|
|
|
/// Aplati un texte multi-lignes en une seule ligne (sauts de ligne → espace, trim).
|
|
fn flatten(text: &str) -> String {
|
|
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.
|
|
///
|
|
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
|
|
/// les blancs. On garde la **ligne brute** (déjà au bon format) : pas besoin de
|
|
/// reconstruire un `ConversationTurn` complet, on ne manipule que du Markdown.
|
|
fn parse_rendered(summary_md: &str) -> Vec<String> {
|
|
summary_md
|
|
.lines()
|
|
.filter(|l| l.starts_with("- **"))
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// Extrait un objectif candidat du premier tour `Prompt` de l'incrément, le cas échéant.
|
|
///
|
|
/// Première ligne non vide du premier prompt, aplatie et tronquée à [`OBJECTIVE_MAX_CHARS`].
|
|
fn extract_objective(new_turns: &[ConversationTurn]) -> Option<String> {
|
|
let first_prompt = new_turns.iter().find(|t| t.role == TurnRole::Prompt)?;
|
|
let flat = flatten(&first_prompt.text);
|
|
if flat.is_empty() {
|
|
return None;
|
|
}
|
|
let truncated: String = flat.chars().take(OBJECTIVE_MAX_CHARS).collect();
|
|
Some(truncated)
|
|
}
|
|
|
|
/// Assemble le `summary_md` final = ligne d'objectif (si présent) + fenêtre de tours.
|
|
fn render_summary(objective: Option<&str>, window: &[String]) -> String {
|
|
let mut blocks: Vec<String> = Vec::new();
|
|
if let Some(obj) = objective {
|
|
blocks.push(format!("{OBJECTIVE_PREFIX}{obj}"));
|
|
}
|
|
if !window.is_empty() {
|
|
blocks.push(window.join("\n"));
|
|
}
|
|
blocks.join("\n\n")
|
|
}
|
|
|
|
#[async_trait]
|
|
impl HandoffSummarizer for HeuristicHandoffSummarizer {
|
|
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
|
// Rien de neuf : on rend `prev` inchangé (ou un handoff vide cohérent si None).
|
|
if new_turns.is_empty() {
|
|
return prev.unwrap_or_else(|| {
|
|
Handoff::new(String::new(), TurnId::from_uuid(uuid::Uuid::nil()), None)
|
|
});
|
|
}
|
|
|
|
// Objectif : repris de `prev` s'il existe, sinon extrait du premier prompt de
|
|
// l'incrément (règle simple et déterministe ; figé une fois fixé).
|
|
let objective = prev
|
|
.as_ref()
|
|
.and_then(|h| h.objective.clone())
|
|
.or_else(|| extract_objective(new_turns));
|
|
|
|
// Fenêtre : (tours déjà rendus dans prev) ++ (incrément rendu), tronquée aux
|
|
// WINDOW derniers. On ne relit jamais tout le log : seul l'incrément est nouveau.
|
|
let mut window: Vec<String> = prev
|
|
.as_ref()
|
|
.map(|h| parse_rendered(&h.summary_md))
|
|
.unwrap_or_default();
|
|
window.extend(new_turns.iter().map(render_turn));
|
|
let start = window.len().saturating_sub(WINDOW);
|
|
let window = &window[start..];
|
|
|
|
// Curseur : l'id du dernier tour de l'incrément (new_turns non vide ici).
|
|
let up_to = new_turns
|
|
.last()
|
|
.map(|t| t.id)
|
|
.unwrap_or_else(|| TurnId::from_uuid(uuid::Uuid::nil()));
|
|
|
|
let summary_md = render_summary(objective.as_deref(), window);
|
|
Handoff::new(summary_md, up_to, objective)
|
|
}
|
|
}
|