Lot 32 : images gardées (KEEP_TURN_IMAGES) dans le tour de la façade

KEEP_TURN_IMAGES ne garde plus la conversation au Go. L'état du tour vit
dans la façade ; les fichiers et la vision restent au Go.

Go (facade_keepimg.go) :
- /internal/v1/turn/keepimg : vision réellement active, budget des images
  gardées ;
- /internal/v1/turn/keepimg/store : une image relayée rangée par
  référence (refRelayImage), relue à l'octet près.

Rust :
- chat/keepimg.rs : port de keepImages (image à l'essai, mesure par le
  moteur répartie au prorata des octets, budget, publication, fin de tour).
- La boucle du tour : budget demandé pour le fil principal, image rangée
  par le Go, historique réécrit publié sans les relais non gardés ; les
  images gardées partent avant tout résumé (filet réactif, en cours de
  tour, fenêtre pleine).
- Début et fin de tour : retirées si le contexte le demande ou si elles
  ne doivent plus partir (clé retirée, vision absente).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 02:30:59 +00:00
1 parent a005626e66
commit f79b74dac3
17 files changed
+1435 -37

No files matched your search

+90
View File
@@ -1992,3 +1992,93 @@ async fn telemetrie_des_resumes() {
);
assert_eq!(perfs[1]["complete"], false);
}
/// Lot 32 : KEEP_TURN_IMAGES tenu par la façade. L'image relayée par un
/// outil est rangée par le Go, gardée au coût mesuré par le moteur ; au tour
/// suivant, clé retirée, elle part d'abord (légende et marque gardées).
#[tokio::test]
async fn images_gardees_par_la_facade() {
use loki_rs::chat::compact::{IMAGE_LOST_MARKER, msg_tokens};
use loki_rs::chat::types::Message;
let tool = Message {
role: "tool".into(),
tool_call_id: "c1".into(),
content: Some(serde_json::json!("exec:bash")),
..Default::default()
};
let prompt2 = 1020 + msg_tokens(&tool) + 300;
let (url, seen) = fake_engine(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"}],"usage":{"prompt_tokens":1000,"completion_tokens":20}}),
]),
sse(&[
serde_json::json!({"choices":[{"delta":{"content": "vu"}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":prompt2,"completion_tokens":5}}),
]),
answer("ensuite"),
])
.await;
let (svc, go, _stop, t) = service(Some(&url)).await;
// Le fichier que le Go range (ici, le faux ne l'écrit pas).
std::fs::create_dir_all(t.path().join("chatimg")).unwrap();
std::fs::write(t.path().join("chatimg/k1.png"), b"\x89PNG\r\n").unwrap();
go.set("config", "KEEP_TURN_IMAGES", "on");
*go.keep_vision.lock().unwrap() = true;
*go.tool_relay.lock().unwrap() = Some(serde_json::json!({
"role": "user",
"content": [
{"type": "text", "text": "Voici la capture demandée."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0K"}}
]
}));
svc.send(serde_json::json!({"message": "capture", "cid": "k1"}))
.await
.unwrap();
until_done(&svc, 1).await;
until(|| go.turn_log.lock().unwrap().contains(&"done".to_string())).await;
// Rangée par le Go, vue en data-URL par le moteur.
assert_eq!(go.keep_stores.lock().unwrap().len(), 1);
let sent = seen.lock().unwrap()[1].1.clone();
let relay = &sent["messages"].as_array().unwrap()[4];
assert_eq!(
relay["content"][1]["image_url"]["url"],
"data:image/png;base64,iVBORw0K"
);
assert!(relay.get("img_relay").is_none(), "marque envoyée au moteur");
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
let kept = msgs.iter().find(|m| m.img_relay).expect("relais gardé");
assert_eq!(kept.img_tokens, 300);
assert_eq!(
kept.content.as_ref().unwrap()[1]["image_url"]["url"],
"loki-img:k1.png"
);
// Clé retirée : l'image gardée part au début du tour suivant.
go.set("config", "KEEP_TURN_IMAGES", "off");
svc.send(serde_json::json!({"message": "et ensuite ?", "cid": "k2"}))
.await
.unwrap();
until_done(&svc, 2).await;
let msgs = svc.sessions.conv().with_state(|st| st.messages.clone());
let stub = msgs
.iter()
.find(|m| m.img_relay)
.expect("relais gardé en légende");
assert_eq!(stub.img_tokens, 0);
assert_eq!(
stub.content,
Some(serde_json::json!(format!(
"Voici la capture demandée.{IMAGE_LOST_MARKER}"
)))
);
let sent = seen.lock().unwrap()[2].1.clone();
assert!(
sent["messages"]
.as_array()
.unwrap()
.iter()
.all(|m| !m["content"].to_string().contains("image_url")),
"image encore envoyée : {sent}"
);
}