From a005626e66900947916576121e076667deccf932 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:13:20 +0000 Subject: [PATCH] =?UTF-8?q?Lot=2031=20:=20slots=20du=20moteur=20pour=20le?= =?UTF-8?q?=20tour=20tenu=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIDE_SLOT et SLOT_PERSIST ne gardent plus la conversation au Go. La machinerie reste au Go (configuration, requêtes en vol, tampon du slot) ; la façade la consulte avant chaque requête au moteur local. Go : - /internal/v1/turn/slot (facade_slots.go) rend engineSlotFor et, pour une étape de tour, recharge d'abord l'état gardé de la discussion (slotPersistRestore). La sauvegarde à la bascule voit les étapes de la façade : /internal/v1/oai/served marque le slot (engineMarkMain). - facadeChatBlocker : SIDE_SLOT et SLOT_PERSIST retirés. Rust : - TurnHost::engine_slot : l'id_slot posé dans chaque requête (0 pour le fil de la discussion, 1 pour la vérification), le ticket « en vol » ouvert sur le second slot. - Cache KV plein en plein calcul (sideSlotPoolError) : rejoué deux fois tel quel, sans compaction, puis l'erreur. - Résumés de compaction comptés en vol comme postSummary : transcription sur le second slot, continuation sur le slot 0. - La passe de vérification est un travail annexe (engineSideJob) : le Go efface le slot à la fin s'il a servi (écart du lot 7 levé). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...10-16-rust-migration-lot31-slots-design.md | 88 ++++++++++++ internal/loki/facade_chat.go | 12 +- internal/loki/facade_chat_test.go | 4 +- internal/loki/facade_slots.go | 39 +++++ internal/loki/facade_slots_test.go | 129 +++++++++++++++++ internal/loki/web_internal.go | 2 + rust/loki-rs/src/chat/generate.rs | 7 +- rust/loki-rs/src/chat/host.rs | 134 ++++++++++++++---- rust/loki-rs/src/chat/turn.rs | 65 ++++++++- rust/loki-rs/src/chat/turn/tests.rs | 96 +++++++++++++ rust/loki-rs/src/chat/verify.rs | 35 ++++- rust/loki-rs/tests/common/fakego.rs | 23 ++- rust/loki-rs/tests/lot6.rs | 101 ++++++++++++- 14 files changed, 679 insertions(+), 58 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md create mode 100644 internal/loki/facade_slots.go create mode 100644 internal/loki/facade_slots_test.go diff --git a/README.md b/README.md index 1456c0a..ff933db 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` | | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | -| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go et honore les motifs de relance personnalisés (`retry_patterns`). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `SIDE_SLOT`, `SLOT_PERSIST` ou `PREWARM`, le Go la garde de toute façon (décidé au démarrage) | Rust | +| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES` ou `PREWARM`, le Go la garde de toute façon (décidé au démarrage) | Rust | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md new file mode 100644 index 0000000..a87fc52 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md @@ -0,0 +1,88 @@ +# Lot 31 — Slots du moteur pour le tour tenu par la façade + +## Contexte + +Trois clés opt-in du moteur local gardaient la conversation au Go +(`facadeChatBlocker`), faute d'être honorées par la façade : + +- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes. + Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la + discussion (étape d'un tour, résumé en continuation), 1 pour le reste + (vérification, résumé sur transcription). Un cache KV à court de place en + plein calcul est rejoué tel quel, sans compaction : c'est la faute du + voisin, pas un prompt trop long. +- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est + gardé sur disque à la bascule de preset, et rechargé avant la première + requête de la discussion au retour. + +Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après +la passe de vérification du mode Code, le Go efface le slot local s'il a +servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses +requêtes. + +Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au +moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`). +La machinerie peut donc rester au Go ; il suffit que la façade la consulte. + +## Le lot + +### Go + +- `/internal/v1/turn/slot` (`facade_slots.go`) : + - entrée : `{"conv":"…","main":true}` ; + - sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ; + - avec `main` et `conv`, recharge d'abord l'état gardé de la discussion + (`slotPersistRestore`), comme la boucle Go avant une requête d'un tour. +- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès + que le moteur accepte une étape de son tour : `/internal/v1/oai/served` + appelle `engineMarkMain`. +- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation. + +### Rust + +- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose + l'`id_slot` rendu dans le corps. +- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot + en service et un moteur local : + - deux fois au plus, après l'attente des reprises réseau ; + - puis l'erreur telle quelle ; + - ni compaction ni réduction. +- `GoTurnHost` : + - moteur externe : aucun slot ; + - étape d'un tour : `main` avec la discussion (donc le rechargement + `SLOT_PERSIST`) ; + - vérification : le second slot, et le ticket « en vol » ouvert sur lui + (`side`). +- `EngineSummarizer` : un résumé est désormais compté en vol, comme + `postSummary`. Il part sur le second slot s'il est fait sur la + transcription, sur le slot 0 s'il est fait en continuation. +- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`. + À la fin, le Go efface le slot si le moteur a servi. + +## Tests + +- Go (`facade_slots_test.go`) : + - sans la clé, aucun slot et `/props` jamais lu ; + - fil de la discussion sur le 0, le reste sur le 1 ; + - second slot hors service ; + - rechargement une seule fois, pour la bonne discussion et une étape de tour + seulement ; + - une étape de la façade acceptée par le moteur suffit à la sauvegarde ; + - corps illisible, GET refusé. +- La boucle du tour : + - `id_slot` dans chaque requête, slot demandé avant chaque envoi ; + - cache KV plein : rejoué deux fois sans compaction, puis l'erreur ; + - sans slot, ou moteur externe : un débordement comme un autre ; + - motif `sideSlotPoolError` repris des cas du Go. +- Le vrai hôte (`tests/lot6.rs`) : + - contrat du mode Code : le tour sur le 0, la vérification sur le 1 ; + - tickets ouverts sur le second slot, travail annexe ouvert puis rendu ; + - résumés : transcription sur le 1, continuation sur le 0, tickets rendus. + +## Écarts assumés + +- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go + relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent + le même cache de dix secondes. +- Le slot est demandé avant de composer la requête, et non juste avant de + l'envoyer : rien ne se passe entre les deux. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 69b312c..832a853 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -48,19 +48,17 @@ func facadeChat() bool { // facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y // en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet -// figé, images gardées, slots du moteur, préchauffage). NUDGE_IN_TOOL n'en est -// plus (lot 23) : la façade a sa sonde de gabarit ; ni les motifs de relance -// personnalisés (retry_patterns, lot 30) : la façade les lit et les compile. +// figé, images gardées, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) : +// la façade a sa sonde de gabarit ; ni les motifs de relance personnalisés +// (retry_patterns, lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et +// SLOT_PERSIST (lot 31) : la façade demande son slot avant chaque requête +// (facade_slots.go). func facadeChatBlocker(cfg map[string]string) string { switch { case projSnapEnabled(cfg): return "PROJ_SNAPSHOT" case keepTurnImagesOn(cfg): return "KEEP_TURN_IMAGES" - case sideSlotOn(cfg): - return "SIDE_SLOT" - case slotPersistOn(cfg): - return "SLOT_PERSIST" case prewarmMode(cfg) != "": return "PREWARM" } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index d806341..1bbdd39 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -298,8 +298,8 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu()) } for k, want := range map[string]string{ - "PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT", - "SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM", + "PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "", + "SLOT_PERSIST": "", "PREWARM": "PREWARM", } { if got := facadeChatBlocker(map[string]string{k: "on"}); got != want { t.Errorf("%s : %q", k, got) diff --git a/internal/loki/facade_slots.go b/internal/loki/facade_slots.go new file mode 100644 index 0000000..2450cc2 --- /dev/null +++ b/internal/loki/facade_slots.go @@ -0,0 +1,39 @@ +package loki + +import ( + "net/http" +) + +// facade_slots.go — les slots du moteur pour le tour tenu par la façade (lot 31 +// de la migration). La machinerie reste ici, où vivent la configuration, le +// compteur des requêtes en vol et le tampon du slot de la discussion +// (llm_slots.go) ; la façade demande, avant chaque requête au moteur local, +// le slot où l'envoyer. +// +// - SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0, le +// reste (vérification, résumé sur transcription) sur le 1, tant que le +// second slot est en service ; sinon aucun id_slot. +// - SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule de +// preset est rechargé avant la première requête de la discussion, si tout +// concorde. La sauvegarde, elle, se fait à la bascule (déjà ici) : le slot +// porte la discussion dès que le moteur a accepté une étape de son tour +// (/internal/v1/oai/served, engineMarkMain). + +// handleInternalTurnSlot : POST {"conv":"…","main":true} → {"slot":0|1|-1}. +// main : requête du fil de la discussion (étape d'un tour, résumé en +// continuation) ; conv : la discussion d'une étape de tour, pour le +// rechargement SLOT_PERSIST — vide pour un résumé. -1 : aucun id_slot. +func handleInternalTurnSlot(w http.ResponseWriter, r *http.Request) { + var in struct { + Conv string `json:"conv"` + Main bool `json:"main"` + } + if !readInternalJSON(w, r, &in) { + return + } + ep := resolveChatEndpoint() + if in.Main && in.Conv != "" { + slotPersistRestore(ep, in.Conv) + } + sendJSON(w, http.StatusOK, map[string]any{"slot": engineSlotFor(ep, in.Main)}) +} diff --git a/internal/loki/facade_slots_test.go b/internal/loki/facade_slots_test.go new file mode 100644 index 0000000..f79c938 --- /dev/null +++ b/internal/loki/facade_slots_test.go @@ -0,0 +1,129 @@ +package loki + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Le slot d'une requête de la façade : aucun sans la clé (et /props jamais +// lu), le fil de la discussion sur le 0 et le reste sur le 1 quand le second +// slot est en service. +func TestFacadeTurnSlot(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + slot := func(body string) (int, float64) { + w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", body) + var out struct { + Slot float64 `json:"slot"` + } + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out.Slot + } + prev := sideSlotProps + sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false } + for _, b := range []string{`{"conv":"c1","main":true}`, `{"main":false}`} { + if code, s := slot(b); code != 200 || s != -1 { + t.Fatalf("sans la clé %s : %d %v", b, code, s) + } + } + sideSlotProps = prev + if err := SetConfigKey("SIDE_SLOT", "on"); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + stubSideProps(t, 2, 8192) + if _, s := slot(`{"conv":"c1","main":true}`); s != 0 { + t.Fatalf("étape du tour : slot %v", s) + } + if _, s := slot(`{"main":false}`); s != 1 { + t.Fatalf("vérification : slot %v", s) + } + if _, s := slot(`{"main":true}`); s != 0 { + t.Fatalf("résumé en continuation : slot %v", s) + } + // Second slot hors service : plus d'id_slot. + stubSideProps(t, 1, 8192) + if _, s := slot(`{"main":false}`); s != -1 { + t.Fatalf("un seul slot : %v", s) + } + if code, _ := slot(`{`); code != 400 { + t.Fatalf("corps illisible : %d", code) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/turn/slot", ""); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +} + +// SLOT_PERSIST : l'état gardé à la bascule est rechargé avant la première +// étape du tour de CETTE discussion, une seule fois ; jamais pour une autre +// discussion, une requête annexe ou un résumé. +func TestFacadeTurnSlotRecharge(t *testing.T) { + m, home := persistSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + snap := slotPersistPrepare() + if snap == nil { + t.Fatal("rien à garder") + } + snap.save() + name := slotPersistName(persistTestKey, "c1") + for _, b := range []string{ + `{"conv":"autre","main":true}`, + `{"conv":"c1","main":false}`, + `{"main":true}`, + `{"conv":"c1","main":true}`, + `{"conv":"c1","main":true}`, + } { + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", b); w.Code != 200 { + t.Fatalf("%s : %d", b, w.Code) + } + } + want := []string{"save tmp-" + name, "restore " + name} + if got := m.got(); !reflect.DeepEqual(got, want) { + t.Fatalf("appels %v, attendu %v", got, want) + } + if _, err := os.Stat(filepath.Join(home, "slots", name)); !os.IsNotExist(err) { + t.Fatal("fichier gardé après la tentative") + } +} + +// Une étape de tour de la façade acceptée par le moteur marque le slot : +// c'est ce que la sauvegarde SLOT_PERSIST relit à la bascule. +func TestFacadeTurnSlotSauvegarde(t *testing.T) { + m, home := persistSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + engineGate.mu.Lock() + engineGate.main = engineMainStamp{} + engineGate.mu.Unlock() + if slotPersistPrepare() != nil { + t.Fatal("slot sans discussion : rien à garder") + } + w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c2"}`) + var b struct { + Ticket uint64 `json:"ticket"` + } + if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || b.Ticket == 0 { + t.Fatalf("begin : %s", w.Body.String()) + } + tk := `{"ticket":` + jsonNum(b.Ticket) + `}` + appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk) + appelOAIInterne(t, "POST", "/internal/v1/oai/end", tk) + snap := slotPersistPrepare() + if snap == nil { + t.Fatal("étape de la façade non vue par la sauvegarde") + } + snap.save() + name := slotPersistName(persistTestKey, "c2") + if _, err := os.Stat(filepath.Join(home, "slots", name)); err != nil { + t.Fatalf("état non gardé : %v (%v)", err, m.got()) + } +} + +func jsonNum(n uint64) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 1f5c7cf..d7c76f7 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -73,6 +73,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIServed(w, r) case "/internal/v1/turn/perf": handleInternalTurnPerf(w, r) + case "/internal/v1/turn/slot": + handleInternalTurnSlot(w, r) case "/internal/v1/tool/exec": handleInternalToolExec(w, r) case "/internal/v1/tool/context": diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index dc21bc2..5ed7626 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -26,7 +26,9 @@ //! (refusés par `/turn/view`), préchauffage. La télémétrie des complétions //! (tours, vérifications, résumés) rejoint l'anneau du Go //! (`/internal/v1/turn/perf`) ; les motifs `retry_patterns` personnalisés -//! sont lus au démarrage ([`retry_patterns`]). +//! sont lus au démarrage ([`retry_patterns`]). Chaque requête au moteur local +//! part sur le slot que rend le Go (SIDE_SLOT, rechargement SLOT_PERSIST, +//! lot 31). use std::collections::VecDeque; use std::future::Future; @@ -510,7 +512,7 @@ impl ChatService { cfg: cfg.clone(), patterns: self.shared.patterns.clone(), port, - perf: Some((store.clone(), self.sessions.conv().id())), + go: Some((store.clone(), self.sessions.conv().id())), }; TurnEnv { cfg, @@ -820,6 +822,7 @@ impl ChatService { }, queued: Box::new(move || me.drain_queued(epoch)), perf_kind: super::perf::KIND_MAIN, + side_slot: Default::default(), }; // Chaque événement devient un delta du journal. diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 6073eb4..a3af40f 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -11,7 +11,11 @@ //! ([`EngineSummarizer`]), blocs archivés pour recall par le Go //! ([`StoreRecall`]) ; //! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go -//! (déchiffrées), gardées en cache comme `chatImageDataURL`. +//! (déchiffrées), gardées en cache comme `chatImageDataURL` ; +//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go +//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état +//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol, +//! sur ce slot, jusqu'à la fin de sa lecture. //! //! Écarts assumés : le résumé en continuation (COMPACT_CONTINUATION) demande //! l'état du slot du moteur, tenu par le Go — `slot_holds` rend faux et la @@ -20,7 +24,9 @@ use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use futures_util::StreamExt; use serde_json::{Map, Value, json}; @@ -170,13 +176,28 @@ pub struct EngineSummarizer { pub cfg: HashMap, pub patterns: Arc, pub port: u16, - /// Télémétrie des résumés (kind=compact), rattachée à cette discussion : - /// `None` sans base (tests). - pub perf: Option<(Store, String)>, + /// Le Go et la discussion des résumés : télémétrie (kind=compact), + /// requêtes en vol et slot du moteur. `None` sans base (tests). + pub go: Option<(Store, String)>, } impl EngineSummarizer { - async fn post(&self, payload: Value) -> Result { + /// `postSummary`. `main` : le résumé prolonge le prompt du slot de la + /// discussion (continuation) ; sinon c'est un prompt neuf, sur le second + /// slot quand il est en service (SIDE_SLOT). + async fn post(&self, mut payload: Value, main: bool) -> Result { + let mut _gate = None; + if !self.ep.external + && let Some((store, _)) = &self.go + { + let slot = engine_slot_for(store, "", main).await; + if let Some(n) = slot + && let Some(o) = payload.as_object_mut() + { + o.insert("id_slot".into(), Value::from(n)); + } + _gate = engine_begin(store, "", slot == Some(1)).await; + } let body = serde_json::to_vec(&payload).unwrap_or_default(); let resp = self .http @@ -211,7 +232,7 @@ impl EngineSummarizer { Err(SummaryError::Other(e)) => e == super::compact::ERR_EMPTY_SUMMARY, Err(_) => false, }; - if decoded && let Some((store, conv)) = &self.perf { + if decoded && let Some((store, conv)) = &self.go { let (complete, stats) = super::perf::wire_stats(&raw); let rec = PerfRec { complete, @@ -247,7 +268,7 @@ impl Summarizer for EngineSummarizer { { o.remove("chat_template_kwargs"); } - self.post(p).await + self.post(p, false).await } async fn summarize_continuation( @@ -274,7 +295,7 @@ impl Summarizer for EngineSummarizer { o.remove("stream_options"); o.insert("max_tokens".into(), json!(req.max_tokens)); } - self.post(p).await + self.post(p, true).await } fn slot_holds(&self, _conv: &str) -> bool { @@ -340,11 +361,68 @@ pub struct GoTurnHost { /// Nature des complétions pour la télémétrie : `main` (tour de la /// discussion), `verify` (passe de vérification du mode Code). pub perf_kind: &'static str, + /// La requête qui part va sur le second slot (posé par `engine_slot`, + /// lu par `send` pour le ticket « en vol »). + pub side_slot: AtomicBool, +} + +/// Délai d'une demande de slot : le rechargement SLOT_PERSIST relit jusqu'à +/// 8 Gio (`slotPersistRestoreTimeout`, deux minutes). +const SLOT_ASK_TIMEOUT: Duration = Duration::from_secs(180); + +/// `engineSlotFor` demandé au Go (`/internal/v1/turn/slot`). `main` : fil de +/// la discussion ; `conv` (étape d'un tour seulement) : le Go recharge +/// d'abord l'état SLOT_PERSIST de cette discussion. Go injoignable : aucun +/// `id_slot`, la requête d'avant la clé. +pub async fn engine_slot_for(store: &Store, conv: &str, main: bool) -> Option { + match store + .internal_post_within( + "/internal/v1/turn/slot", + &json!({ "conv": conv, "main": main }), + SLOT_ASK_TIMEOUT, + ) + .await + { + Ok((code, v)) if code.is_success() => v["slot"].as_i64().and_then(|n| u8::try_from(n).ok()), + Ok((code, v)) => { + eprintln!("[loki-rs] moteur : slot non demandé ({code} {v})"); + None + } + Err(e) => { + eprintln!("[loki-rs] moteur : slot non demandé ({e})"); + None + } + } +} + +/// Ouvre un ticket « en vol » auprès du Go (`engineRequestBeginSide`) : le +/// slot de la discussion n'est pas effacé pendant la requête, et un +/// préchauffage est annulé. `main_conv` : étape d'un tour de cette +/// discussion ; `side` : requête sur le second slot. `None` si le Go ne +/// répond pas — la requête part quand même. +pub async fn engine_begin(store: &Store, main_conv: &str, side: bool) -> Option { + match store + .internal_json( + hyper::Method::POST, + "/internal/v1/oai/begin", + Some(&json!({ "main_conv": main_conv, "side": side })), + ) + .await + { + Ok(v) => v["ticket"].as_u64().map(|ticket| GateTicket { + store: store.clone(), + ticket, + }), + Err(e) => { + eprintln!("[loki-rs] moteur : requête non comptée en vol ({e})"); + None + } + } } /// Rend le ticket « en vol » au Go quand la réponse du moteur est lue ou /// abandonnée (`engineRequestBeginSide` … `end`). -struct GateTicket { +pub struct GateTicket { store: Store, ticket: u64, } @@ -367,34 +445,14 @@ impl Drop for GateTicket { } impl GoTurnHost { - /// Ouvre un ticket « en vol » auprès du Go : le slot de la discussion - /// n'est pas effacé pendant la requête, et un préchauffage est annulé. - /// `None` si le Go ne répond pas — la requête part quand même. async fn gate_begin(&self) -> Option { let main_conv = if self.perf_kind == super::perf::KIND_MAIN { self.conv_id.as_str() } else { "" }; - match self - .shared - .store - .internal_json( - hyper::Method::POST, - "/internal/v1/oai/begin", - Some(&json!({ "main_conv": main_conv })), - ) - .await - { - Ok(v) => v["ticket"].as_u64().map(|ticket| GateTicket { - store: self.shared.store.clone(), - ticket, - }), - Err(e) => { - eprintln!("[loki-rs] moteur : requête non comptée en vol ({e})"); - None - } - } + let side = self.side_slot.load(Ordering::SeqCst); + engine_begin(&self.shared.store, main_conv, side).await } } @@ -646,6 +704,20 @@ impl TurnHost for GoTurnHost { self.shared.echo_refusals.refuse(model); } + async fn engine_slot(&self, ep: &ChatEndpoint) -> Option { + if ep.external { + self.side_slot.store(false, Ordering::SeqCst); + return None; + } + // Étape d'un tour de la discussion : le slot 0, après rechargement de + // l'état gardé (SLOT_PERSIST). Vérification : le second slot. + let main = self.perf_kind == super::perf::KIND_MAIN; + let conv = if main { self.conv_id.as_str() } else { "" }; + let slot = engine_slot_for(&self.shared.store, conv, main).await; + self.side_slot.store(slot == Some(1), Ordering::SeqCst); + slot + } + fn cache_hint(&self) -> Option { None } diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 81a994e..3856d31 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -27,12 +27,16 @@ //! [`TurnHost::perf_record`]) ; la dernière statistique d'une complétion //! complète porte la nature et la perte de cache qu'il a calculées. //! +//! Slots du moteur (lot 31) : chaque requête part sur le slot que rend +//! [`TurnHost::engine_slot`] (SIDE_SLOT : 0 pour le fil de la discussion, 1 +//! pour une vérification ; aucun sans second slot en service), et un cache KV +//! à court de place en plein calcul est rejoué tel quel. Le rechargement +//! SLOT_PERSIST et le comptage des requêtes en vol passent par le Go (l'hôte, +//! autour de [`TurnHost::engine_slot`] et de [`TurnHost::send`]). +//! //! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) : -//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV -//! plein »), SLOT_PERSIST, préchauffage. Avec l'une de ces clés, le Go garde -//! la conversation (`facadeChatBlocker`, décidé au démarrage). Le comptage -//! des requêtes en vol du moteur passe par le Go (l'hôte autour de -//! [`TurnHost::send`]). +//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, préchauffage. Avec l'une de ces clés, le +//! Go garde la conversation (`facadeChatBlocker`, décidé au démarrage). use std::collections::HashMap; use std::future::Future; @@ -189,6 +193,12 @@ pub trait TurnHost: Send + Sync { fn preflight(&self) -> Option { None } + /// Slot du moteur où envoyer la requête qui part (`engineSlotFor`) ; + /// `None` : aucun `id_slot`. Appelé avant chaque requête, moteur local ou + /// non : l'hôte y recharge aussi l'état SLOT_PERSIST de la discussion. + fn engine_slot(&self, _ep: &ChatEndpoint) -> impl Future> + Send { + async { None } + } /// Télémétrie d'une complétion (`perfRecord`) : la rend au Go, qui la /// range dans l'anneau de `/api/perf/summary` et dit quoi poser sur la /// copie finale des stats (nature, jetons perdus, alerte). @@ -324,6 +334,20 @@ fn log_stream_retry(attempt: u32, cause: &str) { ); } +/// `sideSlotPoolError` : « Context size has been exceeded. » sans le nombre +/// de jetons de la requête n'est pas un prompt trop long, c'est le cache KV +/// qui a manqué de place pendant le calcul — avec deux slots, la faute du +/// voisin, pas de la conversation. +pub fn side_slot_pool_error(msg: &str) -> bool { + static REQ_EXCEEDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"(?i)exceeds the available context size|request \([0-9]+ tokens\)") + .expect("motif fixe") + }); + msg.to_lowercase() + .contains("context size has been exceeded") + && !REQ_EXCEEDS.is_match(msg) +} + fn text_msg(role: &str, text: impl Into) -> Message { Message { role: role.into(), @@ -400,6 +424,8 @@ struct Loop { budget_nudges: i64, /// `perfIter` : complétions de ce tour, complètes ou non. perf_iter: i64, + /// Rejeux « cache KV plein » (SIDE_SLOT). + pool_retries: u32, } /// `runChatLoop` : le tour, de la première requête à la réponse finale. @@ -467,9 +493,10 @@ pub async fn run_turn( 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()); host.prepare_images(&messages).await; + let slot = host.engine_slot(ep).await; let (body, echo_sent, prefix) = { let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol); - let payload = build_chat_payload( + let mut payload = build_chat_payload( ep, &sent, tc.temperature, @@ -484,6 +511,12 @@ pub async fn run_turn( &tc.cfg, &|r| host.image_data_url(r), ); + // SIDE_SLOT : le slot rendu par l'hôte ; aucun, la requête d'avant. + if let Some(n) = slot + && let Some(o) = payload.as_object_mut() + { + o.insert("id_slot".into(), Value::from(n)); + } ( serde_json::to_vec(&payload).unwrap_or_default(), echo_sent, @@ -532,6 +565,26 @@ pub async fn run_turn( effort = fixed; continue; } + // SIDE_SLOT : un cache KV à court de place en plein calcul n'est + // pas un prompt trop long. Rejouée telle quelle, sans compaction ni + // réduction — deux fois au plus, le temps que l'autre slot finisse, + // puis l'erreur telle quelle. Sans second slot en service, rien. + if !ep.external && slot.is_some() && side_slot_pool_error(&msg) { + if s.pool_retries < 2 { + s.pool_retries += 1; + log_ctx( + "cache KV plein pendant le calcul (deux slots) : requête rejouée telle quelle, sans compaction", + ); + if cancel.backoff(s.pool_retries - 1).await { + continue; + } + } + return fail( + cb, + extra, + format!("llama-server a renvoyé {status} : {msg}"), + ); + } if echo_sent > 0 && echo_template_error(status, &msg) { s.echo_off_turn = true; s.echo_tpl_retry = true; diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs index f17ac64..ee756fa 100644 --- a/rust/loki-rs/src/chat/turn/tests.rs +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -39,6 +39,9 @@ struct Fake { /// Télémétrie rangée ; et ce que le Go en tire. perfs: Mutex>, marks: Mutex, + /// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues. + slot: Mutex>, + slot_asks: Mutex, } impl Fake { @@ -150,6 +153,10 @@ impl TurnHost for Fake { self.perfs.lock().unwrap().push(rec); self.marks.lock().unwrap().clone() } + async fn engine_slot(&self, _ep: &ChatEndpoint) -> Option { + *self.slot_asks.lock().unwrap() += 1; + *self.slot.lock().unwrap() + } } fn content(s: &str) -> String { @@ -375,6 +382,95 @@ async fn erreur_500_tool_choice_none_puis_outils_retires() { assert!(host.completions.lock().unwrap().is_empty()); } +/// SIDE_SLOT : chaque requête porte le slot que rend l'hôte, demandé avant +/// chaque envoi ; sans slot, aucun `id_slot`. +#[tokio::test] +async fn slot_du_moteur_dans_chaque_requete() { + let host = Fake::new(vec![ + tool_step("c1", "glob", r#"{"pattern":"*.go"}"#), + answer("Trouvé"), + ]); + *host.slot.lock().unwrap() = Some(1); + let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!( + ( + host.body(0)["id_slot"].clone(), + host.body(1)["id_slot"].clone() + ), + (json!(1), json!(1)) + ); + assert_eq!(*host.slot_asks.lock().unwrap(), 2); + let host = Fake::new(vec![answer("ok")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert!(host.body(0).get("id_slot").is_none()); +} + +/// Cache KV plein en plein calcul avec deux slots : rejouée telle quelle, +/// deux fois au plus, sans compaction ; au-delà, l'erreur. Sans second slot, +/// c'est un débordement comme un autre. +#[tokio::test(start_paused = true)] +async fn cache_kv_plein_rejoue_sans_compaction() { + let plein = r#"{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}"#; + let host = Fake::new(vec![ + Script::Status(500, plein.into()), + Script::Status(500, plein.into()), + answer("ok"), + ]); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(text(&evs), "ok"); + assert_eq!(host.sent(), 3); + assert!(host.phases.lock().unwrap().is_empty(), "pas de compaction"); + assert_eq!(host.body(2)["messages"], host.body(0)["messages"]); + // Trois fois de suite : l'erreur telle quelle, sans compaction. + let host = Fake::new((0..3).map(|_| Script::Status(500, plein.into())).collect()); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + // Le corps du refus tel quel, comme le Go. + assert_eq!(r.err, Some(format!("llama-server a renvoyé 500 : {plein}"))); + assert_eq!(host.sent(), 3); + assert!(host.phases.lock().unwrap().is_empty()); + // Sans second slot : compaction réactive. + let host = Fake::new(vec![Script::Status(500, plein.into()), answer("ok")]); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(*host.phases.lock().unwrap(), [CompactPhase::Reactive]); + // Moteur externe : jamais (le slot vient d'un moteur local). + let host = Fake::new(vec![Script::Status(500, plein.into()), answer("ok")]); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let mut tc = config(&[]); + tc.ep.external = true; + let (r, _) = run(&host, &tc, &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(*host.phases.lock().unwrap(), [CompactPhase::Reactive]); +} + +#[test] +fn cache_kv_plein_ou_prompt_trop_long() { + for (msg, want) in [ + ( + r#"{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}"#, + true, + ), + ( + r#"{"error":{"message":"request (40000 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error"}}"#, + false, + ), + ("the request exceeds the available context size", false), + ("CONTEXT SIZE HAS BEEN EXCEEDED, Request (12 TOKENS)", false), + ("failed to parse tool call", false), + ] { + assert_eq!(side_slot_pool_error(msg), want, "{msg}"); + } +} + #[tokio::test] async fn debordement_compaction_reactive() { let host = Fake::new(vec![ diff --git a/rust/loki-rs/src/chat/verify.rs b/rust/loki-rs/src/chat/verify.rs index 3034b9f..055dca0 100644 --- a/rust/loki-rs/src/chat/verify.rs +++ b/rust/loki-rs/src/chat/verify.rs @@ -12,9 +12,10 @@ //! en base (`chat/crit:`, chiffrés comme le reste) ; le contexte isolé du //! vérificateur vient de `/internal/v1/turn/verifier`. //! -//! Écart assumé : l'isolation du slot du moteur après la passe de -//! vérification (`engineSideJob`, effacement du cache) reste au Go, qui ne -//! voit pas passer les requêtes de la façade. +//! La passe de vérification est un travail annexe (`engineSideJob`, lot 31) : +//! le Go voit passer ses requêtes (tickets « en vol », moteur qui les accepte) +//! et, à la fin, efface le slot local s'il a servi — la discussion se +//! recharge alors depuis le cache RAM du moteur au lieu de se recalculer. use std::sync::Arc; @@ -54,6 +55,29 @@ pub struct Criterion { pub note: String, } +/// `engineSideJob` : début d'un travail annexe auprès du Go ; `None` s'il ne +/// répond pas. +async fn side_job_begin(store: &Store) -> Option { + store + .internal_post("/internal/v1/engine/sidejob", &json!({})) + .await + .ok() + .and_then(|(_, v)| v.get("id").and_then(Value::as_i64)) +} + +/// Fin du travail annexe. Les requêtes servies ont déjà été comptées une à +/// une (`/internal/v1/oai/served`) : rien à ajouter. +async fn side_job_end(store: &Store, id: Option) { + if let Some(id) = id { + let _ = store + .internal_post( + "/internal/v1/engine/sidejob", + &json!({ "end": id, "served": false }), + ) + .await; + } +} + /// `critList` : les critères de la discussion (vide si absents ou illisibles, /// mémoire verrouillée comprise). pub async fn crit_list(store: &Store, conv_id: &str) -> Vec { @@ -277,8 +301,12 @@ impl ChatService { ..Default::default() }, ]; + // Travail annexe : le Go efface le slot à la fin s'il a servi + // (les requêtes de la passe le lui disent en passant). + let side = side_job_begin(&self.shared.store).await; self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run) .await; + side_job_end(&self.shared.store, side).await; } other => { let e = match other { @@ -434,6 +462,7 @@ impl ChatService { } else { super::perf::KIND_MAIN }, + side_slot: Default::default(), }; let mut out = PassOut::default(); let mut cb = |ev: StreamEvent| -> bool { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 088491e..32d46eb 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -127,6 +127,11 @@ pub struct FakeGo { pub service_os_fail: Mutex>, /// /internal/v1/turn/perf reçus (corps entiers). pub perf_records: Mutex>, + /// /internal/v1/turn/slot : second slot en service (le fil de la + /// discussion sur le 0, le reste sur le 1 ; sinon aucun) ; demandes + /// reçues (« conv main »). + pub turn_side_live: Mutex, + pub turn_slots: Mutex>, } impl FakeGo { @@ -666,10 +671,11 @@ async fn handle(st: Arc, req: Request) -> Response let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); if let Some(conv) = v["main_conv"].as_str() { + let side = if v["side"] == true { " side" } else { "" }; st.oai_log .lock() .unwrap() - .push(format!("begin main_conv={conv}")); + .push(format!("begin main_conv={conv}{side}")); } else { st.oai_log.lock().unwrap().push(format!( "begin read={} side={} post_v1={}", @@ -681,6 +687,21 @@ async fn handle(st: Arc, req: Request) -> Response } resp(200, r#"{"ticket":7}"#) } + "/internal/v1/turn/slot" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let main = v["main"] == true; + st.turn_slots + .lock() + .unwrap() + .push(format!("{} {main}", v["conv"].as_str().unwrap_or(""))); + let slot = match (*st.turn_side_live.lock().unwrap(), main) { + (false, _) => -1, + (true, true) => 0, + (true, false) => 1, + }; + resp(200, serde_json::json!({ "slot": slot }).to_string()) + } "/internal/v1/oai/served" => { 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 d8ca1d8..08e02f5 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -680,7 +680,7 @@ async fn tour_complet_par_le_go() { cfg: cfg.clone(), patterns: shared.patterns.clone(), port: 8080, - perf: None, + go: None, }, compact: TurnCompact { cfg: CompactConfig::default(), @@ -693,6 +693,7 @@ async fn tour_complet_par_le_go() { }, queued: Box::new(Vec::new), perf_kind: "main", + side_slot: Default::default(), }; let bash = Tool { kind: "function".into(), @@ -1056,7 +1057,7 @@ async fn hote_et_sonde_de_gabarit() { cfg, patterns: shared.patterns.clone(), port: 1, - perf: None, + go: None, }, compact: TurnCompact { cfg: CompactConfig::default(), @@ -1069,6 +1070,7 @@ async fn hote_et_sonde_de_gabarit() { }, queued: Box::new(Vec::new), perf_kind: "main", + side_slot: Default::default(), } }; let bash = Tool { @@ -1179,6 +1181,7 @@ async fn contrat_verifie_par_la_facade() { .await; let (svc, go, _stop, _t) = service(Some(&url)).await; *go.code.lock().unwrap() = true; + *go.turn_side_live.lock().unwrap() = true; let id = svc.sessions.conv().id(); go.set( "chat", @@ -1193,6 +1196,39 @@ async fn contrat_verifie_par_la_facade() { let seen = seen.lock().unwrap().clone(); assert_eq!(seen.len(), 3); + // SIDE_SLOT (lot 31) : le tour sur le slot 0 (rechargement SLOT_PERSIST + // demandé pour la discussion), la vérification sur le 1, comptée en vol + // sur le second slot ; la passe est un travail annexe pour le Go. + let slots: Vec<_> = seen.iter().map(|(_, b)| b["id_slot"].clone()).collect(); + assert_eq!( + slots, + [ + serde_json::json!(0), + serde_json::json!(1), + serde_json::json!(1) + ] + ); + assert_eq!( + *go.turn_slots.lock().unwrap(), + [format!("{id} true"), " false".into(), " false".into()] + ); + let begins: Vec = go + .oai_log + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("begin")) + .cloned() + .collect(); + assert_eq!( + begins, + [ + format!("begin main_conv={id}"), + "begin main_conv= side".into(), + "begin main_conv= side".into() + ] + ); + assert_eq!(*go.side_jobs.lock().unwrap(), ["begin 1", "end 1 false"]); let v = &seen[1].1; assert_eq!(v["messages"][0]["content"], "VÉRIFICATEUR"); assert_eq!( @@ -1855,7 +1891,7 @@ async fn parite_du_tour_complet() { async fn telemetrie_des_resumes() { use loki_rs::chat::compact::{Summarizer, TranscriptRequest}; let (st, go, _stop, _t) = store().await; - let (url, _seen) = fake_engine(vec![ + let (url, seen) = fake_engine(vec![ serde_json::json!({ "choices": [{"message": {"content": "résumé"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 3000, "completion_tokens": 200}, @@ -1864,6 +1900,10 @@ async fn telemetrie_des_resumes() { .to_string(), r#"{"choices":[]}"#.into(), "pas du json".into(), + serde_json::json!({ + "choices": [{"message": {"content": "suite"}, "finish_reason": "stop"}] + }) + .to_string(), ]) .await; let s = EngineSummarizer { @@ -1877,8 +1917,9 @@ async fn telemetrie_des_resumes() { cfg: Default::default(), patterns: RetryPatterns::builtin(), port: 8080, - perf: Some((st, "c9".into())), + go: Some((st, "c9".into())), }; + *go.turn_side_live.lock().unwrap() = true; let req = || TranscriptRequest { system: "s".into(), transcript: "t".into(), @@ -1890,8 +1931,58 @@ async fn telemetrie_des_resumes() { ); assert!(s.summarize_transcript(req()).await.is_err(), "réponse vide"); assert!(s.summarize_transcript(req()).await.is_err(), "illisible"); + // Lot 31 : la transcription, prompt neuf, sur le second slot, comptée en + // vol sur lui et rendue à la fin ; jamais de rechargement SLOT_PERSIST. + let sent = seen.lock().unwrap().clone(); + assert!(sent.iter().all(|(_, b)| b["id_slot"] == 1), "{sent:?}"); + assert_eq!( + *go.turn_slots.lock().unwrap(), + [" false", " false", " false"] + ); + until(|| { + go.oai_log + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("end")) + .count() + == 3 + }) + .await; + assert_eq!( + go.oai_log + .lock() + .unwrap() + .iter() + .filter(|l| *l == "begin main_conv= side") + .count(), + 3 + ); + // En continuation, il prolonge le prompt de la discussion : le slot 0. + use loki_rs::chat::compact::ContinuationRequest; + let cont = s + .summarize_continuation(ContinuationRequest { + messages: vec![], + tools: vec![], + effort: String::new(), + kwargs: Default::default(), + max_tokens: 500, + conv: "c9".into(), + }) + .await + .unwrap(); + assert_eq!(cont.content, "suite"); + assert_eq!(seen.lock().unwrap()[3].1["id_slot"], 0); + assert_eq!(go.turn_slots.lock().unwrap()[3], " true"); + until(|| { + go.oai_log + .lock() + .unwrap() + .contains(&"begin main_conv=".to_string()) + }) + .await; let perfs = go.perf_records.lock().unwrap().clone(); - assert_eq!(perfs.len(), 2, "la réponse illisible n'est pas rangée"); + assert_eq!(perfs.len(), 3, "la réponse illisible n'est pas rangée"); assert_eq!( perfs[0], serde_json::json!({ "kind": "compact", "conv": "c9", "iter": 0, "complete": true,