Lot 6 : persistance des discussions portée en Rust (chat::persist)

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:<id>, chat/index, toolres/<clé>, 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 <context_update>
  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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 08:50:51 +00:00
1 parent d8e2c3dbf6
commit f15baa79aa
4 files changed
+1518 -6

No files matched your search

+5 -5
View File
@@ -98,7 +98,7 @@ const THINK_CLOSE: &str = "</think>";
// 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 <context_update> (chat_projsnap.go).
const CTX_UPD_OPEN: &str = "<context_update from=\"loki\"";
const CTX_UPD_CLOSE: &str = "</context_update>";
pub(crate) const CTX_UPD_OPEN: &str = "<context_update from=\"loki\"";
pub(crate) const CTX_UPD_CLOSE: &str = "</context_update>";
// ---- configuration -------------------------------------------------------
@@ -391,7 +391,7 @@ pub fn log_compact(
// ---- rappels de loki, images gardées, blocs projet ------------------------
/// `isLokiInjected` : un message `user` venu de loki, pas de l'utilisateur.
fn is_loki_injected(m: &Message) -> 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 <context_update> de tête
/// (seulement un message `user` marqué `ctx_upd`).
fn without_ctx_update(m: &Message) -> Option<Message> {
pub(crate) fn without_ctx_update(m: &Message) -> Option<Message> {
if m.role != "user" || !m.ctx_upd {
return None;
}
+3 -1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+102
View File
@@ -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<u8>) -> 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<Vec<u8>> {
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");
}