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)