mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 14:08:11 +02:00
Lot 6 : un tour de discussion de bout en bout en Rust (chat::generate)
Port d'EnqueueOrStart, StartTurn, generate, drainQueued, startQueuedIfAny, dropQueued et compactAndPublish : - envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis en file si un tour tourne, sinon moteur prêt (/health), verrou du tour puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ; - tour : compaction de début de tour, vue d'envoi (tête du Go, historique sans <context_update>, préambule fusionné comme InjectSkills), boucle dont chaque événement devient un delta du journal, historique rangé (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés), contexte estimé si le moteur ne le compte pas, compaction de fin de tour ; - fin : turn_done, journal coalescé, verrou et bail rendus, discussion enregistrée, file abandonnée après un stop ou message suivant lancé, sinon Web Push ; - stop : le tour en cours, ou le détenteur Go du moteur. chat::history porte historyFromView, dropStrayNudges, le rappel des pages mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en mémoire seulement. EngineLease::interrupted ne garde plus de verrou à travers une attente. Écarts : boucle de vérification du mode Code, critères pour la compaction, préchauffage, télémétrie, retry_patterns personnalisés. Tests de bout en bout : tour complet (outil, réponse, bail rendu, discussion enregistrée, doublon), message en file injecté entre deux étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
11 files changed
+1678
-10
No files matched your search
@@ -42,6 +42,8 @@ pub struct FakeGo {
|
||||
pub recall: Mutex<Vec<String>>,
|
||||
/// Discussion active annoncée et discussions supprimées.
|
||||
pub conv_log: Mutex<Vec<String>>,
|
||||
/// Tours : débuts, vues et fins demandés.
|
||||
pub turn_log: Mutex<Vec<String>>,
|
||||
/// Adresse d'écoute du faux Go (pour un client `Store` direct).
|
||||
pub backend: Mutex<Option<SocketAddr>>,
|
||||
}
|
||||
@@ -191,6 +193,34 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
.push(format!("{what} {}", v["id"].as_str().unwrap_or_default()));
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/turn/start" => {
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let msg = v["message"].as_str().unwrap_or_default().to_string();
|
||||
st.turn_log.lock().unwrap().push(format!("start {msg}"));
|
||||
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 out = serde_json::json!({
|
||||
"files": [], "caps": caps, "turn_caps": caps,
|
||||
"content": msg, "model": "m1", "code_hint": false,
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/turn/view" => {
|
||||
st.turn_log.lock().unwrap().push("view".into());
|
||||
let out = serde_json::json!({
|
||||
"head": [{"role": "system", "content": "Tu es Loki."}],
|
||||
"prefix": "PRÉAMBULE",
|
||||
"tools": [{"type": "function", "function": {"name": "bash", "description": "", "parameters": {"type": "object"}}}],
|
||||
});
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/turn/done" => {
|
||||
st.turn_log.lock().unwrap().push("done".into());
|
||||
resp(204, "")
|
||||
}
|
||||
"/internal/v1/engine/stop" => {
|
||||
st.lease_log.lock().unwrap().push("stop".into());
|
||||
*st.go_busy.lock().unwrap() = None;
|
||||
|
||||
+231
-6
@@ -443,10 +443,21 @@ use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TurnReaso
|
||||
async fn fake_engine(
|
||||
replies: Vec<String>,
|
||||
) -> (String, Arc<StdMutex<Vec<(String, serde_json::Value)>>>) {
|
||||
fake_engine_gated(replies, None).await
|
||||
}
|
||||
|
||||
type Seen = Arc<StdMutex<Vec<(String, serde_json::Value)>>>;
|
||||
|
||||
/// Comme [`fake_engine`] ; avec `gate`, chaque complétion attend un jeton.
|
||||
/// `/health` répond toujours 200.
|
||||
async fn fake_engine_gated(
|
||||
replies: Vec<String>,
|
||||
gate: Option<Arc<tokio::sync::Semaphore>>,
|
||||
) -> (String, Seen) {
|
||||
use http_body_util::{BodyExt, Full};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let seen = Arc::new(StdMutex::new(Vec::new()));
|
||||
let seen: Seen = Arc::new(StdMutex::new(Vec::new()));
|
||||
let replies = Arc::new(StdMutex::new(std::collections::VecDeque::from(replies)));
|
||||
let s = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -454,12 +465,20 @@ async fn fake_engine(
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let (s, replies) = (s.clone(), replies.clone());
|
||||
let (s, replies, gate) = (s.clone(), replies.clone(), gate.clone());
|
||||
tokio::spawn(async move {
|
||||
let svc = hyper::service::service_fn(
|
||||
move |req: hyper::Request<hyper::body::Incoming>| {
|
||||
let (s, replies) = (s.clone(), replies.clone());
|
||||
let (s, replies, gate) = (s.clone(), replies.clone(), gate.clone());
|
||||
async move {
|
||||
let ok = |b: String| {
|
||||
Ok::<_, std::convert::Infallible>(hyper::Response::new(Full::new(
|
||||
bytes::Bytes::from(b),
|
||||
)))
|
||||
};
|
||||
if req.uri().path() == "/health" {
|
||||
return ok("ok".into());
|
||||
}
|
||||
let auth = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
@@ -469,10 +488,11 @@ async fn fake_engine(
|
||||
s.lock()
|
||||
.unwrap()
|
||||
.push((auth, serde_json::from_slice(&body).unwrap()));
|
||||
if let Some(g) = &gate {
|
||||
g.acquire().await.unwrap().forget();
|
||||
}
|
||||
let out = replies.lock().unwrap().pop_front().unwrap_or_default();
|
||||
Ok::<_, std::convert::Infallible>(hyper::Response::new(Full::new(
|
||||
bytes::Bytes::from(out),
|
||||
)))
|
||||
ok(out)
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -647,3 +667,208 @@ async fn crochets_go_des_discussions() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- un tour de bout en bout, par le service ----
|
||||
|
||||
use loki_rs::chat::generate::{ChatService, ERR_MODEL_LOADING, Refused, Sent};
|
||||
use loki_rs::chat::sessions::GoSessionHooks;
|
||||
|
||||
fn port_of(url: &str) -> String {
|
||||
url.trim_start_matches("http://127.0.0.1:")
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn service(
|
||||
engine_url: Option<&str>,
|
||||
) -> (
|
||||
Arc<ChatService>,
|
||||
Arc<fakego::FakeGo>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let (st, go, stop, t) = store().await;
|
||||
go.set(
|
||||
"config",
|
||||
"PORT",
|
||||
&engine_url.map(port_of).unwrap_or_else(|| "1".into()),
|
||||
);
|
||||
go.set("state", "api_key", "cle-du-moteur");
|
||||
let sessions = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
|
||||
sessions.load().await.unwrap();
|
||||
let shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
|
||||
(
|
||||
ChatService::new(Arc::new(sessions), Arc::new(shared)),
|
||||
go,
|
||||
stop,
|
||||
t,
|
||||
)
|
||||
}
|
||||
|
||||
async fn until_done(svc: &ChatService, turns: usize) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let c = svc.sessions.conv();
|
||||
let done = c.with_state(|st| {
|
||||
st.log
|
||||
.iter()
|
||||
.filter(|e| e.delta.contains_key("turn_done"))
|
||||
.count()
|
||||
});
|
||||
if done >= turns && !c.is_generating() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("tour jamais terminé");
|
||||
}
|
||||
|
||||
fn deltas(svc: &ChatService, key: &str) -> Vec<serde_json::Value> {
|
||||
svc.sessions.conv().with_state(|st| {
|
||||
st.log
|
||||
.iter()
|
||||
.filter_map(|e| e.delta.get(key).cloned())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_then_answer(answer: &str) -> Vec<String> {
|
||||
vec![
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"ls\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]),
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"content": answer}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":80,"completion_tokens":3}}),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tour_de_bout_en_bout() {
|
||||
let (url, seen) = fake_engine(tool_then_answer("fini")).await;
|
||||
let (svc, go, _stop, _t) = service(Some(&url)).await;
|
||||
let sent = svc
|
||||
.send(serde_json::json!({"message": "liste", "cid": "a1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sent, Sent::default());
|
||||
until_done(&svc, 1).await;
|
||||
// La fin est signalée en tout dernier (après l'enregistrement) : on l'attend.
|
||||
until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await;
|
||||
|
||||
// Vue modèle rangée : la demande, l'appel, son résultat, la réponse.
|
||||
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
|
||||
let roles: Vec<&str> = msgs.iter().map(|m| m.role.as_str()).collect();
|
||||
assert_eq!(roles, ["user", "assistant", "tool", "assistant"]);
|
||||
assert_eq!(msgs[3].content, Some(serde_json::json!("fini")));
|
||||
// Journal : bulle avec le modèle, outil, réponse, fin.
|
||||
assert_eq!(deltas(&svc, "user"), [serde_json::json!("liste")]);
|
||||
assert_eq!(deltas(&svc, "model"), [serde_json::json!("m1")]);
|
||||
assert!(
|
||||
deltas(&svc, "tool_used")
|
||||
.iter()
|
||||
.any(|t| t["done"] == true && t["result"] == "exec:bash")
|
||||
);
|
||||
assert_eq!(deltas(&svc, "content"), [serde_json::json!("fini")]);
|
||||
assert!(!deltas(&svc, "stats").is_empty());
|
||||
// Requête : préambule fusionné au prompt du preset, clé du moteur, outils.
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(seen[0].0, "Bearer cle-du-moteur");
|
||||
assert_eq!(
|
||||
seen[0].1["messages"][0]["content"],
|
||||
"PRÉAMBULE\n\nTu es Loki."
|
||||
);
|
||||
assert_eq!(seen[0].1["messages"][1]["content"], "liste");
|
||||
assert_eq!(seen[0].1["tools"][0]["function"]["name"], "bash");
|
||||
// Bail rendu, fin signalée, discussion enregistrée.
|
||||
assert_eq!(*go.lease.lock().unwrap(), None);
|
||||
let id = svc.sessions.conv().id();
|
||||
let body = opened(&go, BK_CHAT, &format!("conv:{id}")).unwrap();
|
||||
assert_eq!(decode_state(&body).messages.len(), 4);
|
||||
// Réessai du même envoi : reconnu.
|
||||
let again = svc
|
||||
.send(serde_json::json!({"message": "liste", "cid": "a1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(again.dup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_en_file_injecte_dans_le_tour() {
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
let (url, seen) = fake_engine_gated(tool_then_answer("les deux"), Some(gate.clone())).await;
|
||||
let (svc, _go, _stop, _t) = service(Some(&url)).await;
|
||||
svc.send(serde_json::json!({"message": "premier", "cid": "p1"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until(|| seen.lock().unwrap().len() == 1).await;
|
||||
let q = svc
|
||||
.send(serde_json::json!({"message": "deuxième", "cid": "p2"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(q.queued, "un tour tourne : en file");
|
||||
gate.add_permits(10);
|
||||
until_done(&svc, 1).await;
|
||||
assert_eq!(
|
||||
deltas(&svc, "user"),
|
||||
[serde_json::json!("premier"), serde_json::json!("deuxième")]
|
||||
);
|
||||
let second = seen.lock().unwrap()[1].1.clone();
|
||||
let last = second["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(last["content"], "deuxième", "injecté entre deux étapes");
|
||||
let users = svc
|
||||
.sessions
|
||||
.conv()
|
||||
.with_state(|st| st.messages.iter().filter(|m| m.role == "user").count());
|
||||
assert_eq!(users, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moteur_tenu_par_une_tache_planifiee() {
|
||||
let (url, _seen) = fake_engine(vec![]).await;
|
||||
let (svc, go, _stop, _t) = service(Some(&url)).await;
|
||||
*go.go_busy.lock().unwrap() = Some("la tâche planifiée « veille » occupe le modèle".into());
|
||||
let body = serde_json::json!({"message": "salut", "cid": "b1"});
|
||||
let want = Refused {
|
||||
status: 409,
|
||||
error: "la tâche planifiée « veille » occupe le modèle".into(),
|
||||
};
|
||||
assert_eq!(svc.send(body.clone()).await, Err(want.clone()));
|
||||
// Refusé : le même envoi peut être renvoyé (pas pris pour un doublon).
|
||||
assert_eq!(svc.send(body).await, Err(want));
|
||||
assert!(!svc.sessions.conv().is_generating());
|
||||
assert!(deltas(&svc, "user").is_empty());
|
||||
// Le stop du chat arrête le détenteur Go.
|
||||
svc.stop().await;
|
||||
assert!(go.lease_log.lock().unwrap().contains(&"stop".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moteur_pas_pret_et_message_vide() {
|
||||
let (svc, _go, _stop, _t) = service(None).await;
|
||||
let r = svc
|
||||
.send(serde_json::json!({"message": "salut", "cid": "c1"}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
r,
|
||||
Err(Refused {
|
||||
status: 503,
|
||||
error: ERR_MODEL_LOADING.into()
|
||||
})
|
||||
);
|
||||
let r = svc
|
||||
.send(serde_json::json!({"message": " ", "cid": "c2"}))
|
||||
.await;
|
||||
assert_eq!(r.unwrap_err().status, 400);
|
||||
}
|
||||
Reference in new issue
Block a user