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]