From b0c53e32af6990e2a7531bbd145b989bf3726aa7 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 09:25:35 +0200 Subject: [PATCH] =?UTF-8?q?Compaction=20:=20COMPACT=5FCONTINUATION,=20rele?= =?UTF-8?q?cture=20=E2=80=94=20un=20refus=20m=C3=A9moris=C3=A9=20ne=20vaut?= =?UTF-8?q?=20que=20pour=20le=20m=C3=AAme=20fil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le refus mémorisé ne regardait que la taille du contexte. Il n'est évalué qu'au-dessus du seuil : une discussion vidée, éditée ou régénérée qui remontait dans la même plage de jetons voyait sa compaction sautée sur la foi d'un refus qui concernait un autre fil. - le refus garde l'empreinte de l'historique (celle de perfPrefix) ; seul un historique qui prolonge celui d'alors en profite, tout autre retente - testé : historique modifié ou raccourci, refus noté par une vraie compaction, filet réactif jamais bloqué Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++- internal/loki/chat_compact_cont.go | 44 ++++++++++++------ internal/loki/chat_compact_cont_test.go | 60 +++++++++++++++++++------ internal/loki/chat_conversation.go | 4 +- internal/loki/llm_client.go | 2 +- 5 files changed, 83 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7e11e51..1dd10a5 100644 --- a/README.md +++ b/README.md @@ -428,8 +428,9 @@ Ajoutées par ce fork : modèle jusqu'au redémarrage (gpt-oss à intensité haute peut épuiser le budget en réflexion : repli). La clé évite aussi un résumé voué au refus (même vide, il ne réduirait pas le contexte de 20 %) et, après un refus faute de réduction, - n'en redemande pas avant que le contexte ait grossi de 10 % — sauf à 90 % de la - fenêtre. Le filet réactif (prompt refusé), le bouton « compacter », les tâches, + n'en redemande pas avant que le contexte ait grossi de 10 % sur le même fil — + sauf à 90 % de la fenêtre ou après une édition, une régénération ou un fil + vidé. Le filet réactif (prompt refusé), le bouton « compacter », les tâches, les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la télémétrie sous `compact`. - **Discussions multiples** : historique complet dans la barre latérale, titre diff --git a/internal/loki/chat_compact_cont.go b/internal/loki/chat_compact_cont.go index b87eb03..6ea32eb 100644 --- a/internal/loki/chat_compact_cont.go +++ b/internal/loki/chat_compact_cont.go @@ -48,8 +48,9 @@ package loki // garantie de réduction (compactHopeless, borne exacte) ; // - une compaction refusée parce que le résumé obtenu était trop long n'est // retentée, en début ou fin de tour et entre deux étapes, qu'une fois le -// contexte grossi de 10 % — jamais pour le filet réactif, le bouton -// « compacter », une fenêtre pleine à 90 % ou plus. +// contexte grossi de 10 % sur le même fil (un historique réécrit retente) +// — jamais pour le filet réactif, le bouton « compacter », une fenêtre +// pleine à 90 % ou plus. // // Réactif (prompt refusé par le moteur), fenêtre pleine en pleine génération, // bouton manuel, tâches, sous-agents, terminal : chemin d'avant, toujours. @@ -159,7 +160,7 @@ func compactMessagesNoted(ctx context.Context, msgs []Message, caps Caps, opt co if changed { compactRefusedClear(opt.conv) } else if refused { - compactRefusedNote(opt.conv, opt.used) + compactRefusedNote(opt.conv, opt.used, msgs) } } return out, changed @@ -168,10 +169,22 @@ func compactMessagesNoted(ctx context.Context, msgs []Message, caps Caps, opt co // --- refus mémorisés -------------------------------------------------------- // compactRefusal : une compaction refusée faute de réduction, à at jetons de -// contexte. En mémoire seulement : un redémarrage retente. +// contexte, sur un historique de n messages d'empreinte hash. En mémoire +// seulement : un redémarrage retente. type compactRefusal struct { at, window int model string + n int + hash uint64 +} + +// historyHash : empreinte d'un historique (celle de perfPrefix). +func historyHash(msgs []Message) uint64 { + if len(msgs) == 0 { + return 0 + } + p := perfPrefix(msgs) + return p[len(p)-1] } var compactRefused struct { @@ -181,23 +194,25 @@ var compactRefused struct { const compactRefusedMax = 64 -// compactRefusedSkip : la dernière compaction de conv a été refusée et le -// contexte n'a pas grossi de 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; -// oublié dès que le contexte baisse (compaction, réécriture, nouvelle -// discussion) ou que le modèle ou la fenêtre changent. -func compactRefusedSkip(conv string, used int) bool { +// compactRefusedSkip : la dernière compaction de conv a été refusée, msgs ne +// fait que prolonger l'historique d'alors, et le contexte n'a pas grossi de +// 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; oublié dès que +// l'historique a été réécrit (compaction, édition, régénération, discussion +// vidée), que le contexte baisse, ou que le modèle ou la fenêtre changent. +func compactRefusedSkip(conv string, used int, msgs []Message) bool { if conv == "" || used <= 0 || !compactContinuationOn(ReadConfig()) { return false } model, window := engineMainNow() compactRefused.mu.Lock() - defer compactRefused.mu.Unlock() r, ok := compactRefused.m[conv] + compactRefused.mu.Unlock() if !ok { return false } - if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 { - delete(compactRefused.m, conv) + if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 || + len(msgs) < r.n || historyHash(msgs[:r.n]) != r.hash { + compactRefusedClear(conv) return false } if used*10 >= window*9 { @@ -207,11 +222,12 @@ func compactRefusedSkip(conv string, used int) bool { return true } -func compactRefusedNote(conv string, used int) { +func compactRefusedNote(conv string, used int, msgs []Message) { if conv == "" || used <= 0 { return } model, window := engineMainNow() + hash := historyHash(msgs) compactRefused.mu.Lock() defer compactRefused.mu.Unlock() if compactRefused.m == nil { @@ -223,7 +239,7 @@ func compactRefusedNote(conv string, used int) { break } } - compactRefused.m[conv] = compactRefusal{at: used, window: window, model: model} + compactRefused.m[conv] = compactRefusal{at: used, window: window, model: model, n: len(msgs), hash: hash} } func compactRefusedClear(conv string) { diff --git a/internal/loki/chat_compact_cont_test.go b/internal/loki/chat_compact_cont_test.go index 1093be3..8e98122 100644 --- a/internal/loki/chat_compact_cont_test.go +++ b/internal/loki/chat_compact_cont_test.go @@ -219,7 +219,7 @@ func TestCompactContDefautIdentique(t *testing.T) { if stamp.seq != 0 { t.Fatal("tampon de slot posé sans la clé") } - if compactContinuationOn(ReadConfig()) || compactRefusedSkip(convActiveID(), 7000) { + if compactContinuationOn(ReadConfig()) || compactRefusedSkip(convActiveID(), 7000, nil) { t.Fatal("continuation ou mémoire des refus active sans la clé") } } @@ -538,6 +538,25 @@ func TestCompactHopeless(t *testing.T) { if changed || !refused || len(m.all()) != 1 { t.Fatalf("sans pré-contrôle : changed=%v refused=%v requêtes=%d", changed, refused, len(m.all())) } + // Un refus après résumé est noté ; il n'arrête ni le filet réactif ni le + // bouton manuel (compactMessages, sans options), qui redemandent un résumé. + if _, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, compactOpts{conv: "c9", used: 7000}); changed { + t.Fatal("compaction acceptée") + } + compactRefused.mu.Lock() + _, noted := compactRefused.m["c9"] + compactRefused.mu.Unlock() + if !noted { + t.Fatal("refus non noté") + } + if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil { + t.Fatal(err) + } + before := len(m.all()) + compactMessages(context.Background(), msgs, Caps{}) + if len(m.all()) != before+1 { + t.Fatal("le chemin réactif ne doit pas tenir compte des refus mémorisés") + } // Un cas qui réussit n'est jamais déclaré sans issue. h := contHistory() head, tailStart = compactBounds(h, int(float64(estimateTokens(h))*compactTailFrac)) @@ -547,40 +566,55 @@ func TestCompactHopeless(t *testing.T) { } // Mémoire des refus : pas de nouvel essai avant +10 % de contexte, jamais à -// 90 % de la fenêtre, oubliée quand le contexte baisse ; rien sans la clé. +// 90 % de la fenêtre, oubliée quand le contexte baisse ou que l'historique est +// réécrit ; rien sans la clé. func TestCompactRefusedSkip(t *testing.T) { testHome(t) contReset(t) if err := SetConfigKey("CTX", "65536"); err != nil { t.Fatal(err) } - compactRefusedNote("c1", 50000) - if compactRefusedSkip("c1", 52000) { + h := contHistory() + grown := append(append([]Message(nil), h...), um("suite")) + compactRefusedNote("c1", 50000, h) + if compactRefusedSkip("c1", 52000, grown) { t.Fatal("sans la clé, aucun essai ne doit être sauté") } if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil { t.Fatal(err) } - if !compactRefusedSkip("c1", 52000) || compactRefusedSkip("c2", 52000) { + if !compactRefusedSkip("c1", 52000, grown) || compactRefusedSkip("c2", 52000, grown) { t.Fatal("refus mal appliqué") } - if compactRefusedSkip("c1", 59000) { + if compactRefusedSkip("c1", 59000, grown) { t.Fatal("essai sauté à 90 % de la fenêtre") } - if compactRefusedSkip("c1", 55000) { + if compactRefusedSkip("c1", 55000, grown) { t.Fatal("essai sauté après +10 %") } - if compactRefusedSkip("c1", 52000) { + if compactRefusedSkip("c1", 52000, grown) { t.Fatal("refus non oublié après +10 %") } - compactRefusedNote("c1", 50000) - if compactRefusedSkip("c1", 30000) || compactRefusedSkip("c1", 50000) { + compactRefusedNote("c1", 50000, h) + if compactRefusedSkip("c1", 30000, grown) || compactRefusedSkip("c1", 50000, grown) { t.Fatal("refus non oublié quand le contexte baisse") } - // Une compaction refusée note le refus ; réussie, elle l'efface. - compactRefusedNote("c1", 50000) + // Historique réécrit (édition, régénération, discussion vidée puis + // regarnie) : même taille de contexte, autre fil — on retente. + compactRefusedNote("c1", 50000, h) + edited := append([]Message(nil), grown...) + edited[3] = um("message modifié") + if compactRefusedSkip("c1", 51000, edited) || compactRefusedSkip("c1", 51000, grown) { + t.Fatal("refus gardé pour un historique réécrit") + } + compactRefusedNote("c1", 50000, h) + if compactRefusedSkip("c1", 51000, h[:5]) { + t.Fatal("refus gardé pour un historique raccourci") + } + // Une compaction réussie l'efface. + compactRefusedNote("c1", 50000, h) compactRefusedClear("c1") - if compactRefusedSkip("c1", 51000) { + if compactRefusedSkip("c1", 51000, grown) { t.Fatal("refus non effacé") } } diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 68f0c95..987f691 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -804,7 +804,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // tout de suite, et le résumé peut prolonger le prompt en cache. Sans la // clé, ni l'un ni l'autre. conv := perfTagOf(ctx).conv - if compactNeeded(msgs, ctxUsed, peak) && !compactRefusedSkip(conv, ctxUsed) { + if compactNeeded(msgs, ctxUsed, peak) && !compactRefusedSkip(conv, ctxUsed, msgs) { opt := c.compactOptsFor(caps, msgs, ctxUsed, conv) if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps, opt); changed { msgs = out @@ -1017,7 +1017,7 @@ 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. - if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed) { + if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed, msgs) { return } // context.Background() et non ctx : le tour est terminé, son contexte peut diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 945cacb..91c0bf8 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -2501,7 +2501,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera if ptag.kind == perfMain { mainConv = ptag.conv } - if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used) { + if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) { opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol, disableTools || toolChoiceNone) yes, no := true, false