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

+260 -2
View File
@@ -42,6 +42,12 @@ struct Fake {
/// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues.
slot: Mutex<Option<u8>>,
slot_asks: Mutex<usize>,
/// KEEP_TURN_IMAGES : budget (clé valable pour le tour) ; image relayée
/// par chaque outil ; rangements demandés (refusés si `keep_refuse`).
keep: Mutex<Option<i64>>,
relay: Mutex<Option<Message>>,
keep_stores: Mutex<usize>,
keep_refuse: Mutex<bool>,
}
impl Fake {
@@ -120,6 +126,7 @@ impl TurnHost for Fake {
.unwrap_or_else(|| format!("résultat de {name}"));
ToolOutcome {
result,
relay: self.relay.lock().unwrap().clone(),
..Default::default()
}
}
@@ -133,8 +140,8 @@ impl TurnHost for Fake {
fn drain_queued(&self) -> Vec<Message> {
std::mem::take(&mut *self.queued.lock().unwrap())
}
fn image_data_url(&self, _r: &str) -> Option<String> {
None
fn image_data_url(&self, r: &str) -> Option<String> {
(r == "loki-img:aa.png").then(|| IMG_URL.to_string())
}
fn effort_remember(&self, want: &str, got: &str) {
self.efforts.lock().unwrap().push((want.into(), got.into()));
@@ -153,12 +160,83 @@ impl TurnHost for Fake {
self.perfs.lock().unwrap().push(rec);
self.marks.lock().unwrap().clone()
}
async fn keep_images(&self) -> Option<i64> {
*self.keep.lock().unwrap()
}
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
*self.keep_stores.lock().unwrap() += 1;
if *self.keep_refuse.lock().unwrap() {
return None;
}
let mut r = m.clone();
r.content = Some(json!([
m.content.as_ref().unwrap()[0].clone(),
{"type": "image_url", "image_url": {"url": "loki-img:aa.png"}}
]));
Some((r, 1000))
}
async fn engine_slot(&self, _ep: &ChatEndpoint) -> Option<u8> {
*self.slot_asks.lock().unwrap() += 1;
*self.slot.lock().unwrap()
}
}
/// L'image d'un relais : la même data-URL que rend la référence rangée.
const IMG_URL: &str = "data:image/png;base64,AA==";
fn relay_msg() -> Message {
Message {
role: "user".into(),
content: Some(json!([
{"type": "text", "text": "Voici la capture demandée."},
{"type": "image_url", "image_url": {"url": IMG_URL}}
])),
..Default::default()
}
}
/// Fin de complétion avec cet usage ; `prompt` 0 : aucun usage.
fn finish_u(reason: &str, prompt: i64, completion: i64) -> Vec<String> {
let mut v = vec![format!(
"data: {}",
json!({"choices":[{"delta":{},"finish_reason": reason}]})
)];
if prompt > 0 {
v.push(format!(
"data: {}",
json!({"choices":[],"usage":{"prompt_tokens":prompt,"completion_tokens":completion}})
));
}
v
}
fn see_image_step(prompt: i64) -> Script {
let mut l = vec![call("v1", "see_image", r#"{"file":"img.png"}"#)];
l.extend(finish_u("tool_calls", prompt, 20));
Script::Ok(l)
}
fn answer_u(s: &str, prompt: i64) -> Script {
let mut l = vec![content(s)];
l.extend(finish_u("stop", prompt, 5));
Script::Ok(l)
}
/// Lance un tour sur cet historique et rend (résultat, événements).
async fn run_hist(
host: &Fake,
tc: &TurnConfig,
hist: Vec<Message>,
) -> (TurnResult, Vec<StreamEvent>) {
let mut evs = Vec::new();
let mut cb = |ev: StreamEvent| {
evs.push(ev);
true
};
let r = run_turn(host, tc, hist, &Cancel::never(), &mut cb).await;
(r, evs)
}
fn content(s: &str) -> String {
format!("data: {}", json!({"choices":[{"delta":{"content": s}}]}))
}
@@ -797,3 +875,183 @@ async fn telemetrie_incomplete() {
"pas de statistique finale"
);
}
// ── KEEP_TURN_IMAGES (lot 32, cas de chat_keep_images_test.go) ──
fn tool_res(s: &str) -> Message {
Message {
role: "tool".into(),
tool_call_id: "v1".into(),
content: Some(json!(s)),
..Default::default()
}
}
fn kept_relay(tokens: i64) -> Message {
let mut m = relay_msg();
m.content.as_mut().unwrap()[1]["image_url"]["url"] = json!("loki-img:aa.png");
m.img_relay = true;
m.img_tokens = tokens;
m
}
/// Sans la clé : image éphémère, en data-URL, sans marque ; avec la clé, la
/// requête qui la montre est la MÊME à l'octet près, et l'image est gardée
/// par référence au coût mesuré (écart de prompt moins le texte ajouté).
#[tokio::test]
async fn images_gardees_requete_identique_et_mesuree() {
let text = crate::chat::compact::msg_tokens(&tool_res("résultat de see_image"));
let script = || vec![see_image_step(1000), answer_u("vu", 1020 + text + 300)];
let off = Fake::new(script());
*off.relay.lock().unwrap() = Some(relay_msg());
let (r, _) = run(&off, &config(&["see_image"]), &Cancel::never()).await;
assert_eq!(r.err, None);
assert_eq!(r.extra.len(), 2, "sans la clé, l'image reste éphémère");
assert!(r.extra.iter().all(|m| !m.img_relay && m.img_tokens == 0));
assert_eq!(*off.keep_stores.lock().unwrap(), 0);
let on = Fake::new(script());
*on.relay.lock().unwrap() = Some(relay_msg());
*on.keep.lock().unwrap() = Some(3200);
let (r, _) = run(&on, &config(&["see_image"]), &Cancel::never()).await;
assert_eq!(r.err, None);
assert_eq!(on.body(0), off.body(0));
assert_eq!(on.body(1), off.body(1), "requête différente avec la clé");
assert_eq!(r.extra.len(), 3, "{:?}", r.extra);
let k = &r.extra[2];
assert!(k.img_relay && k.img_tokens == 300, "{k:?}");
assert_eq!(
k.content.as_ref().unwrap()[1]["image_url"]["url"],
"loki-img:aa.png"
);
}
/// Budget dépassé, moteur sans usage, rangement refusé : éphémère.
#[tokio::test]
async fn images_gardees_refusees() {
let text = crate::chat::compact::msg_tokens(&tool_res("résultat de see_image"));
for (budget, prompts, refuse) in [
(200, (1000, 1020 + text + 300), false),
(3200, (0, 0), false),
(3200, (1000, 1020 + text + 300), true),
] {
let host = Fake::new(vec![see_image_step(prompts.0), answer_u("vu", prompts.1)]);
*host.relay.lock().unwrap() = Some(relay_msg());
*host.keep.lock().unwrap() = Some(budget);
*host.keep_refuse.lock().unwrap() = refuse;
let (r, _) = run(&host, &config(&["see_image"]), &Cancel::never()).await;
assert_eq!(r.err, None);
assert_eq!(
r.extra.len(),
2,
"budget={budget} {prompts:?} refus={refuse}"
);
// Le modèle l'a vue quand même, en data-URL.
assert_eq!(
host.body(1)["messages"][3]["content"][1]["image_url"]["url"],
IMG_URL
);
}
}
/// Tour interrompu juste après l'image : jamais mesurée, éphémère.
#[tokio::test]
async fn image_a_l_essai_en_fin_de_tour() {
let host = Fake::new(vec![
see_image_step(1000),
Script::Status(401, "clé refusée".into()),
]);
*host.relay.lock().unwrap() = Some(relay_msg());
*host.keep.lock().unwrap() = Some(3200);
let (r, _) = run(&host, &config(&["see_image"]), &Cancel::never()).await;
assert!(r.err.is_some());
assert_eq!(r.extra.len(), 2, "{:?}", r.extra);
assert!(r.extra.iter().all(|m| !m.img_relay));
}
/// En cours de tour, au seuil : les images gardées partent d'abord, et le
/// résumé n'est pas demandé si leur retrait suffit.
#[tokio::test]
async fn images_retirees_en_cours_de_tour() {
let mut glob = vec![call("g1", "glob", r#"{"pattern":"*.go"}"#)];
glob.extend(finish_u("tool_calls", 1600, 10));
let host = Fake::new(vec![Script::Ok(glob), answer_u("fini", 0)]);
let mut tc = config(&["glob"]);
tc.compact.ctx_window = 2000;
let hist = vec![
um("q"),
tool_res("[ok]"),
kept_relay(1000),
text_msg("assistant", "vu"),
um("suite"),
];
let (r, evs) = run_hist(&host, &tc, hist.clone()).await;
assert_eq!(r.err, None);
assert_eq!(host.sent(), 2);
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
let published = evs
.iter()
.rev()
.find_map(|e| match e {
StreamEvent::NewHistory(h) => Some(h.clone()),
_ => None,
})
.expect("historique publié");
assert_eq!(published.len(), hist.len() + 2);
assert_eq!(
published[2].content,
Some(json!(format!(
"Voici la capture demandée.{}",
crate::chat::compact::IMAGE_LOST_MARKER
)))
);
assert_eq!(
host.body(1)["messages"][2]["content"],
published[2].content.clone().unwrap()
);
assert!(r.extra.is_empty(), "{:?}", r.extra);
}
/// Prompt refusé pour débordement : les images gardées partent d'abord, la
/// requête est rejouée sans elles, sans résumé.
#[tokio::test]
async fn images_retirees_avant_le_filet_reactif() {
let host = Fake::new(vec![
Script::Status(
400,
r#"{"error":{"code":400,"message":"request (2500 tokens) exceeds the available context size (2000 tokens), try increasing it"}}"#.into(),
),
answer_u("fini", 0),
]);
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
let mut tc = config(&[]);
tc.compact.ctx_window = 2000;
let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")];
let (r, evs) = run_hist(&host, &tc, hist.clone()).await;
assert_eq!(r.err, None);
assert_eq!(host.sent(), 2);
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
assert!(evs.iter().any(
|e| matches!(e, StreamEvent::NewHistory(h) if h.len() == hist.len() && h[2].img_tokens == 0)
));
assert!(host.body(1)["messages"][2]["content"].is_string());
}
/// Coupée par une fenêtre pleine : les images gardées d'abord ; leur retrait
/// suffit, l'étape est rejouée sans résumé.
#[tokio::test]
async fn images_retirees_fenetre_pleine() {
let mut cut = vec![];
cut.extend(finish_u("length", 1500, 200));
let host = Fake::new(vec![Script::Ok(cut), answer_u("fini", 0)]);
*host.compacted.lock().unwrap() = Some(vec![um("résumé")]);
let mut tc = config(&[]);
tc.compact.ctx_window = 2000;
let hist = vec![um("q"), tool_res("[ok]"), kept_relay(1000), um("suite")];
let (r, evs) = run_hist(&host, &tc, hist).await;
assert_eq!(r.err, None);
assert_eq!(host.sent(), 2);
assert!(host.phases.lock().unwrap().is_empty(), "pas de résumé");
assert!(evs.iter().any(|e| matches!(e, StreamEvent::DropReasoning)));
assert_eq!(text(&evs), "fini");
}