From daa2e7e4c1a753b3593ae8a966961752f1e1f4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:02:36 +0000 Subject: [PATCH] =?UTF-8?q?Lot=207=20:=20boucle=20du=20contrat=20du=20mode?= =?UTF-8?q?=20Code=20tenue=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de codeVerifyLoop (rust/loki-rs/src/chat/verify.rs) : à la fin d'un tour de build en mode Code, relances du builder sur les critères encore ouverts, passe de vérification sur un contexte isolé, corrections, abandon dit dans le fil après deux corrections — mêmes deltas que le Go (role, verify_done, mesures ctx_isolated), relances et corrections rangées dans l'historique, vérifications non. Le Go garde les critères (l'outil criteria s'y exécute) : la façade les lit en base (chat/crit:) et reçoit le contexte isolé du vérificateur par POST /internal/v1/turn/verifier (prompt du rôle et faits de la machine, outils fermés, rôle verifier, seul autorisé à marquer passed). Toute compaction en mode Code rend l'état des critères au résumé, comme compactMessages. generate : vue du tour factorisée (turn_view), question à l'utilisateur suivie (une question posée suspend la vérification). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...10-15-rust-migration-lot7-outils-design.md | 16 + internal/loki/web_internal.go | 2 + internal/loki/web_internal_turn.go | 22 + internal/loki/web_internal_turn_test.go | 31 + rust/loki-rs/src/chat/generate.rs | 128 ++-- rust/loki-rs/src/chat/host.rs | 14 +- rust/loki-rs/src/chat/mod.rs | 1 + rust/loki-rs/src/chat/verify.rs | 570 ++++++++++++++++++ rust/loki-rs/tests/common/fakego.rs | 41 +- rust/loki-rs/tests/lot6.rs | 173 ++++++ 10 files changed, 961 insertions(+), 37 deletions(-) create mode 100644 rust/loki-rs/src/chat/verify.rs diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md index 785d321..cc93c9f 100644 --- a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md @@ -55,6 +55,22 @@ retrouve le même journal. Le test Go échoue si le comportement Go change - `mkdir` en échec : le chemin cité est le dossier parent visé, pas le composant qui a échoué. +## Étape 2 — boucle du contrat du mode Code (fait) + +`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un +tour de build en mode Code, relances du builder sur les critères encore +ouverts, passe de vérification sur un contexte isolé (`POST +/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils +fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon +dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`, +`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base +(`chat/crit:`) ; l'outil `criteria` reste au Go. Toute compaction en mode +Code rend leur état au résumé, comme `compactMessages`. + +Écart assumé : l'isolation du slot du moteur après la vérification +(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la +façade. + ## Étapes suivantes 1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 8782b75..4354a19 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -97,6 +97,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalTurnView(w, r) case "/internal/v1/turn/done": handleInternalTurnDone(w, r) + case "/internal/v1/turn/verifier": + handleInternalTurnVerifier(w, r) case "/internal/v1/conv/active": handleInternalConvActive(w, r) case "/internal/v1/conv/dropped": diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go index ebbb630..dadb2e2 100644 --- a/internal/loki/web_internal_turn.go +++ b/internal/loki/web_internal_turn.go @@ -21,6 +21,8 @@ import ( // POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools} // 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go) // POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés) +// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps} +// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier) // POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils) // POST /internal/v1/conv/dropped {"id":…} → 204 (fichiers, jobs, critères, // mode, résultats d'outils ; @@ -109,6 +111,26 @@ func handleInternalTurnView(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusOK, map[string]any{"head": head, "prefix": injectPrefix(caps, tools), "tools": tools}) } +// handleInternalTurnVerifier : ce que runVerifyPass envoie au moteur hors de +// l'historique — le prompt du rôle verifier suivi des faits de la machine, et +// le jeu d'outils fermé de la vérification. caps : celles du tour, rôle +// remplacé (le Go s'y fie pour laisser l'outil criteria marquer « passed »). +func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + } + if !readInternalJSON(w, r, &in) { + return + } + vcaps := in.Caps + vcaps.Role = "verifier" + sendJSON(w, http.StatusOK, map[string]any{ + "system": rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps), + "tools": EnabledTools(vcaps), + "caps": vcaps, + }) +} + func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) { var in struct { Ms int64 `json:"ms"` diff --git a/internal/loki/web_internal_turn_test.go b/internal/loki/web_internal_turn_test.go index b02339b..092d036 100644 --- a/internal/loki/web_internal_turn_test.go +++ b/internal/loki/web_internal_turn_test.go @@ -127,3 +127,34 @@ func TestTourInterneDiscussions(t *testing.T) { t.Fatalf("corps illisible : %d %q", w.Code, w.Body.String()) } } + +// La passe de vérification de la façade reçoit exactement le contexte isolé +// de runVerifyPass : prompt du rôle et faits de la machine, outils fermés, +// rôle verifier (seul autorisé à marquer un critère « passed »). +func TestTourInterneVerificateur(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + got := turnJSON(t, "/internal/v1/turn/verifier", `{"caps":{"Agent":true,"Code":true,"Mem":"off","Role":"builder"}}`, 200) + vcaps := Caps{Agent: true, Code: true, Mem: MemOff, Role: "verifier"} + if got["system"] != rolePrompt("verifier")+"\n\n"+machineSystemPrompt(vcaps) { + t.Fatalf("système : %v", got["system"]) + } + caps, _ := got["caps"].(map[string]any) + if caps["Role"] != "verifier" || caps["Code"] != true { + t.Fatalf("caps : %v", caps) + } + tools, _ := got["tools"].([]any) + var names []string + for _, tl := range tools { + f, _ := tl.(map[string]any)["function"].(map[string]any) + names = append(names, f["name"].(string)) + } + var want []string + for _, tl := range EnabledTools(vcaps) { + want = append(want, tl.Function.Name) + } + if strings.Join(names, ",") != strings.Join(want, ",") || !strings.Contains(strings.Join(names, ","), "criteria") { + t.Fatalf("outils : %v, attendu %v", names, want) + } + turnJSON(t, "/internal/v1/turn/verifier", `{`, 400) +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 8ebbdd7..89efe77 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -18,11 +18,13 @@ //! enregistrée, et la file — abandonnée après un stop, sinon le message //! suivant part (ou la notification Web Push s'il n'y en a pas). //! -//! Écarts assumés (restent au Go, à porter au lot 7) : boucle de -//! vérification du mode Code (`codeVerifyLoop`), critères rendus pour la -//! compaction, PROJ_SNAPSHOT et KEEP_TURN_IMAGES (refusés par -//! `/turn/view`), préchauffage, télémétrie, motifs `retry_patterns` -//! personnalisés. +//! Mode Code : la boucle du contrat (vérification isolée, relances et +//! corrections du builder) suit le tour, avant la compaction de fin +//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé. +//! +//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES +//! (refusés par `/turn/view`), préchauffage, télémétrie, motifs +//! `retry_patterns` personnalisés. use std::collections::VecDeque; use std::future::Future; @@ -146,7 +148,7 @@ struct Queue { recent: VecDeque, } -fn obj(k: &str, v: impl Into) -> Delta { +pub(super) fn obj(k: &str, v: impl Into) -> Delta { let mut d = Map::new(); d.insert(k.into(), v.into()); d @@ -155,13 +157,13 @@ fn obj(k: &str, v: impl Into) -> Delta { type BoxFut = Pin + Send>>; /// Ce qu'un tour lit une fois au départ. -struct TurnEnv { - cfg: std::collections::HashMap, - ep: super::wire::ChatEndpoint, - echo: super::wire::EchoPolicy, - ccfg: CompactConfig, - port: u16, - summarizer: EngineSummarizer, +pub(super) struct TurnEnv { + pub(super) cfg: std::collections::HashMap, + pub(super) ep: super::wire::ChatEndpoint, + pub(super) echo: super::wire::EchoPolicy, + pub(super) ccfg: CompactConfig, + pub(super) port: u16, + pub(super) summarizer: EngineSummarizer, } /// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file. @@ -423,12 +425,17 @@ impl ChatService { let conv = self.sessions.conv(); conv.append_delta(epoch, obj("compacting", true)); let recall = StoreRecall(self.shared.store.clone()); + let criteria = if caps.code { + super::host::live_criteria(&self.shared.store, &conv.id()).await + } else { + String::new() + }; let c = Compactor { cfg: ccfg, summarizer, recall: &recall, memory: &self.shared.compact_memory, - criteria: "", + criteria: &criteria, }; let (compacted, changed) = c.compact_messages_noted(msgs, caps, opt).await; conv.append_delta(epoch, obj("compacting", false)); @@ -456,8 +463,33 @@ impl ChatService { Some(compacted) } + /// `/internal/v1/turn/view` : tête (prompt du preset, consignes, + /// projet), préambule et outils du tour ; `Err` : le message à journaliser. + pub(super) async fn turn_view( + &self, + caps: &Value, + ) -> Result<(Vec, String, Vec), String> { + match self + .shared + .store + .internal_post("/internal/v1/turn/view", &json!({ "caps": caps })) + .await + { + Ok((s, v)) if s.as_u16() == 200 => Ok(( + serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), + v["prefix"].as_str().unwrap_or("").to_string(), + serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), + )), + Ok((_, v)) => Err(v["error"] + .as_str() + .unwrap_or("vue du tour refusée") + .to_string()), + Err(e) => Err(e), + } + } + /// Configuration, point de chat et résumeur d'un tour (lus une fois). - async fn turn_env(&self) -> TurnEnv { + pub(super) async fn turn_env(&self) -> TurnEnv { let store = &self.shared.store; let cfg = store.config().await; let local_key = store.get_str(BK_STATE, "api_key").await; @@ -603,7 +635,7 @@ impl ChatService { ) { let turn_start = Instant::now(); let conv = self.sessions.conv().clone(); - let store = self.shared.store.clone(); + let env = self.turn_env().await; let TurnEnv { cfg, ep, @@ -611,7 +643,15 @@ impl ChatService { ccfg, port, summarizer, - } = self.turn_env().await; + } = &env; + let (cfg, ep, echo, ccfg, port, summarizer) = ( + cfg.clone(), + ep.clone(), + echo.clone(), + ccfg.clone(), + *port, + summarizer.clone(), + ); let local = !ep.external; let conv_id = conv.id(); let agent = prep.cap("Agent"); @@ -680,23 +720,9 @@ impl ChatService { // Vue d'envoi : tête du Go (prompt du preset, consignes, projet), // l'historique sans bloc , le préambule. - let view = store - .internal_post("/internal/v1/turn/view", &json!({ "caps": prep.turn_caps })) - .await; - let (head, prefix, tools) = match view { - Ok((s, v)) if s.as_u16() == 200 => ( - serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), - v["prefix"].as_str().unwrap_or("").to_string(), - serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), - ), - other => { - let e = match other { - Ok((_, v)) => v["error"] - .as_str() - .unwrap_or("vue du tour refusée") - .to_string(), - Err(e) => e, - }; + let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await { + Ok(v) => v, + Err(e) => { conv.append_delta(epoch, obj("error", e)); self.finish(epoch, &cancel, lease, turn_start).await; return; @@ -760,6 +786,7 @@ impl ChatService { let mut final_echo: Option = None; let mut saw_usage = false; let mut turn_peak = 0i64; + let mut asked = false; let mut cb = |ev: StreamEvent| -> bool { match ev { StreamEvent::Err(e) => { @@ -793,6 +820,8 @@ impl ChatService { ); } StreamEvent::Ask(a) => { + // Question structurée (outil ask) : carte à boutons. + asked = true; conv.append_delta( epoch, obj("ask", serde_json::to_value(&a).unwrap_or_default()), @@ -852,7 +881,7 @@ impl ChatService { let msgs = st.messages.clone(); (ctx_now(st, &msgs), st.gen_peak, msgs) }); - let Some((mut ctx_used, peak, msgs)) = after else { + let Some((mut ctx_used, mut peak, mut msgs)) = after else { lease.release().await; return; // reset pendant le tour : il a déjà enregistré le fil vide }; @@ -868,6 +897,35 @@ impl ChatService { ctx_used = est; } + // Boucle du contrat (mode Code) : vérification indépendante des + // critères, corrections, re-vérification — AVANT de rendre la main + // (verify.rs). Rien hors mode Code ou sans critères. + if !cancel.is_cancelled() { + let pe = super::verify::PassEnv { + env: &env, + turn_caps: &prep.turn_caps, + temperature, + reasoning: &reasoning, + ccaps, + conv_id: &conv_id, + }; + let mut run = super::verify::PassRun { + lease: &mut lease, + cancel: &cancel, + cancel_tx: &cancel_tx, + }; + self.code_verify_loop(&pe, epoch, asked, &mut run).await; + let after = conv.with_state_if(epoch, |st| { + let m = st.messages.clone(); + (ctx_now(st, &m), st.gen_peak, m) + }); + let Some(a) = after else { + lease.release().await; + return; // reset pendant la vérification + }; + (ctx_used, peak, msgs) = a; + } + // Compaction de fin de tour : la jauge retombe tout de suite, le tour // suivant démarre avec de la marge. Pas après un stop. if !cancel.is_cancelled() diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index c8a26cd..0a78b28 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -432,12 +432,19 @@ impl TurnHost for GoTurnHost { async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option> { let recall = StoreRecall(self.shared.store.clone()); + // Mode Code : l'état des critères au moment du résumé (le Go les relit + // aussi à chaque compaction — l'outil criteria a pu les changer). + let criteria = if self.compact.caps.code { + live_criteria(&self.shared.store, &self.compact.main_conv).await + } else { + self.compact.criteria.clone() + }; let c = Compactor { cfg: &self.compact.cfg, summarizer: &self.summarizer, recall: &recall, memory: &self.shared.compact_memory, - criteria: &self.compact.criteria, + criteria: &criteria, }; let (out, changed) = match phase { CompactPhase::Reactive | CompactPhase::FullWindow => { @@ -508,6 +515,11 @@ impl TurnHost for GoTurnHost { } } +/// Les critères du mode Code rendus pour le résumé ("" : aucun). +pub(crate) async fn live_criteria(store: &crate::store::Store, conv_id: &str) -> String { + super::verify::crit_render(&super::verify::crit_list(store, conv_id).await) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 9e65044..3a6725c 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -33,4 +33,5 @@ pub mod sessions; pub mod stream; pub mod turn; pub mod types; +pub mod verify; pub mod wire; diff --git a/rust/loki-rs/src/chat/verify.rs b/rust/loki-rs/src/chat/verify.rs new file mode 100644 index 0000000..17e1808 --- /dev/null +++ b/rust/loki-rs/src/chat/verify.rs @@ -0,0 +1,570 @@ +//! La boucle du contrat du mode Code — port de `code_verify.go` (lot 7). +//! +//! À la fin d'un tour de build en mode Code, tant que les critères +//! d'acceptation ne sont pas tous `passed` : le builder est d'abord relancé +//! sur ses critères encore ouverts (au plus [`CODE_MAX_CONTINUE`] fois), puis +//! une passe de VÉRIFICATION tourne sur un contexte isolé (prompt du rôle + +//! critères + tâche, pas l'historique) — seule autorisée à marquer un critère +//! `passed` —, suivie au besoin d'une correction par le builder sur +//! l'historique normal, au plus [`CODE_MAX_FIX_TURNS`] fois. +//! +//! Les critères restent au Go (l'outil `criteria` s'y exécute) : ils sont lus +//! 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. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{Value, json}; +use tokio::sync::watch; + +use super::compact::{CompactCaps, is_loki_injected, strip_context_updates, without_ctx_update}; +use super::generate::{ChatService, TurnEnv, obj}; +use super::history::{drop_stray_nudges, history_from_view, inject_prefix, tool_used_delta}; +use super::host::{GoTurnHost, TurnCompact}; +use super::lease::{EngineLease, LeaseSignal}; +use super::persist::BK_CHAT; +use super::turn::{Cancel, TurnConfig, run_turn}; +use super::types::{Message, ReasoningEcho, StreamEvent, Tool}; +use super::wire::{TurnReasoning, with_echo}; +use crate::store::Store; + +/// Tours de CORRECTION après un échec de vérification. Au-delà, on s'arrête +/// et on dit ce qui manque : l'utilisateur reprend la main. +pub const CODE_MAX_FIX_TURNS: usize = 2; +/// Relances du builder qui a rendu la main avec des critères encore ouverts, +/// avant de vérifier quand même. +pub const CODE_MAX_CONTINUE: usize = 2; + +/// Un critère d'acceptation (`Criterion`). +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +pub struct Criterion { + #[serde(default)] + pub id: i64, + #[serde(default)] + pub text: String, + /// pending | completed (le builder dit « fait ») | passed | failed. + #[serde(default)] + pub status: String, + #[serde(default)] + pub note: String, +} + +/// `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 { + match store.get_open(BK_CHAT, &format!("crit:{conv_id}")).await { + Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or_default(), + _ => Vec::new(), + } +} + +/// `critAllPassed` : contrat rempli. Une liste vide ne compte pas. +pub fn crit_all_passed(list: &[Criterion]) -> bool { + !list.is_empty() && list.iter().all(|c| c.status == "passed") +} + +/// `critPending` : critères pas encore `passed`. +pub fn crit_pending(list: &[Criterion]) -> usize { + list.iter().filter(|c| c.status != "passed").count() +} + +/// `critOpen` : pas encore déclarés faits par le builder. +pub fn crit_open(list: &[Criterion]) -> Vec { + list.iter() + .filter(|c| matches!(c.status.as_str(), "pending" | "failed" | "")) + .cloned() + .collect() +} + +/// `critRender` : la liste formatée pour un prompt. +pub fn crit_render(list: &[Criterion]) -> String { + list.iter() + .map(|c| { + let mark = match c.status.as_str() { + "passed" => "[✓]", + "failed" => "[✗]", + "completed" => "[~]", // fait selon le builder, pas encore vérifié + _ => "[ ]", + }; + let mut l = format!("{mark} #{} {}", c.id, c.text); + if !c.note.is_empty() { + l.push_str(&format!(" — {}", c.note)); + } + l + }) + .collect::>() + .join("\n") +} + +/// `lastUserText` : la tâche — le dernier message utilisateur, hors rappels +/// de Loki et sans mise à jour du projet en tête. +pub fn last_user_text(msgs: &[Message]) -> String { + for m in msgs.iter().rev() { + if m.role != "user" || is_loki_injected(m) { + continue; + } + let m = without_ctx_update(m).unwrap_or_else(|| m.clone()); + if let Some(Value::String(s)) = &m.content { + return s.clone(); + } + } + "(tâche non retrouvée — vérifie d'après le diff)".into() +} + +/// Ce que les passes du contrat partagent avec le tour qui les lance. +pub(super) struct PassEnv<'a> { + pub env: &'a TurnEnv, + pub turn_caps: &'a Value, + pub temperature: f64, + pub reasoning: &'a TurnReasoning, + pub ccaps: CompactCaps, + pub conv_id: &'a str, +} + +/// L'arrêt du tour et le bail du moteur, tenus pendant les passes. +pub(super) struct PassRun<'a> { + pub lease: &'a mut EngineLease, + pub cancel: &'a Cancel, + pub cancel_tx: &'a Arc>, +} + +/// Ce que rend une passe. +#[derive(Default)] +struct PassOut { + extra: Vec, + content: String, + new_base: Option>, + echo: Option, + saw_usage: bool, + asked: bool, +} + +fn cap_bool(caps: &Value, k: &str) -> bool { + caps.get(k).and_then(Value::as_bool).unwrap_or(false) +} + +impl ChatService { + /// `codeVerifyLoop` : rien hors mode Code, sans contrat, pour le + /// vérificateur ou le planificateur, ni après une question à + /// l'utilisateur (sa réponse passe avant). + pub(super) async fn code_verify_loop( + self: &Arc, + pe: &PassEnv<'_>, + epoch: i64, + asked: bool, + run: &mut PassRun<'_>, + ) { + let role = pe + .turn_caps + .get("Role") + .and_then(Value::as_str) + .unwrap_or(""); + if !cap_bool(pe.turn_caps, "Code") + || role == "verifier" + || run.cancel.is_cancelled() + || asked + { + return; + } + let store = &self.shared.store; + let mut list = crit_list(store, pe.conv_id).await; + if list.is_empty() || crit_all_passed(&list) || role == "planner" { + return; // planification : rien n'a encore été construit + } + for _ in 0..CODE_MAX_CONTINUE { + let open = crit_open(&list); + if open.is_empty() { + break; + } + let msg = format!( + "Not done yet — these acceptance criteria are still open:\n{}\n\n\ + Continue working on them. Once one is done and checked, mark it completed \ + (criteria action=set, status=completed). If you need the user's decision, use ask.", + crit_render(&open) + ); + if self.builder_pass(pe, epoch, msg, run).await || run.cancel.is_cancelled() { + return; // question posée à l'utilisateur, ou arrêt + } + list = crit_list(store, pe.conv_id).await; + if crit_all_passed(&list) { + return; + } + } + let conv = self.sessions.conv().clone(); + let mut fix = 0; + loop { + if run.cancel.is_cancelled() { + return; + } + self.verify_pass(pe, epoch, run).await; + list = crit_list(store, pe.conv_id).await; + if crit_all_passed(&list) { + conv.append_delta(epoch, obj("verify_done", "passed")); + return; + } + if fix >= CODE_MAX_FIX_TURNS { + conv.append_delta(epoch, obj("verify_done", "gave_up")); + // Le fil doit dire où on en est. + conv.append_delta( + epoch, + obj( + "content", + format!( + "\n\n_(vérification : {} critère(s) non satisfaits après {CODE_MAX_FIX_TURNS} corrections — voir le panneau des critères)_", + crit_pending(&list) + ), + ), + ); + return; + } + let failed: Vec = list + .iter() + .filter(|c| c.status != "passed") + .cloned() + .collect(); + let msg = format!( + "Verification failed on these criteria:\n{}\n\n\ + Fix the code so they pass. Address the notes precisely; do not touch what already passed. \ + Mark each fixed criterion completed again.", + crit_render(&failed) + ); + if self.builder_pass(pe, epoch, msg, run).await { + return; // le builder attend une réponse de l'utilisateur + } + fix += 1; + } + } + + /// `runVerifyPass` : la vérification sur un contexte ISOLÉ — ni rangée dans + /// l'historique, ni compactée avec lui ; seuls ses deltas d'affichage + /// sortent. + async fn verify_pass(self: &Arc, pe: &PassEnv<'_>, epoch: i64, run: &mut PassRun<'_>) { + let conv = self.sessions.conv().clone(); + conv.append_delta(epoch, obj("role", "verifier")); + let list = crit_list(&self.shared.store, pe.conv_id).await; + let task = conv.with_state(|st| last_user_text(&st.messages)); + let user = format!( + "Task:\n{task}\n\nAcceptance criteria:\n{}\n\n\ + Verify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.", + crit_render(&list) + ); + let view = self + .shared + .store + .internal_post( + "/internal/v1/turn/verifier", + &json!({ "caps": pe.turn_caps }), + ) + .await; + match view { + Ok((s, v)) if s.as_u16() == 200 => { + let tools: Vec = + serde_json::from_value(v["tools"].clone()).unwrap_or_default(); + let msgs = vec![ + Message { + role: "system".into(), + content: Some(v["system"].clone()), + ..Default::default() + }, + Message { + role: "user".into(), + content: Some(Value::String(user)), + ..Default::default() + }, + ]; + self.run_pass(pe, epoch, msgs, tools, v["caps"].clone(), true, &[], run) + .await; + } + other => { + let e = match other { + Ok((_, v)) => v["error"] + .as_str() + .unwrap_or("vue du vérificateur refusée") + .to_string(), + Err(e) => e, + }; + conv.append_delta(epoch, obj("error", e)); + } + } + conv.append_delta(epoch, obj("role", "")); + } + + /// `runBuilderTurn` : le builder relancé sur l'historique normal avec une + /// consigne ; sa trace est rangée comme un tour ordinaire. `true` : il a + /// posé une question à l'utilisateur. + async fn builder_pass( + self: &Arc, + pe: &PassEnv<'_>, + epoch: i64, + instruction: String, + run: &mut PassRun<'_>, + ) -> bool { + let conv = self.sessions.conv().clone(); + conv.append_delta(epoch, obj("role", "builder")); + let asked = self.builder_pass_inner(pe, epoch, instruction, run).await; + conv.append_delta(epoch, obj("role", "")); + asked + } + + async fn builder_pass_inner( + self: &Arc, + pe: &PassEnv<'_>, + epoch: i64, + instruction: String, + run: &mut PassRun<'_>, + ) -> bool { + let conv = self.sessions.conv().clone(); + let fix = Message { + role: "user".into(), + content: Some(Value::String(instruction)), + ..Default::default() + }; + let Some(msgs) = conv.with_state_if(epoch, |st| { + st.messages.push(fix); + st.messages.clone() + }) else { + return false; + }; + // Même contexte que le tour de build : sinon le début du prompt change + // et tout le cache est à refaire. + let (head, prefix, tools) = match self.turn_view(pe.turn_caps).await { + Ok(v) => v, + Err(e) => { + conv.append_delta(epoch, obj("error", e)); + return false; + } + }; + let hist = strip_context_updates(msgs); + let mut sent = head; + sent.extend(hist.iter().cloned()); + let sent = inject_prefix(sent, &prefix); + let out = self + .run_pass( + pe, + epoch, + sent, + tools, + pe.turn_caps.clone(), + false, + &hist, + run, + ) + .await; + conv.with_state_if(epoch, |st| { + if let Some(base) = out.new_base { + st.messages = base; + } + st.messages.extend(out.extra); + if !out.content.trim().is_empty() { + let a = Message { + role: "assistant".into(), + content: Some(Value::String(out.content.clone())), + ..Default::default() + }; + st.messages.push(with_echo(a, out.echo.as_ref())); + } + st.messages = drop_stray_nudges(std::mem::take(&mut st.messages)); + if out.saw_usage { + st.ctx_used_len = st.messages.len(); + } + }); + // En plein tour : l'étape suivante part juste après, l'écriture + // ne doit pas l'attendre. + self.sessions.persist_async().await; + out.asked + } + + /// Une passe : le tour tourne, ses événements deviennent des deltas + /// (`forwardStream`). `isolated` : trace à part (vérificateur) — ses + /// comptes ne disent rien du contexte de la discussion, et sa compaction + /// éventuelle n'est pas celle de la discussion. + #[allow(clippy::too_many_arguments)] + async fn run_pass( + self: &Arc, + pe: &PassEnv<'_>, + epoch: i64, + sent: Vec, + tools: Vec, + caps: Value, + isolated: bool, + base: &[Message], + run: &mut PassRun<'_>, + ) -> PassOut { + let conv = self.sessions.conv().clone(); + let env = pe.env; + let local = !env.ep.external; + let tc = TurnConfig { + cfg: env.cfg.clone(), + ep: env.ep.clone(), + tools: tools.clone(), + temperature: pe.temperature, + code: cap_bool(&caps, "Code"), + reasoning: pe.reasoning.clone(), + echo: env.echo.clone(), + compact: env.ccfg.clone(), + patterns: self.shared.patterns.clone(), + }; + let host = GoTurnHost { + shared: self.shared.clone(), + conv_id: pe.conv_id.to_string(), + caps, + cfg: env.cfg.clone(), + port: env.port, + summarizer: env.summarizer.clone(), + compact: TurnCompact { + cfg: env.ccfg.clone(), + caps: pe.ccaps, + criteria: String::new(), + main_conv: pe.conv_id.to_string(), + tools, + effort: pe.reasoning.effort.clone(), + kwargs: pe.reasoning.kwargs.clone().unwrap_or_default(), + }, + // Les messages en file attendent la fin du tour (runChatTools sans + // injection, comme le Go). + queued: Box::new(Vec::new), + }; + let mut out = PassOut::default(); + let mut cb = |ev: StreamEvent| -> bool { + match ev { + StreamEvent::NewHistory(h) => { + if !isolated { + out.new_base = Some(history_from_view(&h, base)); + } + } + StreamEvent::Echo(e) => { + if !isolated { + out.echo = Some(e); + } + } + StreamEvent::Compacting(b) => { + if !isolated { + conv.append_delta(epoch, obj("compacting", b)); + } + } + StreamEvent::Err(e) => { + conv.append_delta(epoch, obj("error", e)); + } + StreamEvent::ToolUsed(t) => { + out.content.clear(); // déjà dans le message tool_calls + conv.append_delta(epoch, tool_used_delta(&t)); + } + StreamEvent::Ask(a) => { + out.asked = true; + conv.append_delta( + epoch, + obj("ask", serde_json::to_value(&a).unwrap_or_default()), + ); + } + StreamEvent::Stats(s) => { + let stats = serde_json::to_value(&s).unwrap_or_default(); + if isolated { + let mut d = obj("stats", stats); + d.insert("ctx_isolated".into(), Value::Bool(true)); + conv.append_delta(epoch, d); + return true; + } + if s.prompt_tokens_total > 0 { + out.saw_usage = true; + } + conv.with_state_if(epoch, |st| { + if s.prompt_tokens_total > 0 { + st.ctx_used = s.ctx_after(); + } + if local { + st.gen_peak = st.gen_peak.max(s.gen_tokens); + } + }); + conv.append_delta(epoch, obj("stats", stats)); + } + StreamEvent::DropReasoning => { + conv.append_delta(epoch, obj("drop_reasoning", true)); + } + StreamEvent::Reasoning(r) if !r.is_empty() => { + conv.append_delta(epoch, obj("reasoning_content", r)); + } + StreamEvent::Content(c) if !c.is_empty() => { + out.content.push_str(&c); + conv.append_delta(epoch, obj("content", c)); + } + _ => {} + } + true + }; + let res = { + let turn = run_turn(&host, &tc, sent, run.cancel, &mut cb); + tokio::pin!(turn); + tokio::select! { + r = &mut turn => r, + sig = run.lease.interrupted() => { + if sig == LeaseSignal::Lost { + eprintln!("[chat] bail du moteur perdu pendant la vérification : arrêt"); + } + let _ = run.cancel_tx.send(true); + turn.await + } + } + }; + out.extra = res.extra; + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(id: i64, status: &str) -> Criterion { + Criterion { + id, + text: format!("critère {id}"), + status: status.into(), + note: String::new(), + } + } + + #[test] + fn contrat() { + let list = vec![c(1, "passed"), c(2, "completed"), c(3, "failed"), c(4, "")]; + assert!(!crit_all_passed(&list) && !crit_all_passed(&[])); + assert!(crit_all_passed(&[c(1, "passed")])); + assert_eq!(crit_pending(&list), 3); + assert_eq!( + crit_open(&list).iter().map(|c| c.id).collect::>(), + [3, 4] + ); + let mut noted = c(5, "failed"); + noted.note = "le test échoue".into(); + assert_eq!( + crit_render(&[c(1, "passed"), c(2, "completed"), noted, c(4, "pending")]), + "[✓] #1 critère 1\n[~] #2 critère 2\n[✗] #5 critère 5 — le test échoue\n[ ] #4 critère 4" + ); + } + + #[test] + fn tache_retrouvee() { + let u = |s: &str| Message { + role: "user".into(), + content: Some(Value::String(s.into())), + ..Default::default() + }; + let a = Message { + role: "assistant".into(), + content: Some(Value::String("ok".into())), + ..Default::default() + }; + assert_eq!( + last_user_text(&[u("fais le jeu"), a.clone()]), + "fais le jeu" + ); + assert_eq!( + last_user_text(&[a]), + "(tâche non retrouvée — vérifie d'après le diff)" + ); + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 8d86fc5..340e72b 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -50,6 +50,10 @@ pub struct FakeGo { /// exécute), et journal du suivi (lectures, écritures, contrôles). pub tool_ctx: Mutex>, pub tracker_log: Mutex>, + /// Tours en mode Code, et outils exécutés (« nom rôle »). L'outil + /// criteria (action=set) modifie les critères rangés (chat/crit:*). + pub code: Mutex, + pub tool_log: Mutex>, } impl FakeGo { @@ -213,7 +217,8 @@ async fn handle(st: Arc, req: Request) -> Response if msg.trim().is_empty() { return resp(400, r#"{"error":"message vide"}"#); } - let caps = serde_json::json!({"Agent": true, "Mem": "off", "Code": false, "Role": ""}); + let code = *st.code.lock().unwrap(); + let caps = serde_json::json!({"Agent": true, "Mem": "off", "Code": code, "Role": ""}); let out = serde_json::json!({ "files": [], "caps": caps, "turn_caps": caps, "content": msg, "model": "m1", "code_hint": false, @@ -229,6 +234,19 @@ async fn handle(st: Arc, req: Request) -> Response }); resp(200, out.to_string()) } + "/internal/v1/turn/verifier" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + st.turn_log.lock().unwrap().push("verifier".into()); + let mut caps = v["caps"].clone(); + caps["Role"] = "verifier".into(); + let out = serde_json::json!({ + "system": "VÉRIFICATEUR", + "tools": [{"type": "function", "function": {"name": "criteria", "description": "", "parameters": {"type": "object"}}}], + "caps": caps, + }); + resp(200, out.to_string()) + } "/internal/v1/turn/done" => { st.turn_log.lock().unwrap().push("done".into()); resp(204, "") @@ -258,6 +276,27 @@ async fn handle(st: Arc, req: Request) -> Response "/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(); + let name = v["name"].as_str().unwrap_or_default(); + st.tool_log.lock().unwrap().push(format!( + "{name} {}", + v["caps"]["Role"].as_str().unwrap_or_default() + )); + if name == "criteria" && v["args"]["action"] == "set" { + let mut chat = st.store.lock().unwrap(); + for (k, val) in chat.entry("chat".into()).or_default().iter_mut() { + if !k.starts_with("crit:") { + continue; + } + let mut list: Vec = + serde_json::from_slice(val).unwrap_or_default(); + for c in &mut list { + if c["id"] == v["args"]["id"] { + c["status"] = v["args"]["status"].clone(); + } + } + *val = serde_json::to_vec(&list).unwrap(); + } + } let result = st .tool_result .lock() diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 91d31c7..ebd2574 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -799,6 +799,179 @@ async fn tour_de_bout_en_bout() { assert!(again.dup); } +/// Contenu seul, fin normale, avec l'usage (une mesure par complétion). +fn answer(text: &str) -> String { + sse(&[ + serde_json::json!({"choices":[{"delta":{"content": text}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":40,"completion_tokens":2}}), + ]) +} + +fn contents_of(msgs: &[loki_rs::chat::types::Message]) -> Vec { + msgs.iter() + .map(|m| { + let c = m.content.as_ref().and_then(|c| c.as_str()).unwrap_or(""); + format!("{}:{}", m.role, c.lines().next().unwrap_or("")) + }) + .collect() +} + +/// Lot 7 : contrat du mode Code tenu. Critère déclaré fait par le builder → +/// passe de vérification sur un contexte isolé, qui le marque « passed » ; sa +/// trace ne rejoint pas l'historique. +#[tokio::test] +async fn contrat_verifie_par_la_facade() { + let (url, seen) = fake_engine(vec![ + answer("fait"), + sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"v1","type":"function","function":{"name":"criteria","arguments":"{\"action\":\"set\",\"id\":1,\"status\":\"passed\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]), + answer("vérifié"), + ]) + .await; + let (svc, go, _stop, _t) = service(Some(&url)).await; + *go.code.lock().unwrap() = true; + let id = svc.sessions.conv().id(); + go.set( + "chat", + &format!("crit:{id}"), + r#"[{"id":1,"text":"les tests passent","status":"completed"}]"#, + ); + svc.send(serde_json::json!({"message": "fais-le", "cid": "v1"})) + .await + .unwrap(); + until_done(&svc, 1).await; + until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await; + + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 3); + let v = &seen[1].1; + assert_eq!(v["messages"][0]["content"], "VÉRIFICATEUR"); + assert_eq!( + v["messages"][1]["content"], + "Task:\nfais-le\n\nAcceptance criteria:\n[~] #1 les tests passent\n\n\ + Verify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool." + ); + assert_eq!(v["tools"][0]["function"]["name"], "criteria"); + assert_eq!(*go.tool_log.lock().unwrap(), ["criteria verifier"]); + assert_eq!( + deltas(&svc, "role"), + [serde_json::json!("verifier"), serde_json::json!("")] + ); + assert_eq!(deltas(&svc, "verify_done"), [serde_json::json!("passed")]); + // Mesures du vérificateur, et elles seules : à part (la jauge de la + // discussion n'en dit rien). + let (inside, outside) = svc.sessions.conv().with_state(|st| { + let (mut verifier, mut inside, mut outside) = (false, 0, 0); + for e in &st.log { + if let Some(r) = e.delta.get("role") { + verifier = r == "verifier"; + } + if e.delta.contains_key("stats") { + let iso = e.delta.get("ctx_isolated") == Some(&serde_json::json!(true)); + assert_eq!(iso, verifier, "{:?}", e.delta); + if verifier { + inside += 1; + } else { + outside += 1; + } + } + } + (inside, outside) + }); + assert!(inside > 0 && outside > 0); + let msgs = svc.sessions.conv().with_state(|st| st.messages.clone()); + assert_eq!(contents_of(&msgs), ["user:fais-le", "assistant:fait"]); +} + +/// Lot 7 : contrat jamais tenu. Deux relances du builder sur les critères +/// ouverts, puis trois vérifications séparées par deux corrections, et +/// l'abandon dit dans le fil. Les relances et corrections rejoignent +/// l'historique ; les vérifications non. +#[tokio::test] +async fn contrat_abandonne_apres_deux_corrections() { + let script = ["r0", "r1", "r2", "v1", "r3", "v2", "r4", "v3"] + .iter() + .map(|t| answer(t)) + .collect(); + let (url, seen) = fake_engine(script).await; + let (svc, go, _stop, _t) = service(Some(&url)).await; + *go.code.lock().unwrap() = true; + let id = svc.sessions.conv().id(); + go.set( + "chat", + &format!("crit:{id}"), + r#"[{"id":1,"text":"build ok","status":"pending"}]"#, + ); + svc.send(serde_json::json!({"message": "construis", "cid": "v2"})) + .await + .unwrap(); + until_done(&svc, 1).await; + until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await; + + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 8); + // Relance : historique + consigne, même tête (préambule fusionné). + let r1 = &seen[1].1["messages"]; + assert_eq!(r1[0]["content"], "PRÉAMBULE\n\nTu es Loki."); + let last = r1.as_array().unwrap().last().unwrap()["content"] + .as_str() + .unwrap(); + assert!( + last.starts_with( + "Not done yet — these acceptance criteria are still open:\n[ ] #1 build ok\n\n" + ), + "{last}" + ); + assert_eq!(seen[3].1["messages"][0]["content"], "VÉRIFICATEUR"); + let fix = seen[4].1["messages"].as_array().unwrap().last().unwrap()["content"] + .as_str() + .unwrap() + .to_string(); + assert!( + fix.starts_with("Verification failed on these criteria:\n[ ] #1 build ok\n\nFix the code"), + "{fix}" + ); + + assert_eq!(deltas(&svc, "verify_done"), [serde_json::json!("gave_up")]); + let roles: Vec = deltas(&svc, "role") + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + roles, + [ + "builder", "", "builder", "", "verifier", "", "builder", "", "verifier", "", "builder", + "", "verifier", "" + ] + ); + let content = deltas(&svc, "content"); + assert_eq!( + content.last().unwrap(), + "\n\n_(vérification : 1 critère(s) non satisfaits après 2 corrections — voir le panneau des critères)_" + ); + let msgs = svc.sessions.conv().with_state(|st| st.messages.clone()); + assert_eq!( + contents_of(&msgs), + [ + "user:construis", + "assistant:r0", + "user:Not done yet — these acceptance criteria are still open:", + "assistant:r1", + "user:Not done yet — these acceptance criteria are still open:", + "assistant:r2", + "user:Verification failed on these criteria:", + "assistant:r3", + "user:Verification failed on these criteria:", + "assistant:r4", + ] + ); + // Enregistrée telle quelle. + let body = opened(&go, BK_CHAT, &format!("conv:{id}")).unwrap(); + assert_eq!(decode_state(&body).messages.len(), 10); +} + /// Lot 7 : un `read` s'exécute dans la façade, dans le dossier de travail que /// lui donne le Go, et la lecture est notée dans le suivi du Go. #[tokio::test]