mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
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:
17 files changed
+1435
-37
No files matched your search
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user