From f15baa79aa2832a5ebc6d5ee9d1d51e8148e4a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:50:51 +0000 Subject: [PATCH 01/24] =?UTF-8?q?Lot=206=20:=20persistance=20des=20discuss?= =?UTF-8?q?ions=20port=C3=A9e=20en=20Rust=20(chat::persist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de chat_persist.go et de la partie index de chat_sessions.go, au même format en base que le Go (chat/conv:, chat/index, toolres/, tous scellés par l'API interne) : - ConvState : JSON de Conversation à l'octet près (tranches vides en null, bloc projet omis), relecture tolérante comme json.Unmarshal et guérison stripImageParts ; instantané en plein tour avec le journal compacté ; - index : ConvMeta, titre déduit (rappels de loki et exclus, 60 caractères), tri, projets, renommage, rafraîchissement d'une entrée ; - écrivain unique (Persister) : lots fusionnés, jamais un état plus ancien qu'un état écrit, résultats d'outils servis depuis la mémoire jusqu'à l'écriture, suppressions qui ne font rien renaître, file jamais bloquée par un écrivain qui panique ; - StoreSink : écriture par l'API interne du Go. Mémoire verrouillée : rien en clair ; index illisible : jamais réécrit. Tests Go de l'écrivain portés, plus des tests de bout en bout contre le faux Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/chat/compact.rs | 10 +- rust/loki-rs/src/chat/mod.rs | 4 +- rust/loki-rs/src/chat/persist.rs | 1408 ++++++++++++++++++++++++++++++ rust/loki-rs/tests/lot6.rs | 102 +++ 4 files changed, 1518 insertions(+), 6 deletions(-) create mode 100644 rust/loki-rs/src/chat/persist.rs diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs index 886e3c4..680ddcb 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -98,7 +98,7 @@ const THINK_CLOSE: &str = ""; // Rappels de loki (llm_injected.go) : reconnus pour ne pas les prendre pour // une demande de l'utilisateur. const LOKI_NOTE_PREFIX: &str = "[system] "; -const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."; +pub(crate) const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."; const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."; const RETRY_CORRECTIVE_LEAD: &str = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n"; @@ -110,8 +110,8 @@ const BUDGET_NUDGE_ENDS: [&str; 3] = [ ]; // Blocs (chat_projsnap.go). -const CTX_UPD_OPEN: &str = " bool { +pub(crate) fn is_loki_injected(m: &Message) -> bool { if m.role != "user" { return false; } @@ -462,7 +462,7 @@ fn strip_ctx_upd_text(s: &str) -> (String, bool) { /// `withoutCtxUpdate` : le message sans son bloc de tête /// (seulement un message `user` marqué `ctx_upd`). -fn without_ctx_update(m: &Message) -> Option { +pub(crate) fn without_ctx_update(m: &Message) -> Option { if m.role != "user" || !m.ctx_upd { return None; } diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 71db738..e7af56a 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -7,11 +7,13 @@ //! - [`stream`] : analyse du flux SSE du moteur (texte, raisonnement, appels //! d'outils, statistiques) ; //! - [`compact`] : estimation du contexte et compactage de l'historique ; -//! - [`journal`] : journal d'affichage (compactage, rejeu, pagination). +//! - [`journal`] : journal d'affichage (compactage, rejeu, pagination) ; +//! - [`persist`] : format en base, index des discussions, écrivain unique. pub mod compact; pub mod engine; pub mod journal; +pub mod persist; pub mod stream; pub mod types; pub mod wire; diff --git a/rust/loki-rs/src/chat/persist.rs b/rust/loki-rs/src/chat/persist.rs new file mode 100644 index 0000000..233067c --- /dev/null +++ b/rust/loki-rs/src/chat/persist.rs @@ -0,0 +1,1408 @@ +//! Persistance des discussions — port de `chat_persist.go` et de la partie +//! « index » de `chat_sessions.go`. +//! +//! Même format en base que le Go, pour qu'une discussion écrite par l'un se +//! relise par l'autre (et pour la comparaison Go / Rust du lot 6) : +//! +//! ```text +//! chat/index → liste des discussions (ConvMeta, sans les messages), scellée +//! chat/active → identifiant de la discussion ouverte (en clair) +//! chat/conv: → état complet d'une discussion (ConvState), scellé +//! toolres/. → résultat complet d'un outil (« voir plus »), scellé +//! ``` +//! +//! Trois parties : +//! - le FORMAT : [`ConvState`] (JSON de `Conversation`), son instantané +//! [`take_snapshot`] et sa relecture tolérante [`decode_state`] ; +//! - l'INDEX : [`ConvMeta`], titres ([`conv_summary`]), tri, projets, +//! rafraîchissement d'une entrée ([`touch_meta_in`]) ; +//! - l'ÉCRIVAIN UNIQUE : [`Persister`], qui sort l'écriture du chemin du +//! premier jeton, fusionne ce qui s'accumule (dernier instantané de chaque +//! discussion + résultats d'outils) en un lot, n'écrit jamais un état plus +//! ancien qu'un état déjà écrit et ne fait jamais renaître une discussion +//! supprimée. Le lot part dans un [`PersistSink`] ; [`StoreSink`] l'écrit +//! par l'API interne du Go ([`crate::store::Store`]), scellé par lui. +//! +//! Écarts assumés avec le Go : +//! - une seule base : le chemin (`path`, `withDBAt`) disparaît des instantanés ; +//! - pas de transaction bbolt à travers l'API interne : [`StoreSink`] écrit les +//! résultats, puis chaque contenu, puis l'index. Un échec au milieu laisse +//! des contenus à jour sous un index en retard — le prochain instantané le +//! rattrape ([`touch_meta_in`] ajoute une entrée absente). Tant que la +//! conversation est tenue par Rust, le Go ne touche plus à l'index ; +//! - images en base64 → références (`refImagesInMessages`) : à faire par +//! l'appelant avant [`take_snapshot`], qui ne touche pas au disque ; +//! - relecture : un champ mal typé est ignoré seul (comme `json.Unmarshal`) ; +//! un message ou un événement mal typé est écarté entier (le Go le garderait +//! à moitié rempli). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use futures_util::FutureExt; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize, Serializer}; +use serde_json::Value; +use tokio::sync::watch; + +use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update}; +use super::journal::compact_log; +use super::types::{LogEvent, Message}; +use crate::store::{CryptError, Store}; + +// ---- clés en base ---------------------------------------------------------- + +/// Bucket des discussions (`bkChat`). +pub const BK_CHAT: &str = "chat"; +/// Bucket des résultats d'outils complets (`bkToolRes`). +pub const BK_TOOL_RES: &str = "toolres"; +/// Liste des discussions (`ckIndex`). +pub const CK_INDEX: &str = "index"; +/// Discussion ouverte (`ckActive`). +pub const CK_ACTIVE: &str = "active"; +/// Fil unique d'avant le multi-discussions (`ckLegacy`). +pub const CK_LEGACY: &str = "conversation"; +/// Projet d'une discussion qui n'en a pas (`defaultProjectSlug`). +pub const DEFAULT_PROJECT_SLUG: &str = "generale"; + +/// Délai d'attente de l'écrivain avant une sortie (`persistExitWait`). +pub const PERSIST_EXIT_WAIT: Duration = Duration::from_secs(6); + +/// Longueur d'un titre déduit, en caractères (`convSummary`). +const TITLE_RUNES: usize = 60; + +/// `convKey` : clé du contenu d'une discussion. +pub fn conv_key(id: &str) -> String { + format!("conv:{id}") +} + +/// `newConvID` : horodatage en nanosecondes — monotone, lisible au tri. +pub fn new_conv_id() -> String { + let ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("c{ns}") +} + +/// `toolResConv` : identifiant de discussion d'une clé « . ». +pub fn tool_res_conv(key: &str) -> &str { + match key.rfind('.') { + Some(i) if i > 0 => &key[..i], + _ => key, + } +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +// ---- format : état d'une discussion ---------------------------------------- + +/// Bloc projet figé d'une discussion (`projSnapshot`, PROJ_SNAPSHOT). Porté +/// tel quel : la façade ne le calcule pas encore, elle le garde intact. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjSnapshot { + #[serde(default)] + pub at: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub msgs: Vec, + #[serde(default)] + pub state: ProjState, + #[serde(default)] + pub key: String, + #[serde(default)] + pub sys_hash: String, + #[serde(default, skip_serializing_if = "is_zero")] + pub snap_tok: i64, + #[serde(default, skip_serializing_if = "is_zero")] + pub delta_tok: i64, +} + +/// Dernier état du projet annoncé au modèle (`projState`). Les tables sont +/// triées par clé, comme une `map` Go marshalée. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjState { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub day: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub env: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub code: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub desc: String, + #[serde(default, skip_serializing_if = "is_false")] + pub mem_on: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mem_prose: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub mem: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub trk: BTreeMap, +} + +fn is_zero(v: &i64) -> bool { + *v == 0 +} +fn is_false(b: &bool) -> bool { + !*b +} + +/// Ce qui est persisté d'une `Conversation` : la vue du modèle, le journal +/// d'affichage, le compteur de séquence, le contexte mesuré et le bloc projet. +/// Le reste (génération en cours, epoch, file d'attente…) vit en mémoire. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ConvState { + pub messages: Vec, + pub log: Vec, + pub seq: i64, + pub ctx_used: i64, + pub proj_snap: Option, +} + +/// Forme écrite, empruntée : l'instantané en plein tour écrit un journal +/// compacté sans toucher à celui que suivent les abonnés. +#[derive(Serialize)] +struct StateOut<'a> { + #[serde(serialize_with = "nil_if_empty")] + messages: &'a [Message], + #[serde(serialize_with = "nil_if_empty")] + log: &'a [LogEvent], + seq: i64, + ctx_used: i64, + #[serde(skip_serializing_if = "Option::is_none")] + proj_snap: Option<&'a ProjSnapshot>, +} + +/// Une tranche Go vide (nil) s'écrit `null` : même octets que le Go. +fn nil_if_empty(v: &&[T], s: S) -> Result { + if v.is_empty() { + s.serialize_none() + } else { + v.serialize(s) + } +} + +impl ConvState { + fn out<'a>(&'a self, log: &'a [LogEvent]) -> StateOut<'a> { + StateOut { + messages: &self.messages, + log, + seq: self.seq, + ctx_used: self.ctx_used, + proj_snap: self.proj_snap.as_ref(), + } + } + + /// JSON de la discussion, tel que `json.Marshal(conv)`. + pub fn to_json(&self) -> Vec { + serde_json::to_vec(&self.out(&self.log)).unwrap_or_default() + } +} + +impl Serialize for ConvState { + fn serialize(&self, s: S) -> Result { + self.out(&self.log).serialize(s) + } +} + +/// Relit une discussion persistée — `json.Unmarshal` dans une `Conversation` +/// remise à zéro (`loadFrom`), puis la guérison `stripImageParts`. Contenu +/// vide ou illisible → fil neuf ; un champ mal typé est ignoré seul. +pub fn decode_state(b: &[u8]) -> ConvState { + let mut st = ConvState::default(); + let Ok(Value::Object(mut obj)) = serde_json::from_slice::(b) else { + return st; + }; + if let Some(v) = obj.remove("messages") { + st.messages = decode_list(v); + } + if let Some(v) = obj.remove("log") { + st.log = decode_list(v); + } + if let Some(n) = obj + .remove("seq") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.seq = n; + } + if let Some(n) = obj + .remove("ctx_used") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.ctx_used = n; + } + if let Some(p) = obj + .remove("proj_snap") + .and_then(|v| serde_json::from_value(v).ok()) + { + st.proj_snap = p; + } + strip_image_parts(&mut st.messages); + st +} + +/// Tableau JSON → éléments décodables ; `null` ou autre chose → vide. +fn decode_list(v: Value) -> Vec { + match v { + Value::Array(items) => items + .into_iter() + .filter_map(|x| serde_json::from_value(x).ok()) + .collect(), + _ => Vec::new(), + } +} + +/// `stripImageParts` : un message multimodal persisté (base64 d'une capture +/// d'avant les captures éphémères) est aplati en son texte suivi de +/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte. +pub fn strip_image_parts(msgs: &mut [Message]) { + for m in msgs.iter_mut() { + let Some(Value::Array(parts)) = &m.content else { + continue; + }; + let mut texts: Vec<&str> = Vec::new(); + let mut dropped = false; + for p in parts.iter().filter_map(Value::as_object) { + if p.get("type").and_then(Value::as_str) == Some("image_url") { + dropped = true; + continue; + } + if let Some(Value::String(t)) = p.get("text") { + texts.push(t); + } + } + if dropped { + let text = texts.join("\n") + IMAGE_LOST_MARKER; + m.content = Some(Value::String(text)); + } + } +} + +/// Un instantané complet d'une discussion, prêt à écrire (`convSnap`). +#[derive(Debug, Clone, PartialEq)] +pub struct ConvSnap { + /// Discussion à laquelle appartient le contenu. + pub id: String, + /// Ordre de prise : le plus grand l'emporte toujours. + pub seq: u64, + /// JSON de la discussion. + pub body: Vec, + /// Titre déduit du premier message ([`conv_summary`]). + pub title: String, + /// Échanges : événements `user` du journal. + pub turns: i64, + /// Projet d'une entrée d'index à créer. + pub project: String, +} + +/// `snapshot` : instantané de `state` sous la discussion `id`. En plein tour +/// (`generating`), le journal porte encore un événement par jeton : on écrit +/// la forme qu'aura le journal en fin de tour (`compactLog`). `seq` vient de +/// [`Persister::next_seq`], pris sous le même verrou que l'état. +pub fn take_snapshot( + state: &ConvState, + generating: bool, + id: &str, + seq: u64, + project: &str, +) -> ConvSnap { + let body = if generating { + let compacted = compact_log(&state.log); + serde_json::to_vec(&state.out(&compacted)).unwrap_or_default() + } else { + state.to_json() + }; + let turns = state + .log + .iter() + .filter(|ev| ev.delta.contains_key("user")) + .count() as i64; + ConvSnap { + id: id.to_string(), + seq, + body, + title: conv_summary(&state.messages), + turns, + project: project.to_string(), + } +} + +// ---- index des discussions ------------------------------------------------- + +/// Une discussion SANS ses messages : ce que liste l'UI (`convMeta`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ConvMeta { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub created: i64, + #[serde(default)] + pub updated: i64, + #[serde(default)] + pub turns: i64, + /// Slug du projet ; vide sur les discussions d'avant les projets. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub project: String, +} + +/// `convProjectOf` : projet d'une discussion, l'absence valant le projet par +/// défaut — une discussion sans projet ne doit jamais devenir invisible. +pub fn conv_project_of(m: &ConvMeta) -> &str { + match m.project.trim() { + "" => DEFAULT_PROJECT_SLUG, + s => s, + } +} + +/// Index relu (`convIndex`) : JSON déjà ouvert ; illisible → vide. Plus +/// récemment modifiée en tête, tri stable. +pub fn decode_index(b: &[u8]) -> Vec { + let mut idx: Vec = serde_json::from_slice::>>(b) + .ok() + .flatten() + .unwrap_or_default(); + sort_index(&mut idx); + idx +} + +/// Plus récemment modifiée en tête (tri stable, comme `sort.SliceStable`). +pub fn sort_index(idx: &mut [ConvMeta]) { + idx.sort_by_key(|m| std::cmp::Reverse(m.updated)); +} + +/// JSON de l'index, tel que `json.Marshal([]convMeta)` (vide → `null`). +pub fn encode_index(idx: &[ConvMeta]) -> Vec { + if idx.is_empty() { + return b"null".to_vec(); + } + serde_json::to_vec(idx).unwrap_or_default() +} + +/// `convIndexForProject` : les discussions d'un projet. +pub fn index_for_project<'a>(idx: &'a [ConvMeta], slug: &str) -> Vec<&'a ConvMeta> { + idx.iter().filter(|m| conv_project_of(m) == slug).collect() +} + +/// `tagOrphanConversations` : rattache au projet les discussions qui n'en ont +/// pas. Rend vrai si l'index a changé (à réécrire). +pub fn tag_orphans(idx: &mut [ConvMeta], slug: &str) -> bool { + let mut changed = false; + for m in idx.iter_mut().filter(|m| m.project.trim().is_empty()) { + m.project = slug.to_string(); + changed = true; + } + changed +} + +/// Refus d'une opération sur l'index (messages du Go). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IndexError { + /// « identifiant ou titre manquant » + Missing, + /// « discussion introuvable » + NotFound, +} + +impl std::fmt::Display for IndexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + IndexError::Missing => "identifiant ou titre manquant", + IndexError::NotFound => "discussion introuvable", + }) + } +} + +/// `convRename`, sur l'index : titre choisi par l'utilisateur (rogné). +pub fn rename_in(idx: &mut [ConvMeta], id: &str, title: &str) -> Result<(), IndexError> { + let title = title.trim(); + if id.is_empty() || title.is_empty() { + return Err(IndexError::Missing); + } + let m = idx + .iter_mut() + .find(|m| m.id == id) + .ok_or(IndexError::NotFound)?; + m.title = title.to_string(); + Ok(()) +} + +/// Retire une discussion de l'index (`convDelete`) ; faux si absente. +pub fn remove_from(idx: &mut Vec, id: &str) -> bool { + let n = idx.len(); + idx.retain(|m| m.id != id); + idx.len() != n +} + +/// `convTouchMetaIn` : rafraîchit l'entrée d'un instantané (date, échanges, +/// titre déduit si l'utilisateur n'en a pas choisi). Une discussion absente +/// est ajoutée — c'est ce qui recolle une discussion créée mémoire +/// verrouillée. +pub fn touch_meta_in(idx: &mut Vec, s: &ConvSnap, now: i64) { + if let Some(m) = idx.iter_mut().find(|m| m.id == s.id) { + m.updated = now; + m.turns = s.turns; + if m.title.is_empty() { + m.title = s.title.clone(); + } + return; + } + idx.push(ConvMeta { + id: s.id.clone(), + title: s.title.clone(), + created: now, + updated: now, + turns: s.turns, + project: s.project.clone(), + }); +} + +/// `convSummary` : le premier message de l'utilisateur fait le titre (rappels +/// de loki et bloc exclus), 60 caractères au plus. Vide sans +/// message : l'UI affiche « Nouvelle discussion ». +pub fn conv_summary(msgs: &[Message]) -> String { + for m in msgs { + if m.role != "user" || is_loki_injected(m) { + continue; + } + let stripped = without_ctx_update(m); + let m = stripped.as_ref().unwrap_or(m); + let Some(Value::String(s)) = &m.content else { + continue; + }; + let s = s.replace('\n', " "); + let s = s.trim(); + if s.is_empty() { + continue; + } + if s.chars().count() > TITLE_RUNES { + let head: String = s.chars().take(TITLE_RUNES).collect(); + return format!("{}…", head.trim()); + } + return s.to_string(); + } + String::new() +} + +// ---- écrivain unique ------------------------------------------------------- + +/// Un résultat d'outil complet à ranger dans [`BK_TOOL_RES`] (`toolResJob`). +#[derive(Debug, Clone)] +pub struct ToolResJob { + /// « . ». + pub key: String, + pub plain: Arc, +} + +/// Bilan d'un lot : instantanés écrits (discussion, seq) et première erreur. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommitOutcome { + pub wrote: Vec<(String, u64)>, + pub err: Option, +} + +/// Écrit un lot (`persistCommit`). Les résultats d'outils d'abord, chacun +/// sauté si [`ResGate::wanted`] dit qu'une suppression l'a annulé. +pub trait PersistSink: Send + Sync + 'static { + fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> impl Future + Send; +} + +#[derive(Default)] +struct PState { + /// Dernier instantané en attente, par discussion. + snaps: HashMap, + /// Résultats d'outils en attente, dans l'ordre. + res: Vec, + /// Résultats pas encore écrits : « voir plus » les sert d'ici. + mem: HashMap>, + /// Seq du dernier instantané écrit, par discussion. + written: HashMap, + /// Discussions supprimées : plus rien ne doit les réécrire. + deleted: HashSet, + queued: u64, + done: u64, + running: bool, + /// Résultats du lot en cours d'écriture, et ceux qu'une suppression a + /// annulés depuis. + inflight: HashSet, + cancelled: HashSet, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, PState> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Ce que l'écrivain consulte DANS son lot (`toolResWanted`). +#[derive(Clone)] +pub struct ResGate(Arc>); + +impl ResGate { + /// Ce résultat du lot en cours doit-il encore être écrit ? + pub fn wanted(&self, key: &str) -> bool { + !lock(&self.0).cancelled.contains(key) + } +} + +struct Shared { + sink: S, + st: Arc>, + done: watch::Sender, + seq: AtomicU64, +} + +/// L'écrivain unique (`convPersister`). Un seul lot à la fois : l'ordre des +/// instantanés ne dépend que de leur seq. Les méthodes qui confient un +/// travail démarrent l'écrivain sur le runtime tokio courant. +pub struct Persister { + sh: Arc>, +} + +impl Clone for Persister { + fn clone(&self) -> Self { + Persister { + sh: self.sh.clone(), + } + } +} + +impl Persister { + pub fn new(sink: S) -> Self { + Persister { + sh: Arc::new(Shared { + sink, + st: Arc::new(Mutex::new(PState::default())), + done: watch::Sender::new(0), + seq: AtomicU64::new(0), + }), + } + } + + pub fn sink(&self) -> &S { + &self.sh.sink + } + + /// Numéro du prochain instantané (`persistSeq`). À prendre sous le verrou + /// de l'état, pour que l'ordre des numéros soit celui des états. + pub fn next_seq(&self) -> u64 { + self.sh.seq.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Confie un instantané et rend son ticket. Un instantané en attente pour + /// la même discussion est remplacé par le plus récent. + pub fn enqueue_snap(&self, s: ConvSnap) -> u64 { + let mut st = lock(&self.sh.st); + if !st.deleted.contains(&s.id) { + let newer = st.snaps.get(&s.id).is_none_or(|cur| s.seq > cur.seq); + if newer { + st.snaps.insert(s.id.clone(), s); + } + } + self.kick(&mut st) + } + + /// Confie un résultat d'outil ; il est servi depuis la mémoire tant qu'il + /// n'est pas sur disque. + pub fn enqueue_tool_res(&self, j: ToolResJob) -> u64 { + let mut st = lock(&self.sh.st); + if !st.deleted.contains(tool_res_conv(&j.key)) { + st.mem.insert(j.key.clone(), j.plain.clone()); + st.res.push(j); + } + self.kick(&mut st) + } + + fn kick(&self, st: &mut PState) -> u64 { + st.queued += 1; + if !st.running { + st.running = true; + tokio::spawn(run(self.sh.clone())); + } + st.queued + } + + /// Attend que le ticket (et tous ceux d'avant) soit traité. + pub async fn wait(&self, ticket: u64) { + let mut rx = self.sh.done.subscribe(); + let _ = rx.wait_for(|d| *d >= ticket).await; + } + + /// Attend que tout ce qui a été confié jusqu'ici soit traité. + pub async fn flush(&self) { + let t = lock(&self.sh.st).queued; + self.wait(t).await; + } + + /// `flushPersister` : [`Self::flush`] borné, pour ne jamais retenir un + /// arrêt sur une base bloquée. Faux si le délai a expiré. + pub async fn flush_timeout(&self, d: Duration) -> bool { + tokio::time::timeout(d, self.flush()).await.is_ok() + } + + /// Prend l'instantané et ATTEND son écriture (`persist`) : fin de tour, + /// reset, bascules. Tout ce qui était en attente avant est écrit aussi. + pub async fn persist(&self, s: ConvSnap) { + let t = self.enqueue_snap(s); + self.wait(t).await; + } + + /// Marque une discussion supprimée : ses instantanés et résultats en + /// attente sont jetés, et plus rien ne la réécrira. L'appelant attend + /// ensuite l'écriture en vol ([`Self::flush`]) avant d'effacer en base. + pub fn forget(&self, id: &str) { + let mut st = lock(&self.sh.st); + st.deleted.insert(id.to_string()); + st.snaps.remove(id); + drop_tool_res_locked(&mut st, &format!("{id}.")); + } + + /// Oublie les résultats en attente dont la clé commence par `prefix` + /// (`dropToolRes`). Ceux du lot en vol sont annulés : l'écrivain les + /// saute, ou les a déjà écrits — l'appelant fait alors [`Self::flush`] + /// avant d'effacer, et rien ne renaît. + pub fn drop_tool_res(&self, prefix: &str) { + drop_tool_res_locked(&mut lock(&self.sh.st), prefix); + } + + /// Relit un résultat pas encore sur disque (`toolResPending`). + pub fn tool_res_pending(&self, key: &str) -> Option> { + lock(&self.sh.st).mem.get(key).cloned() + } +} + +fn drop_tool_res_locked(st: &mut PState, prefix: &str) { + let hit: Vec = st + .inflight + .iter() + .filter(|k| k.starts_with(prefix)) + .cloned() + .collect(); + st.cancelled.extend(hit); + st.mem.retain(|k, _| !k.starts_with(prefix)); + st.res.retain(|j| !j.key.starts_with(prefix)); +} + +/// Vide la file par lots, jusqu'à ce qu'elle soit vide. +async fn run(sh: Arc>) { + loop { + let (upto, mut snaps, res) = { + let mut st = lock(&sh.st); + if st.snaps.is_empty() && st.res.is_empty() { + st.running = false; + st.done = st.queued; + sh.done.send_replace(st.done); + return; + } + let pending = std::mem::take(&mut st.snaps); + let snaps: Vec = pending + .into_values() + .filter(|s| { + !st.deleted.contains(&s.id) + && s.seq > st.written.get(&s.id).copied().unwrap_or(0) + }) + .collect(); + let queued = std::mem::take(&mut st.res); + let mut res = Vec::with_capacity(queued.len()); + for j in queued { + if !st.deleted.contains(tool_res_conv(&j.key)) { + st.inflight.insert(j.key.clone()); + res.push(j); + } + } + (st.queued, snaps, res) + }; + + snaps.sort_by_key(|s| s.seq); + let n_snaps = snaps.len(); + let jobs: Vec<(String, Arc)> = res + .iter() + .map(|j| (j.key.clone(), j.plain.clone())) + .collect(); + let gate = ResGate(sh.st.clone()); + // Un écrivain qui panique ne doit pas laisser la file bloquée : les + // attentes (fin de tour, reset) ne reviendraient jamais. + let out = AssertUnwindSafe(sh.sink.commit(snaps, res, gate)) + .catch_unwind() + .await + .unwrap_or_else(|_| CommitOutcome { + wrote: Vec::new(), + err: Some("écrivain en panique".into()), + }); + + let mut st = lock(&sh.st); + for (key, _) in &jobs { + st.inflight.remove(key); + st.cancelled.remove(key); + } + for (id, seq) in &out.wrote { + let w = st.written.entry(id.clone()).or_insert(0); + if *seq > *w { + *w = *seq; + } + } + match &out.err { + Some(e) => { + // Les résultats restent servis depuis la mémoire ; un + // instantané raté sera remplacé par le suivant. + eprintln!( + "[persist] écriture différée échouée ({} instantané(s), {} résultat(s) d'outil gardé(s) en mémoire) : {e}", + n_snaps.saturating_sub(out.wrote.len()), + jobs.len() + ); + } + None => { + for (key, plain) in &jobs { + if st.mem.get(key).is_some_and(|m| Arc::ptr_eq(m, plain)) { + st.mem.remove(key); + } + } + } + } + st.done = upto; + sh.done.send_replace(upto); + } +} + +// ---- écriture par l'API interne du Go -------------------------------------- + +/// Écrit les lots par l'API interne du Go : scellés par lui (la clé de la +/// mémoire ne sort jamais), rangés par lui dans bbolt. +#[derive(Clone)] +pub struct StoreSink { + pub store: Store, +} + +impl PersistSink for StoreSink { + fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> impl Future + Send { + commit_batch(&self.store, snaps, res, gate, now_unix()) + } +} + +/// `commitPersistBatch` à travers l'API interne. Mémoire verrouillée : rien +/// n'est écrit en clair — les instantanés sont sautés (le fil reste en RAM), +/// les résultats d'outils rendent une erreur (ils restent servis depuis la +/// mémoire). Index illisible (autre clé, abîmé) : les contenus sont écrits, +/// l'index n'est PAS touché — le réécrire depuis une lecture vide effacerait +/// les autres discussions de la liste. +pub async fn commit_batch( + store: &Store, + snaps: Vec, + res: Vec, + gate: ResGate, + now: i64, +) -> CommitOutcome { + let mut out = CommitOutcome::default(); + for j in res { + let sealed = match store.seal(j.plain.as_bytes().to_vec()).await { + Ok(b) => b, + Err(e) => { + out.err = Some(crypt_msg(e)); + return out; + } + }; + if !gate.wanted(&j.key) { + continue; // supprimé avec sa discussion pendant que le lot partait + } + if let Err(e) = store.put(BK_TOOL_RES, &j.key, sealed).await { + out.err = Some(e); + return out; + } + } + if snaps.is_empty() { + return out; + } + let mut idx: Option> = match store.get(BK_CHAT, CK_INDEX).await { + Ok(None) => Some(Vec::new()), + Ok(Some(raw)) if raw.is_empty() => Some(Vec::new()), + Ok(Some(raw)) => match store.open(raw).await { + Ok(dec) => serde_json::from_slice::>>(&dec) + .ok() + .map(Option::unwrap_or_default), + Err(CryptError::Locked) => return out, + Err(CryptError::Other(_)) => None, + }, + Err(e) => { + out.err = Some(e); + return out; + } + }; + for s in &snaps { + let sealed = match store.seal(s.body.clone()).await { + Ok(b) => b, + Err(CryptError::Locked) => return out, + Err(e) => { + out.err = Some(crypt_msg(e)); + return out; + } + }; + if let Err(e) = store.put(BK_CHAT, &conv_key(&s.id), sealed).await { + out.err = Some(e); + return out; + } + if let Some(idx) = idx.as_mut() { + touch_meta_in(idx, s, now); + } + out.wrote.push((s.id.clone(), s.seq)); + } + let Some(idx) = idx else { + return out; + }; + match store.seal(encode_index(&idx)).await { + Ok(sealed) => { + if let Err(e) = store.put(BK_CHAT, CK_INDEX, sealed).await { + out.err = Some(e); + } + } + Err(CryptError::Locked) => {} + Err(e) => out.err = Some(crypt_msg(e)), + } + out +} + +fn crypt_msg(e: CryptError) -> String { + match e { + CryptError::Locked => "mémoire verrouillée".into(), + CryptError::Other(s) => s, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex as StdMutex; + use tokio::sync::{Semaphore, mpsc}; + + fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } + } + + fn ev(seq: i64, delta: Value) -> LogEvent { + let Value::Object(d) = delta else { + panic!("delta objet attendu") + }; + LogEvent { + seq, + ts: 0, + delta: d, + } + } + + // ---- format ---- + + #[test] + fn fil_vide_ecrit_comme_le_go() { + assert_eq!( + String::from_utf8(ConvState::default().to_json()).unwrap(), + r#"{"messages":null,"log":null,"seq":0,"ctx_used":0}"# + ); + } + + #[test] + fn etat_aller_retour() { + let st = ConvState { + messages: vec![um("bonjour")], + log: vec![ev(1, json!({"user": "bonjour"}))], + seq: 1, + ctx_used: 42, + proj_snap: Some(ProjSnapshot { + at: 7, + key: "k".into(), + state: ProjState { + mem: BTreeMap::from([("b".into(), "2".into()), ("a".into(), "1".into())]), + ..Default::default() + }, + ..Default::default() + }), + }; + let b = st.to_json(); + assert_eq!( + String::from_utf8(b.clone()).unwrap(), + r#"{"messages":[{"role":"user","content":"bonjour"}],"log":[{"seq":1,"ts":0,"delta":{"user":"bonjour"}}],"seq":1,"ctx_used":42,"proj_snap":{"at":7,"state":{"mem":{"a":"1","b":"2"}},"key":"k","sys_hash":""}}"# + ); + assert_eq!(decode_state(&b), st); + } + + #[test] + fn relecture_tolerante() { + assert_eq!(decode_state(b""), ConvState::default()); + assert_eq!(decode_state(b"pas du json"), ConvState::default()); + assert_eq!(decode_state(b"[1,2]"), ConvState::default()); + // Un champ mal typé est ignoré seul ; null vaut vide. + let st = decode_state( + br#"{"messages":[{"role":"user","content":"a"},{"role":5}],"log":null,"seq":"x","ctx_used":12,"inconnu":1}"#, + ); + assert_eq!(st.messages, vec![um("a")]); + assert!(st.log.is_empty()); + assert_eq!(st.seq, 0); + assert_eq!(st.ctx_used, 12); + assert_eq!(st.proj_snap, None); + } + + #[test] + fn guerison_des_images_persistees() { + let b = json!({"messages": [ + {"role": "user", "content": "bonjour"}, + {"role": "user", "content": [ + {"type": "text", "text": "Voici la capture demandée."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}} + ]}, + {"role": "user", "content": [{"type": "text", "text": "sans image"}]} + ]}); + let st = decode_state(b.to_string().as_bytes()); + assert_eq!(st.messages[0].content, Some(json!("bonjour"))); + assert_eq!( + st.messages[1].content, + Some(json!(format!( + "Voici la capture demandée.{IMAGE_LOST_MARKER}" + ))) + ); + assert_eq!( + st.messages[2].content, + Some(json!([{"type": "text", "text": "sans image"}])) + ); + } + + #[test] + fn instantane_en_plein_tour_ecrit_le_journal_compacte() { + let st = ConvState { + messages: vec![um("titre de la discussion")], + log: vec![ + ev(1, json!({"user": "titre de la discussion"})), + ev(2, json!({"content": "Bon"})), + ev(3, json!({"content": "jour"})), + ], + seq: 3, + ..Default::default() + }; + let s = take_snapshot(&st, true, "c1", 9, "generale"); + assert_eq!((s.id.as_str(), s.seq, s.turns), ("c1", 9, 1)); + assert_eq!(s.title, "titre de la discussion"); + assert_eq!(s.project, "generale"); + let back = decode_state(&s.body); + assert_eq!(back.log, compact_log(&st.log)); + assert_eq!(back.log.len(), 2); + // Le journal en mémoire n'a pas bougé ; hors tour, il est écrit tel quel. + assert_eq!(st.log.len(), 3); + let s = take_snapshot(&st, false, "c1", 10, ""); + assert_eq!(decode_state(&s.body).log, st.log); + } + + // ---- index ---- + + #[test] + fn titre_deduit() { + assert_eq!(conv_summary(&[]), ""); + let mut nudge = um(super::super::compact::THINK_NUDGE_FIRST); + assert_eq!(conv_summary(&[nudge.clone(), um("bonjour")]), "bonjour"); + nudge.img_relay = true; + assert_eq!(conv_summary(&[nudge, um(" a\nb ")]), "a b"); + let assistant = Message { + role: "assistant".into(), + content: Some(json!("réponse")), + ..Default::default() + }; + let multi = Message { + role: "user".into(), + content: Some(json!([{"type": "text", "text": "image"}])), + ..Default::default() + }; + assert_eq!(conv_summary(&[assistant, multi, um(" "), um("ok")]), "ok"); + // 60 caractères, découpés sur les caractères et non les octets. + let long = "é".repeat(59) + " suite"; + assert_eq!(conv_summary(&[um(&long)]), "é".repeat(59) + "…"); + } + + #[test] + fn titre_sans_bloc_projet() { + let mut m = um(&format!( + "{}\n+ x\n{}\n\ncorrige le build", + super::super::compact::CTX_UPD_OPEN, + super::super::compact::CTX_UPD_CLOSE + )); + m.ctx_upd = true; + assert_eq!(conv_summary(&[m]), "corrige le build"); + } + + #[test] + fn entree_d_index_rafraichie_ou_ajoutee() { + let snap = ConvSnap { + id: "c1".into(), + seq: 1, + body: vec![], + title: "déduit".into(), + turns: 3, + project: "p".into(), + }; + let mut idx = vec![ConvMeta { + id: "c1".into(), + title: "choisi".into(), + created: 1, + updated: 1, + turns: 1, + project: String::new(), + }]; + touch_meta_in(&mut idx, &snap, 50); + assert_eq!( + idx[0], + ConvMeta { + id: "c1".into(), + title: "choisi".into(), + created: 1, + updated: 50, + turns: 3, + project: String::new() + } + ); + idx[0].title.clear(); + touch_meta_in(&mut idx, &snap, 60); + assert_eq!(idx[0].title, "déduit"); + let other = ConvSnap { + id: "c2".into(), + ..snap + }; + touch_meta_in(&mut idx, &other, 70); + assert_eq!( + idx[1], + ConvMeta { + id: "c2".into(), + title: "déduit".into(), + created: 70, + updated: 70, + turns: 3, + project: "p".into() + } + ); + } + + #[test] + fn index_tri_projets_et_operations() { + let raw = br#"[{"id":"a","title":"","created":1,"updated":5,"turns":0}, + {"id":"b","title":"B","created":1,"updated":9,"turns":2,"project":"x"}, + {"id":"c","title":"","created":1,"updated":5,"turns":0,"project":" "}]"#; + let mut idx = decode_index(raw); + let ids: Vec<&str> = idx.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["b", "a", "c"], "plus récente en tête, tri stable"); + assert_eq!(decode_index(b"null"), vec![]); + assert_eq!(decode_index(b"{"), vec![]); + + assert_eq!(conv_project_of(&idx[1]), DEFAULT_PROJECT_SLUG); + let generale: Vec<&str> = index_for_project(&idx, DEFAULT_PROJECT_SLUG) + .iter() + .map(|m| m.id.as_str()) + .collect(); + assert_eq!(generale, ["a", "c"]); + assert!(tag_orphans(&mut idx, "generale")); + assert!(!tag_orphans(&mut idx, "generale")); + assert_eq!(idx[2].project, "generale"); + + assert_eq!(rename_in(&mut idx, "a", " Nom "), Ok(())); + assert_eq!(idx[1].title, "Nom"); + assert_eq!(rename_in(&mut idx, "a", " "), Err(IndexError::Missing)); + assert_eq!(rename_in(&mut idx, "zz", "t"), Err(IndexError::NotFound)); + assert!(remove_from(&mut idx, "b")); + assert!(!remove_from(&mut idx, "b")); + assert_eq!( + String::from_utf8(encode_index(&idx[..1])).unwrap(), + r#"[{"id":"a","title":"Nom","created":1,"updated":5,"turns":0,"project":"generale"}]"# + ); + assert_eq!(encode_index(&[]), b"null"); + } + + #[test] + fn cles() { + assert_eq!(conv_key("c1"), "conv:c1"); + assert_eq!(tool_res_conv("c123.ab12"), "c123"); + assert_eq!(tool_res_conv("c1.2.x"), "c1.2"); + assert_eq!(tool_res_conv("nosession"), "nosession"); + assert_eq!(tool_res_conv(".x"), ".x"); + let id = new_conv_id(); + assert!( + id.starts_with('c') && id[1..].parse::().is_ok(), + "{id}" + ); + } + + // ---- écrivain ---- + + /// Faux écrivain : enregistre ce qu'il écrit ; retenu au besoin (chaque + /// lot s'annonce sur `entered` puis attend un jeton de `release`). + #[derive(Default)] + struct FakeSink { + gate: Option<(mpsc::UnboundedSender>, Arc)>, + seqs: StdMutex>, + disk: StdMutex>>, + res: StdMutex>, + fail: StdMutex, + delay: Option, + } + + impl PersistSink for Arc { + fn commit( + &self, + snaps: Vec, + res: Vec, + gate: ResGate, + ) -> impl Future + Send { + let me = self.clone(); + async move { + if let Some((entered, release)) = &me.gate { + let _ = entered.send(snaps.iter().map(|s| s.seq).collect()); + release.acquire().await.unwrap().forget(); + } + if let Some(d) = me.delay { + tokio::time::sleep(d).await; + } + if *me.fail.lock().unwrap() { + return CommitOutcome { + wrote: vec![], + err: Some("disque plein".into()), + }; + } + for j in res { + if gate.wanted(&j.key) { + me.res.lock().unwrap().insert(j.key, j.plain.to_string()); + } + } + let mut out = CommitOutcome::default(); + for s in snaps { + me.seqs.lock().unwrap().push(s.seq); + me.disk.lock().unwrap().insert(s.id.clone(), s.body); + out.wrote.push((s.id, s.seq)); + } + out + } + } + } + + fn gated() -> ( + Arc, + mpsc::UnboundedReceiver>, + Arc, + ) { + let (tx, rx) = mpsc::unbounded_channel(); + let sem = Arc::new(Semaphore::new(0)); + let sink = Arc::new(FakeSink { + gate: Some((tx, sem.clone())), + ..Default::default() + }); + (sink, rx, sem) + } + + async fn entered(rx: &mut mpsc::UnboundedReceiver>) -> Vec { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("aucun lot n'a atteint l'écriture") + .unwrap() + } + + fn snap(id: &str, seq: u64, body: &str) -> ConvSnap { + ConvSnap { + id: id.into(), + seq, + body: body.as_bytes().to_vec(), + title: String::new(), + turns: 0, + project: String::new(), + } + } + + fn job(key: &str, plain: &str) -> ToolResJob { + ToolResJob { + key: key.into(), + plain: Arc::from(plain), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn jamais_un_etat_plus_ancien() { + let sink = Arc::new(FakeSink { + delay: Some(Duration::from_millis(2)), + ..Default::default() + }); + let p = Persister::new(sink.clone()); + // L'état et son numéro sont pris sous le même verrou, comme sous c.mu. + let state = Arc::new(StdMutex::new(0u32)); + let mut tasks = Vec::new(); + for _ in 0..8 { + let (p, state) = (p.clone(), state.clone()); + tasks.push(tokio::spawn(async move { + for _ in 0..10 { + let s = { + let mut n = state.lock().unwrap(); + *n += 1; + snap("c1", p.next_seq(), &n.to_string()) + }; + p.enqueue_snap(s); + tokio::task::yield_now().await; + } + })); + } + for t in tasks { + t.await.unwrap(); + } + p.flush().await; + let seqs = sink.seqs.lock().unwrap().clone(); + assert!( + seqs.windows(2).all(|w| w[1] > w[0]), + "un état plus ancien a écrasé un plus récent : {seqs:?}" + ); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"80"); + } + + #[tokio::test] + async fn persist_attend_l_ecriture_en_vol() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "avant")); + assert_eq!(entered(&mut rx).await, vec![1]); + let p2 = p.clone(); + let done = tokio::spawn(async move { p2.persist(snap("c1", 2, "vide")).await }); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !done.is_finished(), + "revenu avant la fin de l'écriture en vol" + ); + release.add_permits(100); + tokio::time::timeout(Duration::from_secs(5), done) + .await + .expect("persist bloqué") + .unwrap(); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"vide"); + } + + #[tokio::test] + async fn instantane_en_attente_remplace_et_ancien_ignore() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "un")); + entered(&mut rx).await; + p.enqueue_snap(snap("c1", 3, "trois")); + p.enqueue_snap(snap("c1", 2, "deux")); // plus ancien que l'attente : écarté + release.add_permits(100); + assert_eq!(entered(&mut rx).await, vec![3]); + p.flush().await; + // Déjà écrit plus récent : un instantané plus ancien ne part pas. + p.enqueue_snap(snap("c1", 2, "deux")); + p.flush().await; + assert_eq!(*sink.seqs.lock().unwrap(), vec![1, 3]); + assert_eq!(sink.disk.lock().unwrap()["c1"], b"trois"); + } + + #[tokio::test] + async fn resultat_servi_avant_l_ecriture() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + let long = "sortie\n".repeat(1000); + p.enqueue_tool_res(job("c1.a", &long)); + entered(&mut rx).await; + assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some(long.as_str())); + release.add_permits(100); + p.flush().await; + assert!( + p.tool_res_pending("c1.a").is_none(), + "copie en mémoire restée" + ); + assert_eq!(sink.res.lock().unwrap()["c1.a"], long); + } + + #[tokio::test] + async fn resultat_supprime_avant_puis_pendant_l_ecriture() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_tool_res(job("c1.a", "a")); // retient l'écrivain + entered(&mut rx).await; + p.enqueue_tool_res(job("c2.b", "b")); // en attente derrière + p.drop_tool_res("c2."); + p.drop_tool_res("c1."); // déjà pris par le lot en vol : annulé + assert!(p.tool_res_pending("c1.a").is_none()); + release.add_permits(100); + p.flush().await; + assert!( + sink.res.lock().unwrap().is_empty(), + "un résultat supprimé a été écrit" + ); + // L'annulation ne vaut que pour ce lot. + p.enqueue_tool_res(job("c1.c", "c")); + p.flush().await; + assert!(sink.res.lock().unwrap().contains_key("c1.c")); + } + + #[tokio::test] + async fn suppression_ne_ressuscite_rien() { + let (sink, mut rx, release) = gated(); + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("b", 1, "b")); + entered(&mut rx).await; // un lot est en vol + p.enqueue_snap(snap("a", 2, "a")); + p.enqueue_tool_res(job("a.x", "x")); + p.forget("a"); + p.enqueue_snap(snap("a", 3, "a encore")); // après la suppression : ignoré + p.enqueue_tool_res(job("a.y", "y")); + release.add_permits(100); + p.flush().await; + assert!(!sink.disk.lock().unwrap().contains_key("a")); + assert!(sink.res.lock().unwrap().is_empty()); + assert!(p.tool_res_pending("a.y").is_none()); + assert_eq!(sink.disk.lock().unwrap()["b"], b"b"); + } + + #[tokio::test] + async fn echec_d_ecriture_garde_les_resultats_en_memoire() { + let sink = Arc::new(FakeSink::default()); + *sink.fail.lock().unwrap() = true; + let p = Persister::new(sink.clone()); + p.enqueue_snap(snap("c1", 1, "un")); + p.enqueue_tool_res(job("c1.a", "a")); + p.flush().await; + assert_eq!(p.tool_res_pending("c1.a").as_deref(), Some("a")); + // Le même instantané repart au suivant (rien n'a été marqué écrit). + *sink.fail.lock().unwrap() = false; + p.enqueue_snap(snap("c1", 1, "un")); + p.flush().await; + assert_eq!(sink.disk.lock().unwrap()["c1"], b"un"); + } + + #[tokio::test] + async fn ecrivain_en_panique_ne_bloque_pas_la_file() { + struct Panique; + impl PersistSink for Panique { + async fn commit( + &self, + _: Vec, + _: Vec, + _: ResGate, + ) -> CommitOutcome { + panic!("boum") + } + } + let p = Persister::new(Panique); + p.enqueue_snap(snap("c1", 1, "un")); + assert!( + p.flush_timeout(Duration::from_secs(5)).await, + "file bloquée" + ); + } +} diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 6bf0bb1..ec66183 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -43,3 +43,105 @@ async fn outil_execute_par_le_go() { assert_eq!(v["label"], "ls"); assert_eq!(v["caps_agent"], true); } + +// ---- persistance des discussions par l'API interne ---- + +use loki_rs::chat::persist::{ + BK_CHAT, BK_TOOL_RES, CK_INDEX, ConvSnap, ConvState, Persister, StoreSink, ToolResJob, + decode_index, decode_state, +}; +use std::sync::Arc; + +fn snap(id: &str, seq: u64, title: &str, body: Vec) -> ConvSnap { + ConvSnap { + id: id.into(), + seq, + body, + title: title.into(), + turns: 1, + project: "generale".into(), + } +} + +/// Valeur brute en base, sans le préfixe du faux scellement. +fn opened(go: &fakego::FakeGo, bucket: &str, key: &str) -> Option> { + let raw = go.store.lock().unwrap().get(bucket)?.get(key)?.clone(); + assert!(raw.starts_with(b"SCELLE:"), "{bucket}/{key} écrit en clair"); + Some(raw[b"SCELLE:".len()..].to_vec()) +} + +#[tokio::test] +async fn discussion_scellee_contenu_et_index() { + let (st, go, _stop, _t) = store().await; + let p = Persister::new(StoreSink { store: st }); + let body = ConvState { + seq: 3, + ..Default::default() + } + .to_json(); + p.enqueue_tool_res(ToolResJob { + key: "c1.ab".into(), + plain: Arc::from("sortie complète"), + }); + p.persist(snap("c1", p.next_seq(), "premier titre", body.clone())) + .await; + + assert_eq!( + opened(&go, BK_TOOL_RES, "c1.ab").unwrap(), + "sortie complète".as_bytes() + ); + assert!(p.tool_res_pending("c1.ab").is_none()); + let content = opened(&go, BK_CHAT, "conv:c1").unwrap(); + assert_eq!(content, body); + assert_eq!(decode_state(&content).seq, 3); + let idx = decode_index(&opened(&go, BK_CHAT, CK_INDEX).unwrap()); + assert_eq!(idx.len(), 1); + assert_eq!( + (idx[0].id.as_str(), idx[0].title.as_str(), idx[0].turns), + ("c1", "premier titre", 1) + ); + assert_eq!(idx[0].project, "generale"); + + // Une seconde discussion s'ajoute à l'index sans effacer la première. + p.persist(snap("c2", p.next_seq(), "autre", b"{}".to_vec())) + .await; + let idx = decode_index(&opened(&go, BK_CHAT, CK_INDEX).unwrap()); + let mut ids: Vec<&str> = idx.iter().map(|m| m.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, ["c1", "c2"]); +} + +#[tokio::test] +async fn memoire_verrouillee_rien_en_clair() { + let (st, go, _stop, _t) = store().await; + let p = Persister::new(StoreSink { store: st }); + *go.locked.lock().unwrap() = true; + p.enqueue_tool_res(ToolResJob { + key: "c1.ab".into(), + plain: Arc::from("secret"), + }); + p.persist(snap("c1", p.next_seq(), "t", b"{}".to_vec())) + .await; + assert!( + go.store.lock().unwrap().is_empty(), + "écrit mémoire verrouillée" + ); + // Le résultat reste servi depuis la mémoire ; l'instantané repart au + // déverrouillage. + assert_eq!(p.tool_res_pending("c1.ab").as_deref(), Some("secret")); + *go.locked.lock().unwrap() = false; + p.persist(snap("c1", p.next_seq(), "t", b"{}".to_vec())) + .await; + assert!(opened(&go, BK_CHAT, "conv:c1").is_some()); +} + +#[tokio::test] +async fn index_illisible_jamais_reecrit() { + let (st, go, _stop, _t) = store().await; + go.set(BK_CHAT, CK_INDEX, "SCELLE:pas du json"); + let p = Persister::new(StoreSink { store: st }); + p.persist(snap("c1", p.next_seq(), "t", b"{}".to_vec())) + .await; + assert_eq!(opened(&go, BK_CHAT, "conv:c1").unwrap(), b"{}"); + assert_eq!(go.get(BK_CHAT, CK_INDEX).unwrap(), "SCELLE:pas du json"); +} From 27ce303d7fc2567fded4db3a170d42e378c889dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:59:16 +0000 Subject: [PATCH 02/24] API interne du bail du moteur pour la conversation tenue par Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le verrou de génération (conv.Generating) est partagé par le chat, les tâches planifiées, le bench et l'optimiseur. Une conversation tenue par la façade doit prendre ce verrou-là : sinon une tâche partirait vers le même moteur en plein tour. /internal/v1/engine/lease prend le verrou (409 avec la raison du refus et la tâche en cours), /renew le prolonge et remonte un stop passé par le Go, /release le rend (seulement s'il le tient encore), /engine/stop arrête un détenteur Go. Sans renouvellement le bail expire : une façade tuée en plein tour ne garde pas le moteur. Reset et les bascules de discussion rompent le bail comme ils libèrent un tour Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/chat_conversation.go | 7 +- internal/loki/web_internal.go | 8 + internal/loki/web_internal_lease.go | 196 +++++++++++++++++++++++ internal/loki/web_internal_lease_test.go | 153 ++++++++++++++++++ 4 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 internal/loki/web_internal_lease.go create mode 100644 internal/loki/web_internal_lease_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 4929e55..70faff8 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -88,6 +88,8 @@ type Conversation struct { // errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le // même verrou (backend_tune_job.go). leaseWhy error + // extLease : le verrou est tenu par la façade Rust (web_internal_lease.go). + extLease *extLeaseState // File d'attente des messages envoyés PENDANT une génération (AJEAN // 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière @@ -200,6 +202,7 @@ func LoadConversation() { // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre. conv.Generating = false conv.cancel = nil + conv.extLeaseClearLocked() } // loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil @@ -235,6 +238,7 @@ func (c *Conversation) loadFrom(id string, b []byte) { if !c.benching { c.Generating = false c.cancel = nil + c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go } c.epoch++ c.cond.Broadcast() @@ -1199,7 +1203,8 @@ func (c *Conversation) Reset() { c.benching = false // Stop a annulé le bench : le verrou est rendu ici c.leaseWhy = nil c.cancel = nil - c.queued = nil // les messages en attente visaient le fil qu'on vient de vider + c.extLeaseClearLocked() // Reset débloque aussi un tour de la façade + c.queued = nil // les messages en attente visaient le fil qu'on vient de vider c.cond.Broadcast() c.mu.Unlock() c.persist() diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 3391d6b..1a7a869 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -75,6 +75,14 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalCrypt(w, r, true) case "/internal/v1/crypt/open": handleInternalCrypt(w, r, false) + case "/internal/v1/engine/lease": + handleInternalLease(w, r) + case "/internal/v1/engine/lease/renew": + handleInternalLeaseRenew(w, r) + case "/internal/v1/engine/lease/release": + handleInternalLeaseRelease(w, r) + case "/internal/v1/engine/stop": + handleInternalEngineStop(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_lease.go b/internal/loki/web_internal_lease.go new file mode 100644 index 0000000..ee1eb1c --- /dev/null +++ b/internal/loki/web_internal_lease.go @@ -0,0 +1,196 @@ +package loki + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// API interne du bail du moteur (lot 6 de la migration Rust). +// +// Le verrou de génération (conv.Generating) est partagé : tour de chat, tâche +// planifiée (RunAutonomous), bench et optimiseur (measureLease), et quatre +// sondes isGenerating (optimiseur, VRAM, projets, liste des discussions). Une +// conversation tenue par la façade Rust doit prendre CE verrou-là, sans quoi +// une tâche planifiée partirait en plein tour vers le même moteur. +// +// POST /internal/v1/engine/lease {"ttl_ms":N} → 200 {"lease":id,"ttl_ms":N} +// 409 {"error":…,"task_id":…,"task_name":…} +// POST /internal/v1/engine/lease/renew {"lease":id} → 200 {"stop":bool} · 410 bail perdu +// POST /internal/v1/engine/lease/release {"lease":id} → 204 (idempotent) +// POST /internal/v1/engine/stop → 204 : arrête le détenteur Go +// (tâche, bench, optimiseur) +// +// Le bail expire s'il n'est pas renouvelé : une façade tuée en plein tour ne +// laisse pas le moteur réservé pour toujours. Reset et les bascules de +// discussion côté Go le rompent (renew → 410), comme ils libèrent un tour Go. +// « stop » : le bouton stop est passé par le Go (conv.Stop) — la façade arrête +// son tour. + +const ( + extLeaseDefaultTTL = 30 * time.Second + extLeaseMinTTL = 5 * time.Second + extLeaseMaxTTL = 5 * time.Minute + internalLeaseMax = 4 << 10 +) + +// extLeaseState : le bail tenu par la façade. Protégé par conv.mu. +type extLeaseState struct { + id string + stop bool + ttl time.Duration + timer *time.Timer +} + +// extLeaseClearLocked oublie le bail de la façade. c.mu détenu. +func (c *Conversation) extLeaseClearLocked() { + if c.extLease != nil { + c.extLease.timer.Stop() + c.extLease = nil + } +} + +// extLeaseAcquire prend le verrou de génération pour la façade. +func (c *Conversation) extLeaseAcquire(ttl time.Duration) (string, error) { + if err := tuneGuard(); err != nil { + return "", err + } + c.mu.Lock() + defer c.mu.Unlock() + if c.Generating { + return "", ErrBusy + } + b, err := randBytes(16) + if err != nil { + return "", err + } + st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl} + st.timer = time.AfterFunc(ttl, func() { c.extLeaseExpire(st) }) + c.Generating = true + c.cancel = func() { + c.mu.Lock() + st.stop = true + c.mu.Unlock() + } + c.extLease = st + return st.id, nil +} + +// extLeaseExpire rend le verrou d'un bail que la façade n'a pas renouvelé. +func (c *Conversation) extLeaseExpire(st *extLeaseState) { + c.mu.Lock() + defer c.mu.Unlock() + if c.extLease != st { + return + } + c.extLease = nil + c.Generating, c.cancel = false, nil + fmt.Fprintf(os.Stderr, "[lease] bail de la façade expiré sans renouvellement (%s) : verrou du moteur rendu\n", st.ttl) +} + +// extLeaseRenew prolonge le bail ; ok=false s'il n'est plus le bail courant. +func (c *Conversation) extLeaseRenew(id string) (stop, ok bool) { + c.mu.Lock() + defer c.mu.Unlock() + st := c.extLease + if st == nil || st.id != id { + return false, false + } + st.timer.Reset(st.ttl) + return st.stop, true +} + +// extLeaseRelease rend le verrou, si ce bail le tient encore. +func (c *Conversation) extLeaseRelease(id string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.extLease == nil || c.extLease.id != id { + return + } + c.extLeaseClearLocked() + c.Generating, c.cancel = false, nil +} + +func readLeaseBody(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return false + } + raw, err := io.ReadAll(io.LimitReader(r.Body, internalLeaseMax+1)) + if err != nil || len(raw) > internalLeaseMax { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "corps illisible"}) + return false + } + if len(raw) > 0 && json.Unmarshal(raw, v) != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "JSON illisible"}) + return false + } + return true +} + +func handleInternalLease(w http.ResponseWriter, r *http.Request) { + var in struct { + TTLms int64 `json:"ttl_ms"` + } + if !readLeaseBody(w, r, &in) { + return + } + ttl := extLeaseDefaultTTL + if in.TTLms > 0 { + ttl = min(max(time.Duration(in.TTLms)*time.Millisecond, extLeaseMinTTL), extLeaseMaxTTL) + } + id, err := conv.extLeaseAcquire(ttl) + if err != nil { + if err == ErrBusy { + err = conv.busyReason() // dit QUI occupe le moteur + } + out := map[string]any{"error": err.Error()} + conv.mu.Lock() + if conv.runningTaskID != "" { + out["task_id"], out["task_name"] = conv.runningTaskID, conv.runningTaskName + } + conv.mu.Unlock() + sendJSON(w, http.StatusConflict, out) + return + } + sendJSON(w, http.StatusOK, map[string]any{"lease": id, "ttl_ms": ttl.Milliseconds()}) +} + +func handleInternalLeaseRenew(w http.ResponseWriter, r *http.Request) { + var in struct { + Lease string `json:"lease"` + } + if !readLeaseBody(w, r, &in) { + return + } + stop, ok := conv.extLeaseRenew(in.Lease) + if !ok { + sendJSON(w, http.StatusGone, map[string]any{"error": "bail perdu"}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"stop": stop}) +} + +func handleInternalLeaseRelease(w http.ResponseWriter, r *http.Request) { + var in struct { + Lease string `json:"lease"` + } + if !readLeaseBody(w, r, &in) { + return + } + conv.extLeaseRelease(in.Lease) + w.WriteHeader(http.StatusNoContent) +} + +func handleInternalEngineStop(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readLeaseBody(w, r, &in) { + return + } + conv.Stop() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/web_internal_lease_test.go b/internal/loki/web_internal_lease_test.go new file mode 100644 index 0000000..1d0c442 --- /dev/null +++ b/internal/loki/web_internal_lease_test.go @@ -0,0 +1,153 @@ +package loki + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func leaseJSON(t *testing.T, body string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("réponse illisible %q : %v", body, err) + } + return m +} + +// libérerVerrou rend le verrou de génération quoi qu'un test ait laissé. +func libérerVerrou(t *testing.T) { + t.Cleanup(func() { + conv.mu.Lock() + conv.extLeaseClearLocked() + conv.Generating, conv.benching, conv.cancel, conv.leaseWhy = false, false, nil, nil + conv.mu.Unlock() + }) +} + +// Le bail de la façade EST le verrou de génération : tant qu'il tient, une +// mesure (bench, optimiseur) ou un second bail est refusé. +func TestBailInterneVerrouPartage(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + libérerVerrou(t) + + w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", `{"ttl_ms":60000}`) + if w.Code != 200 { + t.Fatalf("bail : %d %s", w.Code, w.Body.String()) + } + m := leaseJSON(t, w.Body.String()) + id, _ := m["lease"].(string) + if len(id) != 32 || m["ttl_ms"] != float64(60000) { + t.Fatalf("bail : %v", m) + } + if !conv.isGenerating() { + t.Fatal("le bail ne tient pas le verrou de génération") + } + if _, err := conv.measureLease(func() {}, errBenchBusy); err != ErrBusy { + t.Fatalf("bench accepté pendant le bail : %v", err) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", ""); w.Code != 409 || + !strings.Contains(w.Body.String(), ErrBusy.Error()) { + t.Fatalf("second bail : %d %s", w.Code, w.Body.String()) + } + + // Renouvelé ; le stop passé par le Go remonte à la façade. + w = appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`) + if w.Code != 200 || leaseJSON(t, w.Body.String())["stop"] != false { + t.Fatalf("renouvellement : %d %s", w.Code, w.Body.String()) + } + conv.Stop() + w = appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`) + if w.Code != 200 || leaseJSON(t, w.Body.String())["stop"] != true { + t.Fatalf("stop non transmis : %d %s", w.Code, w.Body.String()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"autre"}`); w.Code != 410 { + t.Fatalf("bail inconnu renouvelé : %d", w.Code) + } + + // Rendu : le verrou est libre, le bail est mort, rendre deux fois est sans effet. + for i := 0; i < 2; i++ { + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); w.Code != 204 { + t.Fatalf("libération : %d", w.Code) + } + } + if conv.isGenerating() { + t.Fatal("verrou toujours tenu après la libération") + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease/renew", `{"lease":"`+id+`"}`); w.Code != 410 { + t.Fatalf("bail rendu renouvelé : %d", w.Code) + } +} + +// Un bail rendu après coup ne libère pas le verrou d'un autre détenteur. +func TestBailInterneNeLibereQueLeSien(t *testing.T) { + testHome(t) + libérerVerrou(t) + id, err := conv.extLeaseAcquire(time.Minute) + if err != nil { + t.Fatal(err) + } + conv.Reset() // rompt le bail, comme un tour Go + if _, ok := conv.extLeaseRenew(id); ok { + t.Fatal("bail encore valide après Reset") + } + release, err := conv.measureLease(func() {}, errBenchBusy) + if err != nil { + t.Fatalf("verrou non rendu par Reset : %v", err) + } + defer release() + conv.extLeaseRelease(id) + if !conv.isGenerating() { + t.Fatal("un bail périmé a libéré le verrou du bench") + } +} + +// Une façade tuée en plein tour ne garde pas le moteur : sans renouvellement, +// le bail expire et rend le verrou. +func TestBailInterneExpire(t *testing.T) { + testHome(t) + libérerVerrou(t) + id, err := conv.extLeaseAcquire(50 * time.Millisecond) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for conv.isGenerating() { + if time.Now().After(deadline) { + t.Fatal("bail jamais expiré") + } + time.Sleep(10 * time.Millisecond) + } + if _, ok := conv.extLeaseRenew(id); ok { + t.Fatal("bail expiré encore renouvelable") + } +} + +// Moteur tenu par le Go : le refus dit qui le tient, et /engine/stop l'arrête. +func TestBailInterneRefusEtStop(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + libérerVerrou(t) + ctx, cancel := context.WithCancel(context.Background()) + release, err := conv.measureLease(cancel, errBenchBusy) + if err != nil { + t.Fatal(err) + } + defer release() + w := appelOAIInterne(t, "POST", "/internal/v1/engine/lease", "") + if w.Code != 409 || leaseJSON(t, w.Body.String())["error"] != errBenchBusy.Error() { + t.Fatalf("refus : %d %s", w.Code, w.Body.String()) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/engine/stop", ""); w.Code != 204 { + t.Fatalf("stop : %d", w.Code) + } + if ctx.Err() == nil { + t.Fatal("le bench n'a pas été arrêté") + } + if w := appelOAIInterne(t, "GET", "/internal/v1/engine/stop", ""); w.Code != 405 { + t.Fatalf("GET accepté : %d", w.Code) + } +} From 091902039dde912f0af69da4f0ca5e9656984416 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:59:16 +0000 Subject: [PATCH 03/24] =?UTF-8?q?Lot=206=20:=20bail=20du=20moteur=20c?= =?UTF-8?q?=C3=B4t=C3=A9=20Rust=20(chat::lease)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EngineLease prend le verrou de génération du Go, le renouvelle au tiers de sa durée en tâche de fond et signale au tour un stop passé par le Go ou la perte du bail (Reset, bascule, expiration). Rendu par release, ou en tâche de fond s'il est lâché. engine_stop arrête un détenteur Go (tâche, bench, optimiseur). Store::internal_post garde le statut HTTP pour distinguer les refus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/chat/lease.rs | 207 ++++++++++++++++++++++++++++ rust/loki-rs/src/chat/mod.rs | 4 +- rust/loki-rs/src/store.rs | 18 +++ rust/loki-rs/tests/common/fakego.rs | 53 +++++++ rust/loki-rs/tests/lot6.rs | 96 +++++++++++++ 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 rust/loki-rs/src/chat/lease.rs diff --git a/rust/loki-rs/src/chat/lease.rs b/rust/loki-rs/src/chat/lease.rs new file mode 100644 index 0000000..0920aec --- /dev/null +++ b/rust/loki-rs/src/chat/lease.rs @@ -0,0 +1,207 @@ +//! Bail du moteur — le verrou de génération du Go (`conv.Generating`), pris +//! par une conversation tenue par Rust (`web_internal_lease.go`). +//! +//! Le verrou est partagé avec les tâches planifiées, le bench et l'optimiseur, +//! qui restent au Go : un tour Rust doit le tenir du début à la fin, sinon une +//! tâche partirait vers le même moteur en plein tour. +//! +//! [`EngineLease::acquire`] prend le bail et le renouvelle en tâche de fond +//! (au tiers de sa durée). Le Go le rompt (Reset, bascule) ou y signale un +//! stop : [`EngineLease::interrupted`] le dit au tour. Une façade tuée ne +//! renouvelle plus : le Go rend le verrou à l'expiration. [`EngineLease::release`] +//! le rend tout de suite ; un bail lâché sans `release` est rendu en tâche de +//! fond. + +use std::time::Duration; + +use hyper::StatusCode; +use serde_json::{Value, json}; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +use crate::store::Store; + +/// Durée d'un bail par défaut (`extLeaseDefaultTTL`). +pub const LEASE_TTL: Duration = Duration::from_secs(30); + +const PATH_LEASE: &str = "/internal/v1/engine/lease"; +const PATH_RENEW: &str = "/internal/v1/engine/lease/renew"; +const PATH_RELEASE: &str = "/internal/v1/engine/lease/release"; +const PATH_STOP: &str = "/internal/v1/engine/stop"; + +/// Refus de prendre le bail. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeaseError { + /// Le moteur est tenu (tour, tâche planifiée, bench, optimiseur). `error` + /// est le message du Go (`busyReason`) ; la tâche est nommée s'il s'agit + /// d'une tâche planifiée. + Busy { + error: String, + task_id: Option, + task_name: Option, + }, + /// Go injoignable ou réponse inattendue. + Other(String), +} + +impl std::fmt::Display for LeaseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LeaseError::Busy { error, .. } => f.write_str(error), + LeaseError::Other(e) => write!(f, "bail du moteur : {e}"), + } + } +} + +/// État du bail vu du tour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseSignal { + Held, + /// Stop demandé côté Go : le tour s'arrête, le bail tient jusqu'à `release`. + Stop, + /// Bail rompu (Reset ou bascule côté Go, ou expiré) : le verrou n'est plus + /// à nous. + Lost, +} + +/// Le bail tenu ; rendu par [`Self::release`] ou à la destruction. +pub struct EngineLease { + id: String, + store: Store, + signal: watch::Receiver, + renew: JoinHandle<()>, + released: bool, +} + +impl EngineLease { + /// Prend le verrou de génération pour `ttl` (borné par le Go à 5 s – 5 min). + pub async fn acquire(store: Store, ttl: Duration) -> Result { + let body = json!({ "ttl_ms": ttl.as_millis() as u64 }); + let (status, v) = store + .internal_post(PATH_LEASE, &body) + .await + .map_err(LeaseError::Other)?; + match status { + StatusCode::OK => {} + StatusCode::CONFLICT => { + let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string); + return Err(LeaseError::Busy { + error: s("error").unwrap_or_else(|| "génération en cours".into()), + task_id: s("task_id"), + task_name: s("task_name"), + }); + } + s => return Err(LeaseError::Other(format!("{PATH_LEASE} : {s} {v}"))), + } + let id = v + .get("lease") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LeaseError::Other(format!("{PATH_LEASE} : réponse sans bail {v}")))? + .to_string(); + let granted = v + .get("ttl_ms") + .and_then(Value::as_u64) + .map(Duration::from_millis) + .unwrap_or(ttl); + let (tx, rx) = watch::channel(LeaseSignal::Held); + let renew = tokio::spawn(renew_loop(store.clone(), id.clone(), granted / 3, tx)); + Ok(EngineLease { + id, + store, + signal: rx, + renew, + released: false, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + /// État courant (dernier renouvellement). + pub fn signal(&self) -> LeaseSignal { + *self.signal.borrow() + } + + /// Se résout au premier stop ou à la perte du bail, et rend lequel. + pub async fn interrupted(&mut self) -> LeaseSignal { + match self.signal.wait_for(|s| *s != LeaseSignal::Held).await { + Ok(s) => *s, + // Tâche de renouvellement terminée sans signal : rien à attendre. + Err(_) => std::future::pending().await, + } + } + + /// Rend le verrou maintenant. + pub async fn release(mut self) { + self.released = true; + self.renew.abort(); + release(&self.store, &self.id).await; + } +} + +impl Drop for EngineLease { + fn drop(&mut self) { + self.renew.abort(); + if self.released { + return; + } + // Lâché sans release (tour annulé, panique) : rendu en tâche de fond. + // Hors runtime, l'expiration côté Go s'en charge. + if let Ok(rt) = tokio::runtime::Handle::try_current() { + let (store, id) = (self.store.clone(), std::mem::take(&mut self.id)); + rt.spawn(async move { release(&store, &id).await }); + } + } +} + +async fn release(store: &Store, id: &str) { + if let Err(e) = store + .internal_post(PATH_RELEASE, &json!({ "lease": id })) + .await + { + eprintln!("[lease] libération du bail échouée (le Go le rendra à l'expiration) : {e}"); + } +} + +/// Renouvelle jusqu'à la perte du bail. Une erreur réseau n'arrête pas la +/// boucle : le prochain essai peut passer avant l'expiration. +async fn renew_loop(store: Store, id: String, every: Duration, tx: watch::Sender) { + let mut tick = tokio::time::interval(every.max(Duration::from_millis(100))); + tick.tick().await; // le premier top est immédiat : le bail vient d'être pris + loop { + tick.tick().await; + match store + .internal_post(PATH_RENEW, &json!({ "lease": id })) + .await + { + Ok((StatusCode::OK, v)) => { + if v.get("stop").and_then(Value::as_bool) == Some(true) { + tx.send_if_modified(|s| { + let was = *s; + if was == LeaseSignal::Held { + *s = LeaseSignal::Stop; + } + was == LeaseSignal::Held + }); + } + } + Ok((StatusCode::GONE, _)) => { + tx.send_replace(LeaseSignal::Lost); + return; + } + Ok((s, v)) => eprintln!("[lease] renouvellement : {s} {v}"), + Err(e) => eprintln!("[lease] renouvellement : {e}"), + } + } +} + +/// Arrête le détenteur Go du moteur (tâche planifiée, bench, optimiseur) : +/// le bouton stop du chat, quand ce n'est pas un tour Rust qui tourne. +pub async fn engine_stop(store: &Store) -> Result<(), String> { + match store.internal_post(PATH_STOP, &json!({})).await? { + (StatusCode::NO_CONTENT, _) => Ok(()), + (s, v) => Err(format!("{PATH_STOP} : {s} {v}")), + } +} diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index e7af56a..3dc6b37 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -8,11 +8,13 @@ //! d'outils, statistiques) ; //! - [`compact`] : estimation du contexte et compactage de l'historique ; //! - [`journal`] : journal d'affichage (compactage, rejeu, pagination) ; -//! - [`persist`] : format en base, index des discussions, écrivain unique. +//! - [`persist`] : format en base, index des discussions, écrivain unique ; +//! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go). pub mod compact; pub mod engine; pub mod journal; +pub mod lease; pub mod persist; pub mod stream; pub mod types; diff --git a/rust/loki-rs/src/store.rs b/rust/loki-rs/src/store.rs index 7cdd6f5..3697c0d 100644 --- a/rust/loki-rs/src/store.rs +++ b/rust/loki-rs/src/store.rs @@ -86,6 +86,24 @@ impl Store { serde_json::from_slice(&bytes).map_err(|e| e.to_string()) } + /// POST JSON à l'API interne, statut compris (refus métier à distinguer : + /// 409, 410…). Corps vide ou illisible → `Value::Null`. + pub async fn internal_post( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result<(StatusCode, serde_json::Value), String> { + let (status, bytes) = self + .call( + Method::POST, + path, + serde_json::to_vec(body).unwrap_or_default(), + ) + .await?; + let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + Ok((status, v)) + } + /// Scelle un blob avec la clé de la mémoire (gardée par le Go). Clair /// rendu tel quel si le chiffrement est coupé. pub async fn seal(&self, plain: Vec) -> Result, CryptError> { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index b9c3b87..9876e97 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -32,6 +32,11 @@ pub struct FakeGo { pub oai_log: Mutex>, /// Mémoire verrouillée : seal/open répondent 423. pub locked: Mutex, + /// Bail du moteur : bail courant, stop signalé, détenteur Go, journal. + pub lease: Mutex>, + pub lease_stop: Mutex, + pub go_busy: Mutex>, + pub lease_log: Mutex>, /// Adresse d'écoute du faux Go (pour un client `Store` direct). pub backend: Mutex>, } @@ -118,6 +123,54 @@ async fn handle(st: Arc, req: Request) -> Response resp(200, body.strip_prefix(b"SCELLE:").unwrap_or(&body).to_vec()) } } + "/internal/v1/engine/lease" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + if let Some(why) = st.go_busy.lock().unwrap().clone() { + let out = serde_json::json!({"error": why, "task_id": "t1", "task_name": "veille"}); + return resp(409, out.to_string()); + } + let mut lease = st.lease.lock().unwrap(); + if lease.is_some() { + return resp(409, r#"{"error":"génération en cours"}"#); + } + let n = st.lease_log.lock().unwrap().len(); + let id = format!("bail{n}"); + *lease = Some(id.clone()); + st.lease_log + .lock() + .unwrap() + .push(format!("acquire {}", v["ttl_ms"])); + resp( + 200, + serde_json::json!({"lease": id, "ttl_ms": v["ttl_ms"]}).to_string(), + ) + } + "/internal/v1/engine/lease/renew" | "/internal/v1/engine/lease/release" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let id = v["lease"].as_str().unwrap_or_default().to_string(); + let mut lease = st.lease.lock().unwrap(); + let held = lease.as_deref() == Some(id.as_str()); + if path.ends_with("release") { + st.lease_log.lock().unwrap().push(format!("release {id}")); + if held { + *lease = None; + } + return resp(204, ""); + } + st.lease_log.lock().unwrap().push(format!("renew {id}")); + if !held { + return resp(410, r#"{"error":"bail perdu"}"#); + } + let stop = *st.lease_stop.lock().unwrap(); + resp(200, serde_json::json!({"stop": stop}).to_string()) + } + "/internal/v1/engine/stop" => { + st.lease_log.lock().unwrap().push("stop".into()); + *st.go_busy.lock().unwrap() = None; + resp(204, "") + } "/internal/v1/tool/exec" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index ec66183..e425f09 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -145,3 +145,99 @@ async fn index_illisible_jamais_reecrit() { assert_eq!(opened(&go, BK_CHAT, "conv:c1").unwrap(), b"{}"); assert_eq!(go.get(BK_CHAT, CK_INDEX).unwrap(), "SCELLE:pas du json"); } + +// ---- bail du moteur ---- + +use loki_rs::chat::lease::{EngineLease, LeaseError, LeaseSignal, engine_stop}; +use std::time::Duration; + +async fn until(mut f: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(5), async { + while !f() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("condition jamais atteinte"); +} + +#[tokio::test] +async fn bail_pris_renouvele_rendu() { + let (st, go, _stop, _t) = store().await; + // TTL de 300 ms : renouvelé toutes les 100 ms. + let lease = EngineLease::acquire(st.clone(), Duration::from_millis(300)) + .await + .unwrap(); + assert_eq!(lease.id(), "bail0"); + assert_eq!(go.lease_log.lock().unwrap()[0], "acquire 300"); + // Un second bail est refusé tant que le premier tient. + match EngineLease::acquire(st.clone(), Duration::from_secs(30)).await { + Err(LeaseError::Busy { error, .. }) => assert_eq!(error, "génération en cours"), + other => panic!("second bail : {:?}", other.map(|l| l.id().to_string())), + } + until(|| { + go.lease_log + .lock() + .unwrap() + .iter() + .any(|l| l == "renew bail0") + }) + .await; + assert_eq!(lease.signal(), LeaseSignal::Held); + lease.release().await; + assert_eq!(*go.lease.lock().unwrap(), None); + assert!( + go.lease_log + .lock() + .unwrap() + .contains(&"release bail0".to_string()) + ); +} + +#[tokio::test] +async fn bail_stop_puis_perdu() { + let (st, go, _stop, _t) = store().await; + let mut lease = EngineLease::acquire(st, Duration::from_millis(300)) + .await + .unwrap(); + *go.lease_stop.lock().unwrap() = true; + let got = tokio::time::timeout(Duration::from_secs(5), lease.interrupted()).await; + assert_eq!(got.unwrap(), LeaseSignal::Stop); + // Le Go rompt le bail (Reset) : perdu. + *go.lease.lock().unwrap() = None; + until(|| lease.signal() == LeaseSignal::Lost).await; + assert_eq!(lease.interrupted().await, LeaseSignal::Lost); +} + +#[tokio::test] +async fn bail_lache_sans_release_est_rendu() { + let (st, go, _stop, _t) = store().await; + let lease = EngineLease::acquire(st, Duration::from_secs(30)) + .await + .unwrap(); + drop(lease); + until(|| go.lease.lock().unwrap().is_none()).await; +} + +#[tokio::test] +async fn moteur_tenu_par_une_tache_go() { + let (st, go, _stop, _t) = store().await; + *go.go_busy.lock().unwrap() = Some("la tâche planifiée « veille » occupe le modèle".into()); + match EngineLease::acquire(st.clone(), Duration::from_secs(30)).await { + Err(LeaseError::Busy { + error, + task_id, + task_name, + }) => { + assert!(error.contains("veille")); + assert_eq!(task_id.as_deref(), Some("t1")); + assert_eq!(task_name.as_deref(), Some("veille")); + } + other => panic!("bail accordé : {:?}", other.map(|l| l.id().to_string())), + } + engine_stop(&st).await.unwrap(); + let lease = EngineLease::acquire(st, Duration::from_secs(30)) + .await + .unwrap(); + lease.release().await; +} From 74c2d5105cd7fbdc50adf7cd20efdb90be9ac084 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:05:33 +0000 Subject: [PATCH 04/24] =?UTF-8?q?Lot=206=20:=20la=20discussion=20en=20m?= =?UTF-8?q?=C3=A9moire=20port=C3=A9e=20en=20Rust=20(chat::conv)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de l'état de Conversation et de subscribeSink : état persisté et discussion chargée, epoch (reset, bascule), verrou du tour et son arrêt, ajout au journal avec rejet des deltas d'un tour périmé, coalescence de fin de tour, instantané numéroté sous le même verrou, /api/chat/state. Subscription rend les lots à émettre : rejeu (padding, reset si le curseur vient d'une autre discussion, pagination, caught_up, contexte), puis le direct, avec {reset} et l'en-tête paginé quand l'epoch change. Réveil par compteur de version marqué lu avant la relecture de l'état : aucun réveil perdu. Tests Go de l'abonnement portés. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/chat/conv.rs | 482 ++++++++++++++++++++++++++++++++++ rust/loki-rs/src/chat/mod.rs | 3 + 2 files changed, 485 insertions(+) create mode 100644 rust/loki-rs/src/chat/conv.rs diff --git a/rust/loki-rs/src/chat/conv.rs b/rust/loki-rs/src/chat/conv.rs new file mode 100644 index 0000000..5cf5c66 --- /dev/null +++ b/rust/loki-rs/src/chat/conv.rs @@ -0,0 +1,482 @@ +//! La discussion en mémoire — port de l'état de `Conversation` +//! (`chat_conversation.go`) et de l'abonnement `subscribeSink`. +//! +//! Une seule discussion ouverte, partagée par tous les appareils. Elle porte : +//! - l'état persisté ([`ConvState`] : vue du modèle, journal d'affichage, +//! Seq, contexte mesuré, bloc projet) et l'identifiant de la discussion +//! chargée ; +//! - l'epoch, incrémenté à chaque reset ou bascule : il invalide les abonnés +//! (qui reçoivent `{reset}` et rejouent) et les deltas d'un tour périmé ; +//! - le verrou du tour (`Generating`) et de quoi l'arrêter. +//! +//! Les abonnés ([`Subscription`]) lisent le journal : rejeu coalescé, puis +//! direct. Ils sont réveillés par un compteur de version (`watch`), marqué lu +//! AVANT de relire l'état : aucun réveil n'est perdu, contrairement à une +//! simple notification qui peut partir entre la lecture et l'attente. +//! +//! Hors de ce module : la boucle d'un tour, la file d'attente des messages +//! envoyés pendant un tour, les discussions multiples (index, bascules) et la +//! base — l'appelant charge et enregistre (voir [`super::persist`]). + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value}; +use tokio::sync::watch; + +use super::journal::{self, Delta, live_batch, live_reset_plan, replay_plan}; +use super::persist::{ConvSnap, ConvState, PersistSink, Persister, decode_state, take_snapshot}; + +/// Arrêt du tour en cours (`c.cancel`). +pub type CancelTurn = Box; + +/// Un tour est déjà en cours (`ErrBusy`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Busy; + +impl std::fmt::Display for Busy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("génération en cours") + } +} + +#[derive(Default)] +struct Inner { + st: ConvState, + /// Discussion chargée (`ckActive`) : celle qu'annoncent `{reset}` et + /// `{caught_up}`, et sous laquelle s'enregistrent les instantanés. + id: String, + generating: bool, + epoch: i64, + cancel: Option, +} + +/// La discussion ouverte. À partager dans un [`Arc`] (abonnés, tour). +pub struct Conversation { + inner: Mutex, + version: watch::Sender, +} + +impl Default for Conversation { + fn default() -> Self { + Conversation { + inner: Mutex::new(Inner::default()), + version: watch::Sender::new(0), + } + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +impl Conversation { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Réveille les abonnés (`cond.Broadcast`). + fn bump(&self) { + self.version.send_modify(|v| *v = v.wrapping_add(1)); + } + + /// Identifiant de la discussion chargée. + pub fn id(&self) -> String { + self.lock().id.clone() + } + + pub fn epoch(&self) -> i64 { + self.lock().epoch + } + + /// `isGenerating`. + pub fn is_generating(&self) -> bool { + self.lock().generating + } + + /// `loadFrom` : remplace l'état par la discussion `id` (`body` vide = fil + /// neuf), arrête le tour en cours et invalide les abonnés, qui vident + /// l'écran et rejouent le nouveau fil. + pub fn load_from(&self, id: &str, body: &[u8]) { + self.stop(); + let st = if body.is_empty() { + ConvState::default() + } else { + decode_state(body) + }; + { + let mut g = self.lock(); + g.st = st; + g.id = id.to_string(); + g.generating = false; + g.cancel = None; + g.epoch += 1; + } + self.bump(); + } + + /// `Reset` : fil vide pour tous les appareils, tour arrêté et verrou + /// rendu — même un tour coincé (moteur redémarré sous ses pieds) : c'est + /// le geste qui débloque. L'enregistrement et les fichiers de la + /// discussion restent à l'appelant. + pub fn reset(&self) { + self.stop(); + { + let mut g = self.lock(); + g.st = ConvState::default(); + g.generating = false; + g.cancel = None; + g.epoch += 1; + } + self.bump(); + } + + /// `Stop` : arrête le tour en cours (le verrou est rendu par sa fin). + pub fn stop(&self) { + let cancel = self.lock().cancel.take(); + if let Some(c) = cancel { + c(); + } + } + + /// Prend le verrou du tour ; rend l'epoch du tour, que ses deltas portent. + pub fn begin_turn(&self, cancel: CancelTurn) -> Result { + let mut g = self.lock(); + if g.generating { + return Err(Busy); + } + g.generating = true; + g.cancel = Some(cancel); + Ok(g.epoch) + } + + /// Fin d'un tour : le journal du tour est coalescé (`compactLogLocked`) + /// et le verrou rendu. Un tour dont l'epoch est périmé (reset, bascule) + /// ne touche à rien : le verrou a déjà été rendu, peut-être repris. + pub fn end_turn(&self, epoch: i64) -> bool { + { + let mut g = self.lock(); + if g.epoch != epoch { + return false; + } + let log = std::mem::take(&mut g.st.log); + g.st.log = journal::compact_log_owned(log); + g.generating = false; + g.cancel = None; + } + self.bump(); + true + } + + /// `appendDelta` : journalise un événement d'affichage et réveille les + /// abonnés. Un delta d'un epoch périmé appartient à l'ancien fil : jeté. + pub fn append_delta(&self, epoch: i64, delta: Delta) -> bool { + { + let mut g = self.lock(); + if g.epoch != epoch { + return false; + } + let Inner { st, .. } = &mut *g; + journal::append_delta(&mut st.log, &mut st.seq, now_ms(), delta); + } + self.bump(); + true + } + + /// Accès à l'état (vue du modèle, contexte…) sous le verrou, pour le tour. + /// Ne réveille personne : le journal se modifie par [`Self::append_delta`]. + pub fn with_state(&self, f: impl FnOnce(&mut ConvState) -> R) -> R { + f(&mut self.lock().st) + } + + /// Instantané à enregistrer, numéroté sous le même verrou que l'état. + pub fn snapshot(&self, p: &Persister, project: &str) -> ConvSnap { + let g = self.lock(); + take_snapshot(&g.st, g.generating, &g.id, p.next_seq(), project) + } + + /// `state` : instantané léger pour `/api/chat/state`. La tâche Go qui + /// tiendrait le moteur est ajoutée par l'appelant. + pub fn state_json(&self) -> Map { + let g = self.lock(); + let turns = + g.st.log + .iter() + .filter(|ev| ev.delta.contains_key("user")) + .count(); + let mut m = Map::new(); + m.insert("seq".into(), g.st.seq.into()); + m.insert("generating".into(), g.generating.into()); + m.insert("ctx_used".into(), g.st.ctx_used.into()); + m.insert("turns".into(), turns.into()); + m + } + + /// Abonnement au journal. `from` : dernier Seq vu par le client ; `tail` : + /// échanges à rejouer pour un client paginé (`< 0` : tout) ; `conv_id` : + /// discussion affichée par le client (vide : inconnue). + pub fn subscribe(self: &Arc, from: i64, tail: i64, conv_id: &str) -> Subscription { + Subscription { + conv: self.clone(), + rx: self.version.subscribe(), + from, + tail, + conv_id: conv_id.to_string(), + epoch: 0, + last: 0, + started: false, + } + } +} + +/// Un abonné au journal (`subscribeSink`). Chaque [`Self::next`] rend le lot +/// suivant à émettre, dans l'ordre : d'abord le rejeu (padding, reset éventuel, +/// pagination, rejeu coalescé, `caught_up`, contexte, padding), puis les +/// événements du direct dès qu'il y en a. Lâcher l'abonnement l'arrête. +pub struct Subscription { + conv: Arc, + rx: watch::Receiver, + from: i64, + tail: i64, + conv_id: String, + epoch: i64, + last: i64, + started: bool, +} + +impl Subscription { + /// Lot suivant ; attend s'il n'y a rien de neuf. Jamais vide. + pub async fn next(&mut self) -> Vec { + loop { + // Marqué lu AVANT de relire l'état : un ajout qui suit réveille. + self.rx.borrow_and_update(); + if let Some(events) = self.poll() { + return events; + } + if self.rx.changed().await.is_err() { + // Discussion détruite : plus rien ne viendra. + std::future::pending::<()>().await; + } + } + } + + fn poll(&mut self) -> Option> { + let g = self.conv.lock(); + if !self.started { + self.started = true; + // Le journal est copié sous le verrou ; le rejeu se bâtit hors. + let snapshot = g.st.log.clone(); + let (id, ctx_used) = (g.id.clone(), g.st.ctx_used); + self.epoch = g.epoch; + drop(g); + let plan = replay_plan( + &snapshot, + self.from, + self.tail, + &self.conv_id, + &id, + ctx_used, + ); + self.last = plan.last; + return Some(plan.events); + } + if g.epoch != self.epoch { + self.epoch = g.epoch; + let plan = live_reset_plan(&g.st.log, self.tail, &g.id, g.st.ctx_used); + self.last = plan.last; + return Some(plan.events); + } + let plan = live_batch(&g.st.log, self.last); + if plan.events.is_empty() { + return None; + } + self.last = plan.last; + Some(plan.events) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::persist::{CommitOutcome, ResGate, ToolResJob}; + use serde_json::json; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + fn d(v: Value) -> Delta { + let Value::Object(m) = v else { + panic!("objet attendu") + }; + m + } + + async fn next(sub: &mut Subscription) -> Vec { + tokio::time::timeout(Duration::from_secs(5), sub.next()) + .await + .expect("aucun lot") + } + + fn seqs(evs: &[Delta]) -> Vec { + evs.iter() + .filter_map(|e| e.get("seq").and_then(Value::as_i64)) + .collect() + } + + #[tokio::test] + async fn rejeu_puis_direct() { + let c = Conversation::new(); + c.append_delta(0, d(json!({"user": "salut"}))); + c.append_delta(0, d(json!({"content": "bon"}))); + let mut sub = c.subscribe(0, -1, ""); + let first = next(&mut sub).await; + assert_eq!(seqs(&first), vec![1, 2]); + assert!( + first + .iter() + .any(|e| e.get("caught_up") == Some(&json!(true))) + ); + c.append_delta(0, d(json!({"content": "jour"}))); + assert_eq!(seqs(&next(&mut sub).await), vec![3]); + } + + #[tokio::test] + async fn abonne_depuis_un_seq() { + let c = Conversation::new(); + c.append_delta(0, d(json!({"user": "a"}))); + c.append_delta(0, d(json!({"user": "b"}))); + let mut sub = c.subscribe(1, -1, ""); + assert_eq!(seqs(&next(&mut sub).await), vec![2]); + } + + #[tokio::test] + async fn curseur_d_une_autre_discussion_rejoue_tout() { + let c = Conversation::new(); + c.load_from("c1", b""); + let e = c.epoch(); + c.append_delta(e, d(json!({"user": "premier message"}))); + c.append_delta(e, d(json!({"content": "réponse"}))); + let mut sub = c.subscribe(9999, -1, "c0"); + let evs = next(&mut sub).await; + assert_eq!(evs[1], d(json!({"reset": true, "id": "c1"}))); + assert!( + evs.iter() + .any(|e| e.get("user") == Some(&json!("premier message"))) + ); + } + + #[tokio::test] + async fn reset_prevenu_et_deltas_perimes_jetes() { + let c = Conversation::new(); + let e0 = c.epoch(); + c.append_delta(e0, d(json!({"user": "x"}))); + let mut sub = c.subscribe(0, -1, ""); + next(&mut sub).await; + c.reset(); + let evs = next(&mut sub).await; + assert_eq!(evs[0].get("reset"), Some(&json!(true))); + assert!(!c.append_delta(e0, d(json!({"content": "fantôme"})))); + assert_eq!(c.state_json()["seq"], 0); + assert!(c.append_delta(c.epoch(), d(json!({"user": "nouveau"})))); + assert_eq!(seqs(&next(&mut sub).await), vec![1]); + } + + #[tokio::test] + async fn bascule_charge_le_fil_et_previent() { + let c = Conversation::new(); + c.load_from("a", b""); + c.append_delta(c.epoch(), d(json!({"user": "dans A"}))); + let mut sub = c.subscribe(0, 20, "a"); + next(&mut sub).await; + let body = ConvState { + log: vec![crate::chat::types::LogEvent { + seq: 1, + ts: 5, + delta: d(json!({"user": "dans B"})), + }], + seq: 1, + ctx_used: 77, + ..Default::default() + } + .to_json(); + c.load_from("b", &body); + let evs = next(&mut sub).await; + assert_eq!(evs[0], d(json!({"reset": true, "id": "b"}))); + assert_eq!(c.id(), "b"); + assert_eq!(c.state_json()["ctx_used"], 77); + // Fil court : rien de masqué, le direct rejoue tout depuis 0. + assert_eq!(seqs(&next(&mut sub).await), vec![1]); + } + + #[tokio::test] + async fn verrou_du_tour() { + let c = Conversation::new(); + let stopped = Arc::new(AtomicBool::new(false)); + let s = stopped.clone(); + let e = c + .begin_turn(Box::new(move || s.store(true, Ordering::SeqCst))) + .unwrap(); + assert!(c.is_generating()); + assert_eq!(c.begin_turn(Box::new(|| {})), Err(Busy)); + c.append_delta(e, d(json!({"content": "Bon"}))); + c.append_delta(e, d(json!({"content": "jour"}))); + assert!(c.end_turn(e)); + assert!(!c.is_generating()); + // Fin de tour : journal coalescé. + c.with_state(|st| { + assert_eq!(st.log.len(), 1); + assert_eq!(st.log[0].delta["content"], "Bonjour"); + }); + // Reset pendant un tour : arrêté, verrou rendu, fin périmée sans effet. + let s = stopped.clone(); + let e = c + .begin_turn(Box::new(move || s.store(true, Ordering::SeqCst))) + .unwrap(); + c.reset(); + assert!(stopped.load(Ordering::SeqCst)); + assert!(!c.is_generating()); + let e2 = c.begin_turn(Box::new(|| {})).unwrap(); + assert!(!c.end_turn(e), "fin d'un tour périmé acceptée"); + assert!(c.is_generating()); + assert!(c.end_turn(e2)); + } + + #[tokio::test] + async fn etat_et_instantane() { + struct Rien; + impl PersistSink for Rien { + async fn commit( + &self, + _: Vec, + _: Vec, + _: ResGate, + ) -> CommitOutcome { + CommitOutcome::default() + } + } + let p = Persister::new(Rien); + let c = Conversation::new(); + c.load_from("c9", b""); + let e = c.begin_turn(Box::new(|| {})).unwrap(); + c.append_delta(e, d(json!({"user": "q"}))); + c.append_delta(e, d(json!({"content": "a"}))); + c.append_delta(e, d(json!({"content": "b"}))); + c.with_state(|st| st.ctx_used = 12); + assert_eq!( + Value::Object(c.state_json()), + json!({"seq": 3, "generating": true, "ctx_used": 12, "turns": 1}) + ); + let s1 = c.snapshot(&p, "generale"); + let s2 = c.snapshot(&p, "generale"); + assert_eq!((s1.id.as_str(), s1.turns), ("c9", 1)); + assert!(s2.seq > s1.seq); + // En plein tour, l'instantané porte le journal compacté. + assert_eq!(decode_state(&s1.body).log.len(), 2); + } +} diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 3dc6b37..27b4823 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -8,10 +8,13 @@ //! d'outils, statistiques) ; //! - [`compact`] : estimation du contexte et compactage de l'historique ; //! - [`journal`] : journal d'affichage (compactage, rejeu, pagination) ; +//! - [`conv`] : la discussion en mémoire (état, epoch, verrou du tour, +//! abonnés) ; //! - [`persist`] : format en base, index des discussions, écrivain unique ; //! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go). pub mod compact; +pub mod conv; pub mod engine; pub mod journal; pub mod lease; From a73b20ea044283ebcb2cac02e409283e87908a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:09:00 +0000 Subject: [PATCH 05/24] =?UTF-8?q?Lot=206=20:=20plusieurs=20discussions=20c?= =?UTF-8?q?=C3=B4t=C3=A9=20Rust=20(chat::sessions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port des opérations de chat_sessions.go et de LoadConversation : discussion active créée au besoin (reprise du fil unique d'avant les discussions, clé d'origine gardée), chargement au démarrage qui réessaie et n'écrit rien sur une base illisible, liste du projet actif, création, bascule (la courante enregistrée sous SON identifiant), renommage, suppression (l'écriture en vol ne la fait pas renaître ; bascule sur la plus récente restante ou une neuve : toujours une discussion active). Les opérations sont sérialisées ; l'index partage son verrou avec l'écrivain (StoreSink::index_lock, convIndexMu en Go). Fichiers, travaux, critères, mode, résultats d'outils d'une discussion supprimée et préchauffage après bascule passent par SessionHooks. Store gagne get_open / put_seal (getStoreBytesErr / putStoreBytes). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/chat/mod.rs | 2 + rust/loki-rs/src/chat/persist.rs | 22 +- rust/loki-rs/src/chat/sessions.rs | 352 ++++++++++++++++++++++++++++++ rust/loki-rs/src/store.rs | 25 +++ rust/loki-rs/tests/lot6.rs | 180 ++++++++++++++- 5 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 rust/loki-rs/src/chat/sessions.rs diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 27b4823..fa5701f 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -11,6 +11,7 @@ //! - [`conv`] : la discussion en mémoire (état, epoch, verrou du tour, //! abonnés) ; //! - [`persist`] : format en base, index des discussions, écrivain unique ; +//! - [`sessions`] : plusieurs discussions (index, bascules, chargement) ; //! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go). pub mod compact; @@ -19,6 +20,7 @@ pub mod engine; pub mod journal; pub mod lease; pub mod persist; +pub mod sessions; pub mod stream; pub mod types; pub mod wire; diff --git a/rust/loki-rs/src/chat/persist.rs b/rust/loki-rs/src/chat/persist.rs index 233067c..81079eb 100644 --- a/rust/loki-rs/src/chat/persist.rs +++ b/rust/loki-rs/src/chat/persist.rs @@ -97,7 +97,7 @@ pub fn tool_res_conv(key: &str) -> &str { } } -fn now_unix() -> i64 { +pub(crate) fn now_unix() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) @@ -783,16 +783,30 @@ async fn run(sh: Arc>) { #[derive(Clone)] pub struct StoreSink { pub store: Store, + /// Sérialise les lectures-modifications-écritures de l'index + /// (`convIndexMu`) : celles de l'écrivain et celles des opérations sur + /// les discussions ([`super::sessions`]), qui partagent ce verrou. + pub index_lock: Arc>, +} + +impl StoreSink { + pub fn new(store: Store) -> Self { + StoreSink { + store, + index_lock: Arc::new(tokio::sync::Mutex::new(())), + } + } } impl PersistSink for StoreSink { - fn commit( + async fn commit( &self, snaps: Vec, res: Vec, gate: ResGate, - ) -> impl Future + Send { - commit_batch(&self.store, snaps, res, gate, now_unix()) + ) -> CommitOutcome { + let _index = self.index_lock.lock().await; + commit_batch(&self.store, snaps, res, gate, now_unix()).await } } diff --git a/rust/loki-rs/src/chat/sessions.rs b/rust/loki-rs/src/chat/sessions.rs new file mode 100644 index 0000000..a3dc7c3 --- /dev/null +++ b/rust/loki-rs/src/chat/sessions.rs @@ -0,0 +1,352 @@ +//! Plusieurs discussions — port des opérations de `chat_sessions.go` et du +//! chargement au démarrage (`LoadConversation`, `chat_conversation.go`). +//! +//! Une seule discussion en mémoire ([`Conversation`]), rangée dans un tiroir +//! nommé (`chat/conv:`), listée dans `chat/index`, ouverte selon +//! `chat/active` — partagée par tous les appareils. Basculer = enregistrer la +//! courante, charger l'autre et incrémenter l'epoch : les abonnés reçoivent +//! `{reset}` et rejouent le nouveau fil. +//! +//! Les opérations de haut niveau (création, bascule, renommage, suppression) +//! sont sérialisées (`convOpMu`) ; les lectures-modifications-écritures de +//! l'index partagent leur verrou avec l'écrivain ([`StoreSink::index_lock`], +//! `convIndexMu`). +//! +//! Ce qui vit hors de la conversation reste derrière [`SessionHooks`] : les +//! fichiers, travaux, critères et mode d'une discussion supprimée, ses +//! résultats d'outils, et le préchauffage après une bascule. Le changement de +//! projet (`projectSwitch`) reste au Go pour l'instant. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use super::conv::Conversation; +use super::persist::{ + BK_CHAT, CK_ACTIVE, CK_INDEX, CK_LEGACY, ConvMeta, DEFAULT_PROJECT_SLUG, IndexError, Persister, + StoreSink, conv_key, decode_index, encode_index, index_for_project, new_conv_id, now_unix, + remove_from, rename_in, +}; +use crate::store::{BK_STATE, Store}; + +/// Projet actif (`stActiveProjectKey`, bucket `state`). +pub const ST_ACTIVE_PROJECT: &str = "active_project"; + +/// Essais de lecture au démarrage, et attente entre deux (`loadConvAttempts`, +/// `loadConvRetryWait`) : l'ancien process peut encore tenir la base. +pub const LOAD_ATTEMPTS: u32 = 4; +pub const LOAD_RETRY_WAIT: Duration = Duration::from_millis(250); + +/// Ce qui, autour d'une discussion, ne vit pas dans la conversation. +pub trait SessionHooks: Send + Sync + 'static { + /// Discussion supprimée : ses fichiers, travaux d'arrière-plan, critères, + /// mode et résultats d'outils partent avec elle. + fn dropped(&self, id: &str) -> impl Future + Send; + /// Bascule faite (`prewarmAfterSwitch`). + fn switched(&self, id: &str) -> impl Future + Send; +} + +/// Refus d'une opération sur les discussions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionError { + /// « identifiant manquant » / « identifiant ou titre manquant » + Missing, + /// « discussion introuvable » + NotFound, + /// Base illisible. + Store(String), +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionError::Missing => f.write_str("identifiant ou titre manquant"), + SessionError::NotFound => f.write_str("discussion introuvable"), + SessionError::Store(e) => write!(f, "base illisible : {e}"), + } + } +} + +impl From for SessionError { + fn from(e: IndexError) -> Self { + match e { + IndexError::Missing => SessionError::Missing, + IndexError::NotFound => SessionError::NotFound, + } + } +} + +/// Les discussions de l'utilisateur, autour de la discussion ouverte. +pub struct Sessions { + store: Store, + conv: Arc, + persister: Persister, + hooks: H, + /// `convOpMu`. + op: tokio::sync::Mutex<()>, +} + +impl Sessions { + pub fn new(store: Store, conv: Arc, hooks: H) -> Self { + let persister = Persister::new(StoreSink::new(store.clone())); + Sessions { + store, + conv, + persister, + hooks, + op: tokio::sync::Mutex::new(()), + } + } + + pub fn conv(&self) -> &Arc { + &self.conv + } + + pub fn persister(&self) -> &Persister { + &self.persister + } + + /// `activeProjectSlug` : projet actif, le projet par défaut à défaut. + pub async fn active_project(&self) -> String { + match self.store.get_str(BK_STATE, ST_ACTIVE_PROJECT).await.trim() { + "" => DEFAULT_PROJECT_SLUG.to_string(), + s => s.to_string(), + } + } + + /// `convIndex` : l'index, plus récente en tête. Illisible ou verrouillé : + /// vide — la liste est masquée, rien n'est perdu. + pub async fn read_index(&self) -> Vec { + match self.store.get_open(BK_CHAT, CK_INDEX).await { + Ok(Some(b)) => decode_index(&b), + _ => Vec::new(), + } + } + + async fn save_index(&self, idx: &[ConvMeta]) { + if let Err(e) = self + .store + .put_seal(BK_CHAT, CK_INDEX, encode_index(idx)) + .await + { + eprintln!("[conv] index non enregistré : {e:?}"); + } + } + + /// Ajoute une entrée à l'index (création, migration). + async fn index_add(&self, id: &str) { + let now = now_unix(); + let project = self.active_project().await; + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + idx.push(ConvMeta { + id: id.to_string(), + title: String::new(), + created: now, + updated: now, + turns: 0, + project, + }); + self.save_index(&idx).await; + } + + /// `LoadConversation` : recharge la discussion active au démarrage. Une + /// lecture RATÉE n'est pas une absence : on réessaie, et en cas d'échec + /// durable on ne touche à rien (ni identifiant forgé, ni fil écrasé). + pub async fn load(&self) -> Result<(), String> { + let _op = self.op.lock().await; + let mut last_err = String::new(); + for attempt in 0..LOAD_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(LOAD_RETRY_WAIT).await; + } + // Sonde : tant qu'elle échoue, surtout ne pas créer de discussion. + if let Err(e) = self.store.get(BK_CHAT, CK_ACTIVE).await { + last_err = e; + continue; + } + let id = self.ensure_active_locked().await?; + match self.store.get_open(BK_CHAT, &conv_key(&id)).await { + Ok(body) => { + self.conv.load_from(&id, &body.unwrap_or_default()); + return Ok(()); + } + Err(e) => last_err = e, + } + } + eprintln!( + "[conv] base illisible au démarrage ({last_err}) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact" + ); + Err(last_err) + } + + /// `convEnsureActive` : identifiant de la discussion active, créée au + /// besoin (reprise silencieuse du fil unique d'avant les discussions). + pub async fn ensure_active(&self) -> Result { + let _op = self.op.lock().await; + self.ensure_active_locked().await + } + + async fn ensure_active_locked(&self) -> Result { + let cached = self.conv.id(); + if !cached.is_empty() { + return Ok(cached); + } + if let Some(raw) = self.store.get(BK_CHAT, CK_ACTIVE).await? { + let id = String::from_utf8_lossy(&raw).trim().to_string(); + if !id.is_empty() { + return Ok(id); + } + } + let id = new_conv_id(); + if let Some(legacy) = self.store.get(BK_CHAT, CK_LEGACY).await? + && !legacy.is_empty() + { + // Le fil unique devient la première discussion ; la clé d'origine + // reste intacte (retour arrière sans perte). + if let Err(e) = self.store.put_seal(BK_CHAT, &conv_key(&id), legacy).await { + eprintln!("[conv] reprise du fil unique non écrite : {e:?}"); + } + } + self.store + .put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec()) + .await?; + self.index_add(&id).await; + Ok(id) + } + + /// `ConvList` : les discussions du projet actif, et l'active. + pub async fn list(&self) -> Result<(Vec, String), String> { + let project = self.active_project().await; + let idx = self.read_index().await; + let list = index_for_project(&idx, &project) + .into_iter() + .cloned() + .collect(); + Ok((list, self.ensure_active().await?)) + } + + /// `convActivate` : pointeur en base, identifiant et contenu changent + /// ensemble. + async fn activate(&self, id: &str) -> Result<(), SessionError> { + self.store + .put(BK_CHAT, CK_ACTIVE, id.as_bytes().to_vec()) + .await + .map_err(SessionError::Store)?; + let body = self + .store + .get_open(BK_CHAT, &conv_key(id)) + .await + .ok() + .flatten() + .unwrap_or_default(); + self.conv.load_from(id, &body); + Ok(()) + } + + /// `persist` : enregistre la discussion ouverte et ATTEND l'écriture. + pub async fn persist(&self) { + let project = self.active_project().await; + let snap = self.conv.snapshot(&self.persister, &project); + if snap.id.is_empty() { + self.persister.flush().await; + return; + } + self.persister.persist(snap).await; + } + + /// `persistAsync` : instantané tout de suite, écriture par l'écrivain. + pub async fn persist_async(&self) { + let project = self.active_project().await; + let snap = self.conv.snapshot(&self.persister, &project); + if !snap.id.is_empty() { + self.persister.enqueue_snap(snap); + } + } + + /// `convCreate` : discussion vide ouverte SANS enregistrer la courante. + async fn create_locked(&self) -> Result { + let id = new_conv_id(); + self.index_add(&id).await; + self.activate(&id).await?; + Ok(id) + } + + /// `convNew` : met la courante de côté puis en ouvre une neuve. + pub async fn new_conv(&self) -> Result { + let _op = self.op.lock().await; + self.persist().await; + self.create_locked().await + } + + /// `convSwitch` : enregistre la courante, charge la cible. + pub async fn switch(&self, id: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + if id.is_empty() { + return Err(SessionError::Missing); + } + if !self.read_index().await.iter().any(|m| m.id == id) { + return Err(SessionError::NotFound); + } + if id + == self + .ensure_active_locked() + .await + .map_err(SessionError::Store)? + { + return Ok(()); + } + self.persist().await; + self.activate(id).await?; + self.hooks.switched(id).await; + Ok(()) + } + + /// `convRename`. + pub async fn rename(&self, id: &str, title: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + rename_in(&mut idx, id, title)?; + self.save_index(&idx).await; + Ok(()) + } + + /// `convDelete` : supprimer celle qui est ouverte bascule sur la plus + /// récente restante, ou sur une neuve : il y a TOUJOURS une discussion + /// active. + pub async fn delete(&self, id: &str) -> Result<(), SessionError> { + let _op = self.op.lock().await; + if !self.read_index().await.iter().any(|m| m.id == id) { + return Err(SessionError::NotFound); + } + // Rien ne doit plus la viser, puis l'écriture en vol est attendue : + // sinon elle la ferait renaître, contenu et entrée d'index. + self.persister.forget(id); + self.persister.flush().await; + let next = { + let _g = self.persister.sink().index_lock.lock().await; + let mut idx = self.read_index().await; + remove_from(&mut idx, id); + self.save_index(&idx).await; + idx + }; + if let Err(e) = self.store.del(BK_CHAT, &conv_key(id)).await { + eprintln!("[conv] contenu de {id} non effacé : {e}"); + } + self.hooks.dropped(id).await; + if id + != self + .ensure_active_locked() + .await + .map_err(SessionError::Store)? + { + return Ok(()); + } + match next.first() { + // Surtout pas new_conv : il réenregistrerait la supprimée. + None => self.create_locked().await.map(|_| ()), + Some(m) => self.activate(&m.id.clone()).await, + } + } +} diff --git a/rust/loki-rs/src/store.rs b/rust/loki-rs/src/store.rs index 3697c0d..d9c466a 100644 --- a/rust/loki-rs/src/store.rs +++ b/rust/loki-rs/src/store.rs @@ -127,6 +127,31 @@ impl Store { } } + /// `getStoreBytesErr` : lit et ouvre une valeur scellée. Absente, ou + /// illisible (mémoire verrouillée, autre clé) → `Ok(None)` ; `Err` + /// seulement si la base n'a pas pu être lue (« rien » et « pas pu lire » + /// ne veulent pas dire la même chose). + pub async fn get_open(&self, bucket: &str, key: &str) -> Result>, String> { + match self.get(bucket, key).await? { + Some(raw) if !raw.is_empty() => Ok(self.open(raw).await.ok()), + _ => Ok(None), + } + } + + /// `putStoreBytes` : scelle puis écrit. Mémoire verrouillée : rien n'est + /// écrit (jamais en clair). + pub async fn put_seal( + &self, + bucket: &str, + key: &str, + plain: Vec, + ) -> Result<(), CryptError> { + let sealed = self.seal(plain).await?; + self.put(bucket, key, sealed) + .await + .map_err(CryptError::Other) + } + /// Exécute un appel d'outil par le Go (`/internal/v1/tool/exec`) : même /// fonction que sa boucle de chat. `caps` : objet JSON des capacités du /// tour (champs Go : Agent, Internet, Mem, Code, Role, ComputerUse). diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index e425f09..f1b9dca 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -73,7 +73,7 @@ fn opened(go: &fakego::FakeGo, bucket: &str, key: &str) -> Option> { #[tokio::test] async fn discussion_scellee_contenu_et_index() { let (st, go, _stop, _t) = store().await; - let p = Persister::new(StoreSink { store: st }); + let p = Persister::new(StoreSink::new(st)); let body = ConvState { seq: 3, ..Default::default() @@ -114,7 +114,7 @@ async fn discussion_scellee_contenu_et_index() { #[tokio::test] async fn memoire_verrouillee_rien_en_clair() { let (st, go, _stop, _t) = store().await; - let p = Persister::new(StoreSink { store: st }); + let p = Persister::new(StoreSink::new(st)); *go.locked.lock().unwrap() = true; p.enqueue_tool_res(ToolResJob { key: "c1.ab".into(), @@ -139,7 +139,7 @@ async fn memoire_verrouillee_rien_en_clair() { async fn index_illisible_jamais_reecrit() { let (st, go, _stop, _t) = store().await; go.set(BK_CHAT, CK_INDEX, "SCELLE:pas du json"); - let p = Persister::new(StoreSink { store: st }); + let p = Persister::new(StoreSink::new(st)); p.persist(snap("c1", p.next_seq(), "t", b"{}".to_vec())) .await; assert_eq!(opened(&go, BK_CHAT, "conv:c1").unwrap(), b"{}"); @@ -241,3 +241,177 @@ async fn moteur_tenu_par_une_tache_go() { .unwrap(); lease.release().await; } + +// ---- plusieurs discussions ---- + +use loki_rs::chat::conv::Conversation; +use loki_rs::chat::sessions::{SessionError, SessionHooks, Sessions}; +use std::sync::Mutex as StdMutex; + +#[derive(Default, Clone)] +struct Crochets(Arc>>); + +impl SessionHooks for Crochets { + async fn dropped(&self, id: &str) { + self.0.lock().unwrap().push(format!("dropped {id}")); + } + async fn switched(&self, id: &str) { + self.0.lock().unwrap().push(format!("switched {id}")); + } +} + +async fn sessions() -> ( + Sessions, + Crochets, + Arc, + tokio::sync::oneshot::Sender<()>, + tempfile::TempDir, +) { + let (st, go, stop, t) = store().await; + let h = Crochets::default(); + ( + Sessions::new(st, Conversation::new(), h.clone()), + h, + go, + stop, + t, + ) +} + +fn dire(s: &Sessions, texte: &str) { + let c = s.conv(); + c.append_delta( + c.epoch(), + serde_json::from_value(serde_json::json!({"user": texte})).unwrap(), + ); + c.with_state(|st| { + st.messages.push(loki_rs::chat::types::Message { + role: "user".into(), + content: Some(serde_json::json!(texte)), + ..Default::default() + }) + }); +} + +fn textes(s: &Sessions) -> Vec { + s.conv().with_state(|st| { + st.messages + .iter() + .filter_map(|m| m.content.as_ref()?.as_str().map(str::to_string)) + .collect() + }) +} + +#[tokio::test] +async fn demarrage_sur_base_neuve() { + let (s, _h, go, _stop, _t) = sessions().await; + s.load().await.unwrap(); + let id = s.conv().id(); + assert!(id.starts_with('c')); + assert_eq!(go.get(BK_CHAT, "active").unwrap(), id); + let (list, active) = s.list().await.unwrap(); + assert_eq!(active, id); + assert_eq!(list.len(), 1); + assert_eq!(list[0].project, "generale"); + // Rechargé : même discussion, rien de recréé. + s.load().await.unwrap(); + assert_eq!(s.read_index().await.len(), 1); +} + +#[tokio::test] +async fn demarrage_reprend_le_fil_unique() { + let (s, _h, go, _stop, _t) = sessions().await; + go.set( + BK_CHAT, + "conversation", + r#"{"messages":[{"role":"user","content":"ancien fil"}],"log":null,"seq":0,"ctx_used":0}"#, + ); + s.load().await.unwrap(); + assert_eq!(textes(&s), ["ancien fil"]); + assert!( + go.get(BK_CHAT, "conversation").is_some(), + "clé d'origine gardée" + ); +} + +#[tokio::test] +async fn demarrage_base_illisible_n_ecrit_rien() { + let (s, _h, go, _stop, _t) = sessions().await; + *go.broken.lock().unwrap() = true; + assert!(s.load().await.is_err()); + *go.broken.lock().unwrap() = false; + assert!( + go.store.lock().unwrap().is_empty(), + "écrit malgré la base illisible" + ); + assert_eq!(s.conv().id(), ""); +} + +#[tokio::test] +async fn creer_basculer_renommer() { + let (s, h, _go, _stop, _t) = sessions().await; + s.load().await.unwrap(); + let a = s.conv().id(); + dire(&s, "seulement dans A"); + let b = s.new_conv().await.unwrap(); + assert_ne!(a, b); + assert!(textes(&s).is_empty()); + dire(&s, "seulement dans B"); + s.switch(&a).await.unwrap(); + assert_eq!(textes(&s), ["seulement dans A"]); + s.switch(&b).await.unwrap(); + assert_eq!(textes(&s), ["seulement dans B"]); + assert_eq!( + *h.0.lock().unwrap(), + [format!("switched {a}"), format!("switched {b}")] + ); + // L'index porte les titres déduits. + let idx = s.read_index().await; + let titre = |id: &str| idx.iter().find(|m| m.id == id).unwrap().title.clone(); + assert_eq!(titre(&a), "seulement dans A"); + assert_eq!(s.switch("inconnue").await, Err(SessionError::NotFound)); + assert_eq!(s.switch("").await, Err(SessionError::Missing)); + s.rename(&a, " Mon fil ").await.unwrap(); + assert_eq!( + s.read_index() + .await + .iter() + .find(|m| m.id == a) + .unwrap() + .title, + "Mon fil" + ); + assert_eq!(s.rename(&a, " ").await, Err(SessionError::Missing)); +} + +#[tokio::test] +async fn supprimer_l_ouverte_puis_la_derniere() { + let (s, h, go, _stop, _t) = sessions().await; + s.load().await.unwrap(); + let a = s.conv().id(); + dire(&s, "fil A"); + s.persist().await; + let b = s.new_conv().await.unwrap(); + dire(&s, "fil B"); + s.persist_async().await; // une écriture en attente qui vise B + s.delete(&b).await.unwrap(); + assert!( + go.get(BK_CHAT, &format!("conv:{b}")).is_none(), + "contenu de B resté" + ); + assert!(!s.read_index().await.iter().any(|m| m.id == b)); + assert_eq!(s.conv().id(), a, "bascule sur la plus récente restante"); + assert_eq!(textes(&s), ["fil A"]); + assert!(h.0.lock().unwrap().contains(&format!("dropped {b}"))); + // B ne renaît pas, même après une écriture. + s.persist().await; + assert!(go.get(BK_CHAT, &format!("conv:{b}")).is_none()); + + s.delete(&a).await.unwrap(); + let c = s.conv().id(); + assert!(c != a && !c.is_empty(), "toujours une discussion active"); + let idx = s.read_index().await; + assert_eq!(idx.len(), 1); + assert_eq!(idx[0].id, c); + assert_eq!(s.delete("inconnue").await, Err(SessionError::NotFound)); +} From bec3d2cbfd6a1bcfa7112d089dba6e215abf7182 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:22:48 +0000 Subject: [PATCH 06/24] =?UTF-8?q?Lot=206=20:=20petites=20r=C3=A8gles=20de?= =?UTF-8?q?=20la=20boucle=20d'un=20tour=20(chat::nudges)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de llm_budget.go (palier AGENT_BUDGET, rappels qui reviennent à chaque palier), appendNudge, steerSystem, dedupableTool, repeatedCallResult et toolResultPreview : ce qui manquait à la boucle d'un tour en plus des modules déjà portés. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/chat/compact.rs | 2 +- rust/loki-rs/src/chat/nudges.rs | 227 +++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 rust/loki-rs/src/chat/nudges.rs diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs index 680ddcb..f3653f6 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -99,7 +99,7 @@ const THINK_CLOSE: &str = ""; // une demande de l'utilisateur. const LOKI_NOTE_PREFIX: &str = "[system] "; pub(crate) const THINK_NUDGE_FIRST: &str = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."; -const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."; +pub(crate) const THINK_NUDGE_STUCK: &str = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."; const RETRY_CORRECTIVE_LEAD: &str = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n"; const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered."; diff --git a/rust/loki-rs/src/chat/nudges.rs b/rust/loki-rs/src/chat/nudges.rs new file mode 100644 index 0000000..21248d7 --- /dev/null +++ b/rust/loki-rs/src/chat/nudges.rs @@ -0,0 +1,227 @@ +//! Petites règles de la boucle d'un tour — port de `llm_budget.go`, +//! `appendNudge` (`llm_injected.go`), `steerSystem`, `dedupableTool`, +//! `repeatedCallResult` et `toolResultPreview` (`llm_client.go`). + +use serde_json::Value; + +use super::types::Message; + +pub(crate) use super::compact::{THINK_NUDGE_FIRST, THINK_NUDGE_STUCK}; + +/// Palier par défaut du budget souple d'appels d'outils (`agentBudgetDefault`). +pub const AGENT_BUDGET_DEFAULT: i64 = 24; +/// Multiplicateur du palier en mode Code (`codeBudgetFactor`). +pub const CODE_BUDGET_FACTOR: i64 = 3; +/// Relances « pensé sans agir » d'un tour (`maxNudges`). +pub const MAX_NUDGES: i64 = 2; +/// Aperçu d'un résultat d'outil envoyé à l'UI, en caractères (`toolPreviewChars`). +pub const TOOL_PREVIEW_CHARS: usize = 1600; + +/// `agentBudget` : palier AGENT_BUDGET ; 0 ou off/false/no/non/disable(d) +/// coupe le mécanisme ; illisible → défaut. +pub fn agent_budget(v: &str) -> i64 { + match v.trim().to_lowercase().as_str() { + "" => AGENT_BUDGET_DEFAULT, + "off" | "false" | "no" | "non" | "disable" | "disabled" => 0, + s => s + .parse::() + .ok() + .filter(|n| *n >= 0) + .unwrap_or(AGENT_BUDGET_DEFAULT), + } +} + +/// `budgetNudge` : le rappel à injecter quand le tour franchit un palier +/// (budget, 2×budget…), ou "" : `calls` appels faits, `sent` rappels déjà +/// envoyés. Une pression, pas une barrière. +pub fn budget_nudge(calls: i64, budget: i64, sent: i64) -> String { + if budget <= 0 || calls < budget * (sent + 1) { + return String::new(); + } + let n = format!("{calls} tool calls"); + match sent { + 0 => format!( + "[system] You have made {n} in this single turn. That is a lot: you almost certainly have enough to answer already. Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing." + ), + 1 => format!( + "[system] {n} now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further." + ), + _ => format!( + "[system] {n}. Stop. Write your final answer in this message. Do not call another tool." + ), + } +} + +/// `logBudget`. +pub fn log_budget(calls: i64, budget: i64, sent: i64) { + eprintln!( + "[agent] {calls} appels d'outils sur ce tour (palier={budget}) — rappel n°{sent} envoyé au modèle" + ); +} + +/// `appendNudge` : le rappel part en fin de fil ET dans l'historique (le tour +/// suivant le renvoie à l'identique) — sauf juste après un message `user`, +/// où deux `user` d'affilée n'entrent pas dans l'historique. +pub fn append_nudge(messages: &mut Vec, extra: &mut Vec, text: &str) { + let m = Message { + role: "user".into(), + content: Some(Value::String(text.into())), + ..Default::default() + }; + let after_user = messages.last().is_some_and(|l| l.role == "user"); + messages.push(m.clone()); + if !after_user { + extra.push(m); + } +} + +/// `steerSystem` : la consigne s'ajoute au système de tête (texte), ou +/// devient un système en tête — jamais un système en fin de fil, que les +/// gabarits stricts refusent. +pub fn steer_system(msgs: &[Message], hint: &str) -> Vec { + if let Some(first) = msgs.first() + && first.role == "system" + && let Some(Value::String(existing)) = &first.content + { + let mut out = msgs.to_vec(); + out[0] = Message { + role: "system".into(), + content: Some(Value::String(format!("{existing}\n\n{hint}"))), + ..Default::default() + }; + return out; + } + let mut out = Vec::with_capacity(msgs.len() + 1); + out.push(Message { + role: "system".into(), + content: Some(Value::String(hint.into())), + ..Default::default() + }); + out.extend_from_slice(msgs); + out +} + +/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses +/// jobs (effets de bord), see_image et le navigateur (état vivant, image à +/// part). +pub fn dedupable_tool(name: &str) -> bool { + !matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_") +} + +/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà +/// exécuté dans le tour — la note en tête, puis plus de charge utile du tout. +pub fn repeated_call_result(prev: &str, repeats: i64) -> String { + if repeats >= 2 { + return format!( + "[déjà fait] Cet appel exact a déjà été exécuté {repeats} fois dans ce tour ; son résultat est plus haut dans la conversation. Ne le redemande plus : réponds avec ce que tu as, ou change d'approche (autre URL, autres arguments, web_grep pour cibler)." + ); + } + format!( + "[déjà fait] Appel identique déjà exécuté dans ce tour — non rejoué. Voici à nouveau son résultat ; ne le redemande pas une troisième fois.\n\n{prev}" + ) +} + +/// `toolResultPreview` : (aperçu, taille réelle en caractères, coupé ?). +pub fn tool_result_preview(s: &str) -> (&str, usize, bool) { + let n = s.chars().count(); + if n <= TOOL_PREVIEW_CHARS { + return (s, n, false); + } + let end = s + .char_indices() + .nth(TOOL_PREVIEW_CHARS) + .map_or(s.len(), |(i, _)| i); + (&s[..end], n, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::compact::is_loki_injected; + use serde_json::json; + + fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } + } + + #[test] + fn budget() { + assert_eq!(agent_budget(""), 24); + assert_eq!(agent_budget(" Off "), 0); + assert_eq!(agent_budget("10"), 10); + assert_eq!(agent_budget("-3"), 24); + assert_eq!(agent_budget("x"), 24); + assert_eq!(budget_nudge(23, 24, 0), ""); + assert_eq!(budget_nudge(100, 0, 0), ""); + let first = budget_nudge(24, 24, 0); + assert!(first.contains("24 tool calls") && first.starts_with("[system] ")); + assert_eq!(budget_nudge(47, 24, 1), ""); + assert!(budget_nudge(48, 24, 1).contains("going in circles")); + assert!(budget_nudge(72, 24, 2).ends_with("Do not call another tool.")); + // Les rappels sont reconnus comme venant de loki (titre, compaction…). + for sent in 0..3 { + assert!(is_loki_injected(&um(&budget_nudge( + 24 * (sent + 1), + 24, + sent + )))); + } + } + + #[test] + fn rappel_en_fin_de_fil() { + let (mut msgs, mut extra) = (vec![um("q")], vec![]); + append_nudge(&mut msgs, &mut extra, "a"); + assert_eq!( + (msgs.len(), extra.len()), + (2, 0), + "après un user : hors historique" + ); + let tool = Message { + role: "tool".into(), + content: Some(json!("r")), + ..Default::default() + }; + msgs.push(tool); + append_nudge(&mut msgs, &mut extra, "b"); + assert_eq!((msgs.len(), extra.len()), (4, 1)); + } + + #[test] + fn consigne_systeme_en_tete() { + let sys = Message { + role: "system".into(), + content: Some(json!("base")), + ..Default::default() + }; + let out = steer_system(&[sys, um("q")], "h"); + assert_eq!(out[0].content, Some(json!("base\n\nh"))); + assert_eq!(out.len(), 2); + let out = steer_system(&[um("q")], "h"); + assert_eq!((out[0].role.as_str(), out.len()), ("system", 2)); + } + + #[test] + fn appels_repetes() { + assert!(dedupable_tool("edit") && dedupable_tool("web_fetch")); + assert!(!dedupable_tool("bash") && !dedupable_tool("browser_click")); + assert!(repeated_call_result("R", 1).ends_with("\n\nR")); + let r = repeated_call_result("R", 2); + assert!(r.contains("2 fois") && !r.ends_with("\n\nR")); + } + + #[test] + fn apercu() { + assert_eq!(tool_result_preview("abc"), ("abc", 3, false)); + let long = "é".repeat(TOOL_PREVIEW_CHARS + 5); + let (p, n, cut) = tool_result_preview(&long); + assert_eq!( + (p.chars().count(), n, cut), + (TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true) + ); + } +} From 73bbf92f699a2fd1893055fd9ed22dfc96ae30f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:22:48 +0000 Subject: [PATCH 07/24] =?UTF-8?q?Lot=206=20:=20la=20boucle=20d'un=20tour?= =?UTF-8?q?=20port=C3=A9e=20en=20Rust=20(chat::turn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de runChatLoop : complétions enchaînées jusqu'à la réponse, sur les modules déjà portés (charge utile, flux, compactage). Les filets du Go y sont : reprises réseau et statuts passagers, flux externe coupé (rejoué ou poursuivi après le texte affiché), intensité refusée par le gabarit, raisonnement renvoyé refusé ou trop long, compaction réactive puis réduction forcée, 500 (tool_choice none puis outils retirés), réponse vide (rejeu si fenêtre pleine, relances « pensé sans agir »), appel écrit en texte, budget souple, appels identiques court-circuités, arguments illisibles jamais exécutés, pré-vol du mode Code, compaction en cours de tour, file d'attente injectée entre deux étapes. Aucune entrée-sortie dans la boucle : envoi, outils, compaction, résultats complets et mémoires des refus passent par TurnHost ; l'arrêt par Cancel. Non portés (clés opt-in ou machinerie du moteur local restée au Go) : KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, préchauffage, sonde de gabarit, télémétrie. 15 tests sur un faux hôte aux réponses scriptées (temps suspendu pour les reprises). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/Cargo.toml | 1 + rust/loki-rs/src/chat/mod.rs | 6 +- rust/loki-rs/src/chat/turn.rs | 917 ++++++++++++++++++++++++++++ rust/loki-rs/src/chat/turn/tests.rs | 500 +++++++++++++++ 4 files changed, 1423 insertions(+), 1 deletion(-) create mode 100644 rust/loki-rs/src/chat/turn.rs create mode 100644 rust/loki-rs/src/chat/turn/tests.rs diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index d002ea7..0a5da59 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -30,6 +30,7 @@ tls = ["dep:rustls"] libc = "0.2" [dev-dependencies] +tokio = { version = "1.53.2", features = ["test-util"] } futures-util = "0.3.34" tempfile = "3.27.0" tokio-tungstenite = "0.30.0" diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index fa5701f..11bd048 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -12,15 +12,19 @@ //! abonnés) ; //! - [`persist`] : format en base, index des discussions, écrivain unique ; //! - [`sessions`] : plusieurs discussions (index, bascules, chargement) ; -//! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go). +//! - [`lease`] : bail du moteur (verrou de génération partagé avec le Go) ; +//! - [`nudges`] : budget d'outils, rappels, appels répétés, aperçus ; +//! - [`turn`] : la boucle d'un tour (complétions, outils, filets). pub mod compact; pub mod conv; pub mod engine; pub mod journal; pub mod lease; +pub mod nudges; pub mod persist; pub mod sessions; pub mod stream; +pub mod turn; pub mod types; pub mod wire; diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs new file mode 100644 index 0000000..1857e8e --- /dev/null +++ b/rust/loki-rs/src/chat/turn.rs @@ -0,0 +1,917 @@ +//! La boucle d'un tour — port de `runChatLoop` (`llm_client.go`). +//! +//! Un tour = des complétions enchaînées jusqu'à une réponse : envoi de la +//! requête, lecture du flux ([`StreamParser`]), exécution des appels d'outils +//! puis nouvelle complétion, avec les filets du Go : +//! - reprises réseau AVANT toute réponse (connexion refusée, délai, coupure ; +//! statuts 429/502/503/504), et flux coupé en cours de réponse vers une API +//! externe (rejoué, ou poursuivi après le texte déjà affiché) ; +//! - refus du moteur : intensité de raisonnement refusée par le gabarit, +//! raisonnement renvoyé refusé ou trop long, débordement de contexte +//! (compaction réactive, puis réduction forcée), 500 sur un appel mal formé +//! (`tool_choice: none` puis outils retirés) ; +//! - réponse vide : compaction et rejeu si la fenêtre est pleine, sinon +//! relances « pensé sans agir » ; appel d'outil écrit en texte : relance +//! corrective ; budget souple d'appels d'outils ; appels identiques +//! court-circuités ; arguments illisibles jamais exécutés ; +//! - compaction en cours de tour quand la boucle d'outils remplit la fenêtre ; +//! - messages mis en file par l'utilisateur injectés entre deux étapes. +//! +//! La boucle ne fait AUCUNE entrée-sortie elle-même : tout passe par +//! [`TurnHost`] (envoi au moteur, outils, compaction, résultats complets, +//! file d'attente, mémoires des refus). Les événements partent dans le rappel +//! `cb` (comme le `ChatCallback` Go) ; le tour rend ses messages à ajouter à +//! l'historique ([`TurnResult::extra`]). +//! +//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) : +//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et +//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit, +//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une +//! de ces clés, la conversation doit rester au Go. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Instant; + +use bytes::Bytes; +use futures_util::{Stream, StreamExt}; +use serde_json::{Map, Value}; +use tokio::sync::watch; + +use super::compact::cap_tool_result; +use super::compact::{ + CompactConfig, compact_needed, compact_would_trigger, context_overflow, estimate_tokens, + log_compact, log_ctx, log_ctx_estimate, overflow_tokens, shrink_to_fit, +}; +use super::engine::{ + LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status, + stream_cut_error, +}; +use super::nudges::{ + MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool, + log_budget, repeated_call_result, steer_system, tool_result_preview, +}; +use super::stream::{ + CacheHintFn, MAX_PATTERN_RETRIES, PreflightFn, RetryPatterns, StreamConfig, StreamParser, + call_args, retry_corrective, tool_call_label, +}; +use super::types::{ + AskEvent, DiffLine, Message, ReasoningEcho, StreamEvent, Tool, ToolCall, ToolCallFunc, + ToolUsedEvent, +}; +use super::wire::{ + ChatEndpoint, ChatPayloadOpts, EchoPolicy, TOOLS_OFF_HINT, TurnReasoning, build_chat_payload, + echo_template_error, effort_from_rejection, log_effort_fallback, reasoning_active, + reasoning_echo_model, reasoning_template_kwargs, strip_reasoning, turn_echo_policy, + wire_messages, with_echo, +}; + +/// Reprises d'un flux coupé en cours de réponse (`maxStreamRetries`). +pub const MAX_STREAM_RETRIES: u32 = 5; +/// Corps d'un refus du moteur lu au plus (octets). +const ERROR_BODY_MAX: usize = 2000; +/// Consigne qui suit le texte déjà affiché quand un flux externe est coupé. +pub const STREAM_CUT_CONTINUE: &str = "The connection was cut during your answer. Continue exactly where you stopped, without repeating what you already wrote."; + +/// Corps d'une réponse du moteur. +pub type BodyStream = Pin> + Send>>; + +/// Réponse du moteur (statut et corps). +pub struct Reply { + pub status: u16, + pub body: BodyStream, +} + +/// Échec d'envoi (rien n'est encore parti à l'écran). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SendError { + pub kind: NetKind, + /// Message pour l'utilisateur (`friendlyLLMError`). + pub message: String, +} + +/// Ce qu'a produit un appel d'outil (`toolExecResult` du Go). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ToolOutcome { + pub result: String, + pub diff: Vec, + pub added: i64, + pub removed: i64, + pub ask: Option, + /// Capture à afficher dans la bulle (web_screenshot). + pub image: String, + /// Aperçu en direct du navigateur piloté. + pub live_shot: String, + /// Image relayée au modèle (capture, see_image) : ÉPHÉMÈRE, elle entre dans + /// la vue du tour mais jamais dans l'historique. + pub relay: Option, +} + +/// Où en est le tour quand il demande une compaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompactPhase { + /// Le moteur a refusé un prompt trop long. + Reactive, + /// Entre deux étapes : `used` = contexte réel estimé ; `rescue` : relance + /// de secours (outils coupés ou neutralisés). + Mid { used: i64, rescue: bool }, + /// Complétion coupée par la fenêtre pleine (finish_reason « length »). + FullWindow, +} + +/// Les effets d'un tour, fournis par l'appelant. +pub trait TurnHost: Send + Sync { + /// POST du corps vers l'endpoint (sa clé à lui). + fn send( + &self, + ep: &ChatEndpoint, + body: Vec, + ) -> impl Future> + Send; + /// Exécute un appel d'outil (`execTool`). + fn exec_tool( + &self, + name: &str, + args: &Map, + label: &str, + ) -> impl Future + Send; + /// Range un résultat complet (« voir plus ») et rend son id ("" : échec). + fn save_tool_result(&self, result: &str) -> String; + /// Compacte l'historique ; `None` : rien n'a changé (ou refus mémorisé). + fn compact( + &self, + phase: CompactPhase, + msgs: &[Message], + ) -> impl Future>> + Send; + /// Messages mis en file par l'utilisateur, à injecter maintenant. + fn drain_queued(&self) -> Vec; + /// Image rangée (`loki-img:…`) → data-URL. + fn image_data_url(&self, r: &str) -> Option; + /// Intensité refusée par le gabarit : traduction retenue. + fn effort_remember(&self, want: &str, got: &str); + /// Gabarit qui refuse le raisonnement renvoyé : retenu pour ce modèle. + fn echo_refuse(&self, model: &str); + /// `noteEnginePrompt` (moteur local). + fn cache_hint(&self) -> Option { + None + } + /// Garde d'écriture du mode Code (`codeWriteGuard`). + fn preflight(&self) -> Option { + None + } +} + +/// Ce que le tour sait avant de commencer (lu UNE fois). +#[derive(Clone)] +pub struct TurnConfig { + /// `ReadConfig()`. + pub cfg: HashMap, + pub ep: ChatEndpoint, + pub tools: Vec, + pub temperature: f64, + /// Mode Code : palier du budget triplé, garde d'écriture. + pub code: bool, + pub reasoning: TurnReasoning, + /// Politique d'écho courante (`currentEchoPolicy`). + pub echo: EchoPolicy, + pub compact: CompactConfig, + pub patterns: Arc, +} + +/// Fin du tour : messages à ajouter à l'historique, et l'erreur s'il y en a +/// une (déjà émise dans le rappel). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TurnResult { + pub extra: Vec, + pub err: Option, +} + +/// Arrêt du tour (bouton stop, reset, bascule). +#[derive(Clone)] +pub struct Cancel { + rx: watch::Receiver, + _keep: Option>>, +} + +impl Cancel { + /// Un arrêt et ce qui le déclenche (`send(true)`). + pub fn new() -> (watch::Sender, Cancel) { + let (tx, rx) = watch::channel(false); + (tx, Cancel { rx, _keep: None }) + } + + /// Jamais arrêté. + pub fn never() -> Cancel { + let (tx, rx) = watch::channel(false); + Cancel { + rx, + _keep: Some(Arc::new(tx)), + } + } + + pub fn is_cancelled(&self) -> bool { + *self.rx.borrow() + } + + /// Se résout à l'arrêt. + pub async fn cancelled(&self) { + let mut rx = self.rx.clone(); + if rx.wait_for(|c| *c).await.is_err() { + std::future::pending::<()>().await; + } + } + + /// `f`, ou `None` si l'arrêt arrive avant. + async fn or_stop(&self, f: F) -> Option { + tokio::select! { + biased; + _ = self.cancelled() => None, + v = f => Some(v), + } + } + + /// `llmNetBackoff` : attend le délai de la n-ième reprise ; faux si arrêté. + async fn backoff(&self, n: u32) -> bool { + self.or_stop(tokio::time::sleep(net_delay(n as i64))) + .await + .is_some() + } +} + +fn log_llm_retry(attempt: u32, cause: &str) { + eprintln!("[llm] {cause} — nouvelle tentative {attempt}/{NET_RETRIES}"); +} + +fn log_stream_retry(attempt: u32, cause: &str) { + eprintln!( + "[llm] flux coupé en cours de réponse ({cause}) — reprise {attempt}/{MAX_STREAM_RETRIES}" + ); +} + +fn text_msg(role: &str, text: impl Into) -> Message { + Message { + role: role.into(), + content: Some(Value::String(text.into())), + ..Default::default() + } +} + +/// `LLMPort` pour les messages d'erreur. +fn engine_port(cfg: &HashMap) -> u16 { + cfg.get("PORT") + .and_then(|p| p.trim().parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(8080) +} + +/// Lit au plus [`ERROR_BODY_MAX`] octets d'un corps de refus. +async fn read_error_body(mut body: BodyStream) -> String { + let mut buf = Vec::new(); + while buf.len() < ERROR_BODY_MAX { + match body.next().await { + Some(Ok(b)) => buf.extend_from_slice(&b), + _ => break, + } + } + buf.truncate(ERROR_BODY_MAX); + String::from_utf8_lossy(&buf).trim().to_string() +} + +fn status_text(status: u16) -> String { + let reason = hyper::StatusCode::from_u16(status) + .ok() + .and_then(|s| s.canonical_reason()) + .unwrap_or(""); + format!("{status} {reason}").trim().to_string() +} + +/// `fillToolResult` : aperçu pour l'UI, taille réelle, et l'id du résultat +/// complet s'il est coupé (envoyé entier si l'enregistrement échoue). +fn fill_tool_result(host: &H, mut ev: ToolUsedEvent, result: &str) -> ToolUsedEvent { + let (prev, n, cut) = tool_result_preview(result); + ev.result_chars = n as i64; + ev.result = result.to_string(); + if cut { + let id = host.save_tool_result(result); + if !id.is_empty() { + ev.result = prev.to_string(); + ev.result_id = id; + } + } + ev +} + +/// État d'un tour, d'une complétion à l'autre. +#[derive(Default)] +struct Loop { + disable_tools: bool, + tool_choice_none: bool, + compacted_retry: bool, + shrink_retries: u32, + effort_retried: bool, + done_calls: HashMap, + repeat_count: HashMap, + nudge_count: i64, + peak_gen: i64, + length_replays: u32, + last_est: i64, + pattern_retries: i64, + net_retries: u32, + stream_retries: u32, + echo_off_turn: bool, + echo_tpl_retry: bool, + tool_runs: i64, + budget_nudges: i64, +} + +/// `runChatLoop` : le tour, de la première requête à la réponse finale. +pub async fn run_turn( + host: &H, + tc: &TurnConfig, + mut messages: Vec, + cancel: &Cancel, + cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), +) -> TurnResult { + let mut extra: Vec = Vec::new(); + let mut s = Loop::default(); + let ep = &tc.ep; + let ccfg = &tc.compact; + let port = engine_port(&tc.cfg); + let reasoning_on = reasoning_active(tc.cfg.get("REASONING").map_or("", String::as_str)); + let effort_wanted = tc.reasoning.effort_wanted.clone(); + let think_off = tc.reasoning.think_off; + let mut effort = tc.reasoning.effort.clone(); + let mut kwargs = tc.reasoning.kwargs.clone(); + let mut budget = + super::nudges::agent_budget(tc.cfg.get("AGENT_BUDGET").map_or("", String::as_str)); + if tc.code { + budget *= super::nudges::CODE_BUDGET_FACTOR; + } + let new_history = |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), m: &[Message]| { + cb(StreamEvent::NewHistory(m.to_vec())); + }; + let fail = + |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec, err: String| { + cb(StreamEvent::Err(err.clone())); + TurnResult { + extra, + err: Some(err), + } + }; + + let mut iter: i64 = -1; + loop { + iter += 1; + // Messages mis en file : entre deux étapes, jamais après un stop. + if iter > 0 && !cancel.is_cancelled() { + let q = host.drain_queued(); + messages.extend(q.iter().cloned()); + extra.extend(q); + } + let nudge = budget_nudge(s.tool_runs, budget, s.budget_nudges); + if !nudge.is_empty() { + s.budget_nudges += 1; + log_budget(s.tool_runs, budget, s.budget_nudges); + append_nudge(&mut messages, &mut extra, &nudge); + } + let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none; + let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone()); + let (body, echo_sent) = { + let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol); + let payload = build_chat_payload( + ep, + &sent, + tc.temperature, + &ChatPayloadOpts { + tools: &tc.tools, + disable_tools: s.disable_tools, + tool_choice_none: s.tool_choice_none, + effort: &effort, + kwargs: kwargs.as_ref(), + no_sampling: false, + }, + &tc.cfg, + &|r| host.image_data_url(r), + ); + (serde_json::to_vec(&payload).unwrap_or_default(), echo_sent) + }; + let t_req = Instant::now(); + let reply = match cancel.or_stop(host.send(ep, body)).await { + None => return TurnResult { extra, err: None }, + Some(Ok(r)) => r, + Some(Err(e)) => { + // Rien n'est encore à l'écran : rejouable tel quel. + if s.net_retries < NET_RETRIES && retryable(e.kind) && !cancel.is_cancelled() { + log_llm_retry(s.net_retries + 1, &e.message); + if cancel.backoff(s.net_retries).await { + s.net_retries += 1; + continue; + } + } + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + return fail(cb, extra, e.message); + } + }; + + if reply.status != 200 { + let status = reply.status; + let mut msg = read_error_body(reply.body).await; + if msg.is_empty() { + msg = status_text(status); + } + // La relance sans raisonnement est refusée à son tour : il n'y + // était pour rien. + s.echo_tpl_retry = false; + // Intensité refusée par le gabarit : refus définitif, à traiter + // avant toute compaction. + if !s.effort_retried + && !effort_wanted.is_empty() + && let Some(fixed) = effort_from_rejection(&msg, &effort_wanted) + { + s.effort_retried = true; + host.effort_remember(&effort_wanted, &fixed); + log_effort_fallback(&effort_wanted, &fixed); + kwargs = reasoning_template_kwargs(think_off, &fixed); + effort = fixed; + continue; + } + if echo_sent > 0 && echo_template_error(status, &msg) { + s.echo_off_turn = true; + s.echo_tpl_retry = true; + log_ctx(&format!( + "erreur de gabarit avec le raisonnement renvoyé ({status}) : relance sans lui" + )); + continue; + } + if echo_sent > 0 && context_overflow(ccfg, &msg, &messages) { + s.echo_off_turn = true; + log_ctx( + "prompt trop long avec le raisonnement renvoyé : relance sans lui avant toute compaction", + ); + continue; + } + if s.echo_off_turn { + messages = strip_reasoning(&messages).into_owned(); + } + let overflow = context_overflow(ccfg, &msg, &messages); + if ccfg.enabled && !s.compacted_retry && overflow { + let Some(c) = cancel + .or_stop(host.compact(CompactPhase::Reactive, &messages)) + .await + else { + return TurnResult { extra, err: None }; + }; + if let Some(c) = c { + s.compacted_retry = true; + log_compact(ccfg, "réactif", 0, &messages, &c, true); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + if ccfg.enabled && s.shrink_retries < 2 && overflow { + let actual = overflow_tokens(&msg); + let (c, changed) = shrink_to_fit(ccfg, &messages, actual); + if changed { + s.shrink_retries += 1; + log_compact(ccfg, "réduction", actual, &messages, &c, true); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + // 500 : appel d'outil que llama.cpp n'a pas su lire. Moteur local : + // d'abord tool_choice « none », prompt intact ; puis outils retirés. + if !s.disable_tools + && !tc.tools.is_empty() + && (status == 500 || (s.tool_choice_none && (400..500).contains(&status))) + { + if !ep.external && !s.tool_choice_none { + s.tool_choice_none = true; + log_ctx("relance sans outil après un 500 : tool_choice=none, prompt conservé"); + continue; + } + s.tool_choice_none = false; + s.disable_tools = true; + messages = steer_system(&messages, TOOLS_OFF_HINT); + continue; + } + // « Pas maintenant » (passerelle, surcharge) : rejouable. + if s.net_retries < NET_RETRIES && retryable_status(status) && !cancel.is_cancelled() { + log_llm_retry(s.net_retries + 1, &format!("le moteur a renvoyé {status}")); + if cancel.backoff(s.net_retries).await { + s.net_retries += 1; + continue; + } + } + let who = if ep.external { + "l'API externe" + } else { + "llama-server" + }; + return fail(cb, extra, format!("{who} a renvoyé {status} : {msg}")); + } + + // Une réponse est arrivée : le budget de reprise réseau repart à neuf. + s.net_retries = 0; + if s.echo_tpl_retry { + s.echo_tpl_retry = false; + host.echo_refuse(&reasoning_echo_model(&tc.cfg)); + } + + let mut parser = StreamParser::new(StreamConfig { + reasoning_on, + calls_on, + tool_choice_none: s.tool_choice_none, + external: ep.external, + echo_on: pol.on, + echo_keeps_past: pol.keeps_past, + pattern_retries: s.pattern_retries, + patterns: tc.patterns.clone(), + t_req, + cache_hint: if ep.external { None } else { host.cache_hint() }, + preflight: if tc.code { host.preflight() } else { None }, + }); + let mut emit = |ev: StreamEvent| cb(ev); + let mut lines = Lines::new(reply.body); + // (message, ligne trop longue) + let mut read_err: Option<(String, bool)> = None; + let mut stopped = false; + loop { + match cancel.or_stop(lines.next_line()).await { + None => { + stopped = true; + break; + } + Some(None) => break, + Some(Some(Ok(line))) => { + if !parser.feed_line(&line, Instant::now(), &mut emit) { + break; + } + } + Some(Some(Err(LineError::TooLong))) => { + read_err = Some(("bufio.Scanner: token too long".into(), true)); + break; + } + Some(Some(Err(LineError::Io(e)))) => { + read_err = Some((e, false)); + break; + } + } + } + // Arrêt pendant la lecture : le tour s'arrête là, sans erreur, et rien + // de plus ne part vers l'écran. + if stopped { + return TurnResult { extra, err: None }; + } + let out = parser.finish(&mut emit); + if out.is_complete(read_err.is_some()) { + // Dernier événement de la complétion : copie complète des stats. + cb(StreamEvent::Stats(out.stats.clone())); + if !ep.external { + s.peak_gen = s.peak_gen.max(out.stats.gen_tokens); + if s.last_est > 0 && out.stats.prompt_tokens_total > 0 { + log_ctx_estimate("en-tour", s.last_est, out.stats.prompt_tokens_total); + } + } + s.last_est = 0; + } + if out.finish_reason == "length" { + log_ctx(&format!( + "finish=length prompt={} généré={} raisonnement={} fenêtre={}", + out.stats.prompt_tokens_total, + out.stats.gen_tokens, + out.stats.reasoning_tokens, + ccfg.ctx_window + )); + } + if out.aborted { + return TurnResult { extra, err: None }; + } + if read_err.is_some() && out.read_error_benign() { + read_err = None; + } + // Flux coupé vers une API distante : rejoué, ou poursuivi après le texte + // déjà affiché. Jamais pour le moteur local : coupé, il a planté. + if let Some((err, too_long)) = &read_err + && !too_long + && ep.external + && s.stream_retries < MAX_STREAM_RETRIES + { + s.stream_retries += 1; + if out.sent_reasoning && !out.sent_answer { + cb(StreamEvent::DropReasoning); + } + if let Some(t) = &out.typing_tool { + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: t.name.clone(), + label: t.label.clone(), + done: true, + result: "(appel interrompu par une coupure réseau, relancé)".into(), + ..Default::default() + })); + } + log_stream_retry(s.stream_retries, err); + if cancel.backoff(s.stream_retries).await { + if !out.shown.trim().is_empty() { + messages.push(text_msg("assistant", out.shown.clone())); + messages.push(text_msg("user", STREAM_CUT_CONTINUE)); + } + continue; + } + } + if let Some((err, too_long)) = read_err { + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + return fail(cb, extra, stream_cut_error(&err, too_long, port)); + } + s.stream_retries = 0; + + let content = out.content.as_str(); + // Relance tool_choice « none » sans réponse exploitable : repli UNE fois + // sur le chemin historique, outils retirés. + if s.tool_choice_none + && !cancel.is_cancelled() + && (out.none_leak + || tc.patterns.textual_tool_call(content) + || content.trim().is_empty()) + { + log_ctx("relance tool_choice=none sans réponse exploitable : repli sans outils"); + s.tool_choice_none = false; + s.disable_tools = true; + if out.sent_reasoning && !out.sent_answer { + cb(StreamEvent::DropReasoning); + } + messages = steer_system(&messages, TOOLS_OFF_HINT); + continue; + } + + let echo = ReasoningEcho { + text: out.echo_text.clone(), + model: pol.model.clone(), + }; + let assistant_base = |calls: Vec| { + let mut a = Message { + role: "assistant".into(), + tool_calls: calls, + ..Default::default() + }; + if !content.is_empty() { + a.content = Some(Value::String(content.to_string())); + } + if !echo.text.is_empty() { + a.reasoning_content = echo.text.clone(); + a.reasoning_model = echo.model.clone(); + } + a + }; + + // Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son + // chemin, entre dans l'historique avec le refus pour résultat. + if let Some(pf) = &out.preflight { + let id = if pf.id.is_empty() { + format!("call_{iter}_0") + } else { + pf.id.clone() + }; + let file_arg = serde_json::json!({ "file": pf.file }).to_string(); + let assistant = assistant_base(vec![ToolCall { + id: id.clone(), + kind: "function".into(), + function: ToolCallFunc { + name: pf.name.clone(), + arguments: file_arg, + }, + ..Default::default() + }]); + let result = format!( + "{} (écriture interrompue avant la fin : rien n'a été modifié)", + pf.msg + ); + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: pf.name.clone(), + label: pf.file.clone(), + done: true, + result: result.clone(), + ..Default::default() + })); + let tool_msg = Message { + role: "tool".into(), + tool_call_id: id, + content: Some(Value::String(result)), + ..Default::default() + }; + messages.extend([assistant.clone(), tool_msg.clone()]); + extra.extend([assistant, tool_msg]); + s.tool_runs += 1; + continue; + } + + // Appels d'outils (même si finish_reason dit « stop »). + if !out.tool_calls.is_empty() { + let assembled = out.assemble_tool_calls(iter, &tc.tools); + let assistant = assistant_base(assembled.calls.clone()); + messages.push(assistant.clone()); + extra.push(assistant); + // Début des résultats de CETTE étape, que le moteur n'a pas comptés. + let step_start = messages.len(); + s.pattern_retries = 0; + for call in &assembled.calls { + if cancel.is_cancelled() { + return TurnResult { extra, err: None }; + } + s.tool_runs += 1; + let name = call.function.name.as_str(); + let args = call_args(&call.function.arguments); + let label = tool_call_label(name, &args); + cb(StreamEvent::ToolUsed(ToolUsedEvent { + name: name.into(), + label: label.clone(), + ..Default::default() + })); + let done_ev = || ToolUsedEvent { + name: name.into(), + label: label.clone(), + done: true, + ..Default::default() + }; + let push_result = + |messages: &mut Vec, extra: &mut Vec, content: String| { + let m = Message { + role: "tool".into(), + tool_call_id: call.id.clone(), + content: Some(Value::String(content)), + ..Default::default() + }; + messages.push(m.clone()); + extra.push(m); + }; + if assembled.bad_args.contains(&call.id) { + let result = "[erreur] arguments de l'appel illisibles (JSON invalide ou tronqué) : l'outil n'a PAS été exécuté. Renvoie l'appel avec des arguments JSON complets et valides."; + cb(StreamEvent::ToolUsed(fill_tool_result( + host, + done_ev(), + result, + ))); + push_result(&mut messages, &mut extra, result.into()); + continue; + } + let call_key = format!("{name}\0{}", call.function.arguments); + if let Some(prev) = s.done_calls.get(&call_key) + && dedupable_tool(name) + { + let n = s.repeat_count.entry(call_key.clone()).or_insert(0); + *n += 1; + let result = repeated_call_result(prev, *n); + cb(StreamEvent::ToolUsed(fill_tool_result( + host, + done_ev(), + &result, + ))); + push_result(&mut messages, &mut extra, result); + continue; + } + let Some(ex) = cancel.or_stop(host.exec_tool(name, &args, &label)).await else { + return TurnResult { extra, err: None }; + }; + if let Some(ask) = ex.ask.clone() { + cb(StreamEvent::Ask(ask)); + } + if !ex.result.starts_with("[erreur]") { + s.done_calls.insert(call_key, ex.result.clone()); + } + let ev = ToolUsedEvent { + diff: ex.diff.clone(), + added: ex.added, + removed: ex.removed, + image: ex.image.clone(), + shot: ex.live_shot.clone(), + ..done_ev() + }; + cb(StreamEvent::ToolUsed(fill_tool_result( + host, ev, &ex.result, + ))); + // Plafond pour le MODÈLE seulement : l'UI a reçu le résultat. + push_result(&mut messages, &mut extra, cap_tool_result(&ex.result)); + if let Some(img) = ex.relay { + messages.push(img); // éphémère : jamais dans l'historique + } + } + // Compaction EN COURS DE TOUR, sur le contexte réel du dernier appel + // plus les résultats de cette étape (le moteur ne les a pas vus). + let mut used = 0; + let base = out.stats.ctx_after(); + if base > 0 { + used = base + estimate_tokens(&messages[step_start..], &ccfg.echo); + s.last_est = used; + } + if compact_needed(ccfg, &messages, used, s.peak_gen) { + cb(StreamEvent::Compacting(true)); + let phase = CompactPhase::Mid { + used, + rescue: s.disable_tools || s.tool_choice_none, + }; + let c = cancel.or_stop(host.compact(phase, &messages)).await; + cb(StreamEvent::Compacting(false)); + let Some(c) = c else { + return TurnResult { extra, err: None }; + }; + log_compact( + ccfg, + "en-tour", + used, + &messages, + c.as_deref().unwrap_or(&messages), + c.is_some(), + ); + if let Some(c) = c { + s.last_est = 0; + messages = c; + extra.clear(); + new_history(cb, &messages); + } + } + continue; + } + + // Appel d'outil écrit en texte : relance corrective (le texte fautif + // reste affiché, l'historique garde la trace ET la correction). + let snippet = tc.patterns.snippet(content); + if !snippet.is_empty() && s.pattern_retries < MAX_PATTERN_RETRIES && calls_on { + s.pattern_retries += 1; + let bad = with_echo(text_msg("assistant", content), Some(&echo)); + let fix = text_msg("user", retry_corrective(&snippet)); + messages.extend([bad.clone(), fix.clone()]); + extra.extend([bad, fix]); + continue; + } + + if content.trim().is_empty() { + // Coupée par une fenêtre pleine : compacter et rejouer, une fois. + let known = out.stats.prompt_tokens_total + out.stats.gen_tokens; + if out.finish_reason == "length" + && !ep.external + && s.length_replays < 1 + && compact_would_trigger(ccfg, &messages, known) + { + s.length_replays += 1; + cb(StreamEvent::Compacting(true)); + let c = cancel + .or_stop(host.compact(CompactPhase::FullWindow, &messages)) + .await; + cb(StreamEvent::Compacting(false)); + let Some(c) = c else { + return TurnResult { extra, err: None }; + }; + log_compact( + ccfg, + "fenêtre-pleine", + known, + &messages, + c.as_deref().unwrap_or(&messages), + c.is_some(), + ); + if let Some(c) = c { + cb(StreamEvent::DropReasoning); + messages = c; + extra.clear(); + new_history(cb, &messages); + continue; + } + } + if calls_on && s.nudge_count < MAX_NUDGES { + s.nudge_count += 1; + log_ctx(&format!( + "relance « pensé sans agir » n°{} (finish={})", + s.nudge_count, out.finish_reason + )); + cb(StreamEvent::DropReasoning); + let nudge = if s.nudge_count > 1 { + THINK_NUDGE_STUCK + } else { + THINK_NUDGE_FIRST + }; + append_nudge(&mut messages, &mut extra, nudge); + continue; + } + cb(StreamEvent::Content(format!( + "_(le modèle n'a pas produit de réponse — finish: {})_", + out.finish_reason + ))); + } + // Raisonnement de la réponse finale, rangé avec elle (jamais sans texte). + if !echo.text.is_empty() && !content.trim().is_empty() { + cb(StreamEvent::Echo(echo)); + } + return TurnResult { extra, err: None }; + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs new file mode 100644 index 0000000..50d6505 --- /dev/null +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -0,0 +1,500 @@ +//! Tests de la boucle d'un tour, sur un faux hôte aux réponses scriptées. + +use super::*; +use crate::chat::compact::CompactConfig; +use crate::chat::types::ToolFunction; +use crate::chat::wire::resolve_chat_endpoint; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::Mutex; + +/// Une réponse scriptée du moteur. +enum Script { + Ok(Vec), + Status(u16, String), + Net(NetKind), + /// Corps qui s'interrompt sur une erreur de lecture après ces lignes. + Cut(Vec), +} + +#[derive(Default)] +struct Fake { + script: Mutex>, + bodies: Mutex>, + tools: Mutex>, + execs: Mutex>, + compacted: Mutex>>, + phases: Mutex>, + queued: Mutex>, + efforts: Mutex>, + /// L'arrêt à déclencher pendant l'exécution d'un outil. + stop_on_exec: Mutex>>, +} + +impl Fake { + fn new(script: Vec