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

+6 -7
View File
@@ -48,17 +48,16 @@ func facadeChat() bool {
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, images gardées, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) :
// la façade a sa sonde de gabarit ; ni les motifs de relance personnalisés
// (retry_patterns, lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et
// SLOT_PERSIST (lot 31) : la façade demande son slot avant chaque requête
// (facade_slots.go).
// figé, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa
// sonde de gabarit ; ni les motifs de relance personnalisés (retry_patterns,
// lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et SLOT_PERSIST
// (lot 31) : la façade demande son slot avant chaque requête
// (facade_slots.go) ; ni KEEP_TURN_IMAGES (lot 32) : la façade tient l'état
// du tour, le Go range les images (facade_keepimg.go).
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case prewarmMode(cfg) != "":
return "PREWARM"
}
+4 -4
View File
@@ -277,7 +277,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
}
// Posé après la décision : sans effet avant le redémarrage.
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
if err := SetConfigKey("PREWARM", "on"); err != nil {
t.Fatal(err)
}
if !facadeChat() {
@@ -285,10 +285,10 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
}
oublierDecisionFacade(t)
if facadeChat() {
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
t.Fatal("PREWARM posé : la conversation doit rester au Go")
}
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "PREWARM") {
t.Fatalf("détenteur : %s", w.Body.String())
}
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
@@ -299,7 +299,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
"SLOT_PERSIST": "", "PREWARM": "PREWARM",
"SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
t.Errorf("%s : %q", k, got)
+47
View File
@@ -0,0 +1,47 @@
package loki
import (
"net/http"
)
// facade_keepimg.go — KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32
// de la migration). L'état du tour (images à l'essai, mesure, budget) vit dans
// la façade ; ce qui touche aux fichiers et à la vision reste ici :
//
// - /internal/v1/turn/keepimg : la vision est-elle réellement active
// (visionEnabled, engineSeesImages) et le budget des images gardées
// (keepImgBudget) ;
// - /internal/v1/turn/keepimg/store : une image relayée rangée par
// référence (refRelayImage : fichier chiffré si la mémoire l'est, date
// rafraîchie, relu à l'octet près).
// handleInternalTurnKeepImg : POST {} → {"vision":bool,"budget":n}.
func handleInternalTurnKeepImg(w http.ResponseWriter, r *http.Request) {
var in struct{}
if !readInternalJSON(w, r, &in) {
return
}
sendJSON(w, http.StatusOK, map[string]any{
"vision": visionEnabled() && engineSeesImages(),
"budget": keepImgBudget(),
})
}
// handleInternalTurnKeepImgStore : POST {"msg":Message} →
// {"ok":true,"msg":Message,"weight":octets} ou {"ok":false} (format inconnu,
// disque plein, mémoire verrouillée, relecture différente : l'image reste
// éphémère).
func handleInternalTurnKeepImgStore(w http.ResponseWriter, r *http.Request) {
var in struct {
Msg Message `json:"msg"`
}
if !readInternalJSON(w, r, &in) {
return
}
m, weight, ok := refRelayImage(in.Msg)
if !ok {
sendJSON(w, http.StatusOK, map[string]any{"ok": false})
return
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "msg": m, "weight": weight})
}
+87
View File
@@ -0,0 +1,87 @@
package loki
import (
"encoding/json"
"os"
"strings"
"testing"
)
// L'état que la façade demande au début d'un tour : vision réellement active
// (projecteur déclaré ET vu par le moteur) et budget des images gardées.
func TestFacadeKeepImgEtat(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
if err := SetConfigKey("CTX", "2000"); err != nil {
t.Fatal(err)
}
etat := func() (bool, float64) {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg", `{}`)
var out struct {
Vision bool `json:"vision"`
Budget float64 `json:"budget"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
t.Fatalf("état : %d %s", w.Code, w.Body.String())
}
return out.Vision, out.Budget
}
if v, b := etat(); v || b != 200 {
t.Fatalf("sans projecteur : vision=%v budget=%v", v, b)
}
visionServer(t, "")
if v, _ := etat(); !v {
t.Fatal("projecteur vu par le moteur : vision attendue")
}
if w := appelOAIInterne(t, "GET", "/internal/v1/turn/keepimg", ""); w.Code != 405 {
t.Fatalf("GET : %d", w.Code)
}
}
// Une image relayée rangée par référence, relue à l'octet près ; sinon elle
// reste éphémère.
func TestFacadeKeepImgRangement(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
store := func(content string) map[string]any {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{"msg":{"role":"user","content":`+content+`}}`)
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 {
t.Fatalf("rangement : %d %s", w.Code, w.Body.String())
}
return out
}
out := store(`[{"type":"text","text":"Voici la capture demandée."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`)
if out["ok"] != true || out["weight"] != float64(8) {
t.Fatalf("rangée : %v", out)
}
msg := out["msg"].(map[string]any)
parts := msg["content"].([]any)
if parts[0].(map[string]any)["text"] != "Voici la capture demandée." {
t.Fatalf("légende : %v", parts)
}
ref := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string)
if !strings.HasPrefix(ref, imgRefScheme) {
t.Fatalf("pas par référence : %s", ref)
}
if back, ok := chatImageDataURL(ref); !ok || back != "data:image/png;base64,iVBORw0KGgo=" {
t.Fatalf("relecture : %v %.40s", ok, back)
}
if ents, _ := os.ReadDir(chatImgDir()); len(ents) != 1 {
t.Fatalf("%d fichier(s) rangé(s)", len(ents))
}
for _, c := range []string{
`"texte seul"`,
`[{"type":"image_url","image_url":{"url":"https://exemple.org/a.png"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/x-inconnu;base64,AAAA"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,pas du base64"}}]`,
`[{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`,
} {
if out := store(c); out["ok"] != false {
t.Errorf("%s : %v", c, out)
}
}
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{`); w.Code != 400 {
t.Fatalf("corps illisible : %d", w.Code)
}
}
+4
View File
@@ -75,6 +75,10 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalTurnPerf(w, r)
case "/internal/v1/turn/slot":
handleInternalTurnSlot(w, r)
case "/internal/v1/turn/keepimg":
handleInternalTurnKeepImg(w, r)
case "/internal/v1/turn/keepimg/store":
handleInternalTurnKeepImgStore(w, r)
case "/internal/v1/tool/exec":
handleInternalToolExec(w, r)
case "/internal/v1/tool/context":