Cache : corrections de relecture du lot 2 — KEEP_TURN_IMAGES retire les images avant tout résumé, partout

Le README promet que les images gardées partent d'abord et que le résumé
(avec perte) ne suit que s'il reste nécessaire. C'était vrai en début de tour
et entre deux étapes, pas ailleurs : la compaction de fin de tour, le filet
réactif (prompt refusé) et la fenêtre pleine en génération retiraient les
images ET résumaient d'un même geste, alors que le retrait seul suffisait.

- Fin de tour : même retrait que le début de tour, puis le seuil est relu.
- Prompt refusé pour débordement : images retirées, requête rejouée sans
  elles, avant la compaction de secours.
- finish=length : images retirées d'abord ; l'étape est rejouée sans résumé
  si le contexte libéré suffit.

Sans la clé, aucune image gardée : chemins d'avant à l'identique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 12:30:14 +02:00
1 parent 78d15cefbf
commit 39487f9449
3 files changed
+94

No files matched your search

+6
View File
@@ -1033,6 +1033,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// retombe tout de suite et le tour suivant démarre avec de la marge. // retombe tout de suite et le tour suivant démarre avec de la marge.
// Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé // Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
// (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop. // (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
// KEEP_TURN_IMAGES : comme en début de tour, les images gardées partent
// d'abord si le contexte le demande, et le résumé ne suit que s'il reste
// nécessaire. Sans la clé, aucune image gardée : rien ne change.
if !stale && ctx.Err() == nil {
msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak)
}
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed, msgs) { if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed, msgs) {
return return
} }
+64
View File
@@ -541,3 +541,67 @@ func TestKeepImagesBudgetPleinAvantRangement(t *testing.T) {
t.Fatal("image rangée alors que le budget était plein") t.Fatal("image rangée alors que le budget était plein")
} }
} }
// Corrections de relecture du lot 2 : prompt refusé pour débordement alors que
// des images sont gardées — elles partent d'abord et la requête est rejouée
// sans elles, sans résumé (avec perte) tant que ça suffit.
func TestKeepImagesRetireesAvantLeFiletReactif(t *testing.T) {
withWorkspace(t)
for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
var mu sync.Mutex
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/props" {
_, _ = w.Write([]byte(`{"modalities":{"vision":true}}`))
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
bodies = append(bodies, body)
n := len(bodies)
mu.Unlock()
if n == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"code":400,"message":"request (2500 tokens) exceeds the available context size (2000 tokens), try increasing it"}}`))
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseAnswer("fini", 0)))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
for k, v := range map[string]string{"PORT": u.Port(), "MMPROJ": "/models/mmproj.gguf"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
visionProbeMu.Lock()
visionProbeAt = time.Time{}
visionProbeMu.Unlock()
hist := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(1000), am("vu"), um("suite")}
var published []Message
if _, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.NewHistory != nil {
published = ev.NewHistory
}
return true
}); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 {
t.Fatalf("%d requêtes : un résumé est parti alors que retirer l'image suffisait", len(bodies))
}
if published == nil || hasKeptImages(published) || len(published) != len(hist) {
t.Fatalf("historique publié : %+v", published)
}
if relay := reqMessages(t, bodies[1])[3]; msgText(relay) != "Voici la capture demandée."+imageLostMarker {
t.Fatalf("image encore envoyée après son retrait : %+v", relay)
}
}
+24
View File
@@ -1560,6 +1560,18 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if echoOffTurn { if echoOffTurn {
messages = stripReasoning(messages) messages = stripReasoning(messages)
} }
// KEEP_TURN_IMAGES : les images gardées partent d'abord, et la requête
// est rejouée sans elles ; le résumé (avec perte) ne vient qu'ensuite,
// s'il le faut encore. Sans la clé, aucune image gardée.
if contextOverflow(msg, messages) && hasKeptImages(messages) {
if s, freed := dropKeptImages(messages); freed > 0 {
logKeptImagesDropped("réactif", freed)
messages = s
extra = nil
publishHistory()
continue
}
}
if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) { if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) {
if c, changed := compactMessages(ctx, messages, caps); changed { if c, changed := compactMessages(ctx, messages, caps); changed {
compactedRetry = true compactedRetry = true
@@ -2646,6 +2658,18 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if finishReason == "length" && !ep.External && lengthReplays < 1 && if finishReason == "length" && !ep.External && lengthReplays < 1 &&
compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) { compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) {
lengthReplays++ lengthReplays++
// KEEP_TURN_IMAGES : les images gardées d'abord ; le résumé ne suit
// que s'il reste nécessaire. Sans la clé, rien à retirer.
if s, freed := dropKeptImages(messages); freed > 0 {
logKeptImagesDropped("fenêtre-pleine", freed)
messages = s
extra = nil
publishHistory()
if !compactWouldTrigger(messages, max(stats.PromptTokensTotal+stats.GenTokens-freed, 1)) {
cb(StreamEvent{DropReasoning: true})
continue
}
}
yes, no := true, false yes, no := true, false
cb(StreamEvent{Compacting: &yes}) cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps) c, changed := compactMessages(ctx, messages, caps)