From 39487f9449d6e8ca1ecdb4d31c68878ca61720d8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 12:30:14 +0200 Subject: [PATCH] =?UTF-8?q?Cache=20:=20corrections=20de=20relecture=20du?= =?UTF-8?q?=20lot=202=20=E2=80=94=20KEEP=5FTURN=5FIMAGES=20retire=20les=20?= =?UTF-8?q?images=20avant=20tout=20r=C3=A9sum=C3=A9,=20partout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/chat_conversation.go | 6 +++ internal/loki/chat_keep_images_test.go | 64 ++++++++++++++++++++++++++ internal/loki/llm_client.go | 24 ++++++++++ 3 files changed, 94 insertions(+) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 8c272e6..21dde87 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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. // 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. + // 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) { return } diff --git a/internal/loki/chat_keep_images_test.go b/internal/loki/chat_keep_images_test.go index 590f644..b18ebc1 100644 --- a/internal/loki/chat_keep_images_test.go +++ b/internal/loki/chat_keep_images_test.go @@ -541,3 +541,67 @@ func TestKeepImagesBudgetPleinAvantRangement(t *testing.T) { 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) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 68ab445..a265272 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1560,6 +1560,18 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools if echoOffTurn { 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 c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true @@ -2646,6 +2658,18 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools if finishReason == "length" && !ep.External && lengthReplays < 1 && compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) { 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 cb(StreamEvent{Compacting: &yes}) c, changed := compactMessages(ctx, messages, caps)