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

+27 -1
View File
@@ -132,6 +132,11 @@ pub struct FakeGo {
/// reçues (« conv main »).
pub turn_side_live: Mutex<bool>,
pub turn_slots: Mutex<Vec<String>>,
/// Message relayé par chaque outil exécuté ici (image d'une capture).
pub tool_relay: Mutex<Option<serde_json::Value>>,
/// /internal/v1/turn/keepimg : vision active ; rangements reçus.
pub keep_vision: Mutex<bool>,
pub keep_stores: Mutex<Vec<serde_json::Value>>,
}
impl FakeGo {
@@ -648,11 +653,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
let result = by_name
.or_else(|| st.tool_result.lock().unwrap().clone())
.unwrap_or_else(|| format!("exec:{}", v["name"].as_str().unwrap()));
let out = serde_json::json!({
let mut out = serde_json::json!({
"label": v["args"]["command"],
"result": result,
"caps_agent": v["caps"]["Agent"],
});
if let Some(r) = st.tool_relay.lock().unwrap().clone() {
out["relay"] = r;
}
resp(200, out.to_string())
}
"/internal/v1/oai/route" => {
@@ -687,6 +695,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
}
resp(200, r#"{"ticket":7}"#)
}
"/internal/v1/turn/keepimg" => {
let v = serde_json::json!({
"vision": *st.keep_vision.lock().unwrap(),
"budget": 3276,
});
resp(200, v.to_string())
}
"/internal/v1/turn/keepimg/store" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let mut msg = v["msg"].clone();
st.keep_stores.lock().unwrap().push(msg.clone());
msg["content"][1]["image_url"]["url"] = serde_json::json!("loki-img:k1.png");
resp(
200,
serde_json::json!({ "ok": true, "msg": msg, "weight": 4 }).to_string(),
)
}
"/internal/v1/turn/slot" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
+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}"
);
}