diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index fe59a3a..77e3372 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -234,12 +234,18 @@ func compactNeeded(msgs []Message, used, peakGen int) bool { // fenêtre pour les petites), plus une petite marge. Fonction pure, testable. // Plancher et marge sont choisis pour ne JAMAIS devancer le seuil de 75 % à eux // seuls : seule une génération longue avance la compaction. +// +// Et jamais avant la MOITIÉ de la fenêtre, si longue que soit la génération : +// une étape de 40 k sur 65 k (ou de 20 k sur 32 k) réclamait sinon une place +// que l'historique compacté lui-même — prompt système, outils, résumé, queue — +// ne laisse pas. La garde repartait alors à CHAQUE étape, résumé de résumé, le +// fil perdu pour rien. Au-delà, c'est le rejeu sur « length » qui rattrape. func genHeadroomShort(used, peakGen, window int) bool { if used <= 0 || peakGen <= 0 || window <= 0 { return false } need := max(peakGen*6/5, min(8192, window/8)) + min(1024, window/32) - return used+need > window + return used+min(need, window/2) > window } // logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index bda8d69..796c7b3 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -752,27 +752,35 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // sous-agents, vérification) est rattaché à la discussion active. ctx = withPerf(ctx, perfMain, getStr(bkChat, ckActive)) + // llama-server local seulement : le preset externe garde le seul seuil, et + // ses complétions ne nourrissent pas la garde de marge (compactNeeded). + local := !externalActive() // Snapshot de la vue modèle. c.mu.Lock() msgs := append([]Message(nil), c.Messages...) // Dernier compte du moteur + ce qu'il n'a pas encore vu (ce message-ci, la // file). Sans ce complément, seul le raisonnement compté à tort masquait le // manque ; maintenant qu'il est retiré, le nouveau message doit compter. - ctxUsed := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) + ctxUsed := c.ctxNowLocked(msgs, local) peak := c.genPeak c.genPeak = 0 // recompté pendant ce tour c.mu.Unlock() - // llama-server local seulement : le preset externe garde le seul seuil, et - // ses complétions ne nourrissent pas la garde de marge (compactNeeded). - local := !externalActive() // Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal // d'affichage garde le fil complet. Le résumé est un appel modèle non streamé : // il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la // bannière de progression émise par compactAndPublish. + // Estimation de début de tour, comparée au premier compte réel du moteur + // (journal [ctx], seulement au-delà de 2 % d'écart). + startEst := ctxUsed if compactNeeded(msgs, ctxUsed, peak) { if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed { msgs = out + // Le chiffre d'avant compaction ne dit plus rien de la requête qui + // part : on compare celui que la compaction vient de poser. + c.mu.Lock() + startEst = c.CtxUsed + c.mu.Unlock() } } @@ -809,9 +817,6 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote // déjà la compaction), approximative mais jamais absente. sawUsage := false - // Estimation de début de tour, comparée au premier compte réel du moteur - // (journal [ctx], seulement au-delà de 2 % d'écart). - startEst := ctxUsed turnPeak := 0 // asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) — // pas de vérification du mode Code avant sa réponse. @@ -923,7 +928,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.genPeak = max(c.genPeak, turnPeak) } msgs = append([]Message(nil), c.Messages...) - ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) + ctxUsed = c.ctxNowLocked(msgs, local) peak = c.genPeak stale := c.epoch != epoch c.mu.Unlock() @@ -950,7 +955,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.codeVerifyLoop(ctx, caps, temperature, epoch, asked) c.mu.Lock() msgs = append([]Message(nil), c.Messages...) - ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) + ctxUsed = c.ctxNowLocked(msgs, local) peak = c.genPeak stale = c.epoch != epoch c.mu.Unlock() @@ -973,6 +978,18 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps) } +// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server +// local : le dernier compte plus les messages arrivés depuis (ctxPending). Preset +// externe : le dernier compte seul, comme avant — son CtxUsed garde encore le +// raisonnement (ctxAfter n'y retire rien), qui couvre déjà ce complément ; l'y +// ajouter le ferait compacter plus tôt qu'avant. +func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int { + if !local { + return c.CtxUsed + } + return ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) +} + // CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil // (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière // de progression, résume les anciens tours, remplace le torse et persiste. Les @@ -982,6 +999,7 @@ func (c *Conversation) CompactNow() error { if !healthCheck() { return errModelLoading } + local := !externalActive() c.mu.Lock() if c.Generating { c.mu.Unlock() @@ -991,7 +1009,7 @@ func (c *Conversation) CompactNow() error { ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel msgs := append([]Message(nil), c.Messages...) - lastReal := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) // dernier contexte réel mesuré, pour estimer le surcoût fixe + lastReal := c.ctxNowLocked(msgs, local) // dernier contexte réel mesuré, pour estimer le surcoût fixe epoch := c.epoch c.mu.Unlock() diff --git a/internal/loki/ctx_accounting_test.go b/internal/loki/ctx_accounting_test.go index 1be73c9..50cefe8 100644 --- a/internal/loki/ctx_accounting_test.go +++ b/internal/loki/ctx_accounting_test.go @@ -47,6 +47,8 @@ func TestGenHeadroomShort(t *testing.T) { {"65k, courte : plancher 8k + marge, sous 84 %", 55000, 2000, 65536, false, true}, {"65k, longue : 1,2×15k ne tient plus", 47000, 15000, 65536, true, false}, {"65k, longue mais la place y est", 40000, 15000, 65536, false, false}, + {"65k, énorme (45k) mais historique tout juste compacté", 20000, 45000, 65536, false, false}, + {"65k, énorme (45k) au-delà de la moitié", 33000, 45000, 65536, true, false}, {"131k, courte : rien avant le seuil", 98000, 4000, 131072, false, false}, {"131k, très longue génération", 95000, 30000, 131072, true, false}, {"8k, plancher ramené à 1/8", 6000, 0, 8192, false, false}, @@ -67,6 +69,14 @@ func TestGenHeadroomShort(t *testing.T) { if genHeadroomShort(below, 1, w) { t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w) } + // Génération aussi longue que la fenêtre : jamais avant la moitié, sinon + // l'historique fraîchement compacté la redéclenche à chaque étape. + if genHeadroomShort(w/2, w, w) { + t.Errorf("fenêtre %d : la garde part à 50 %% (compaction en boucle)", w) + } + if !genHeadroomShort(w/2+1, w, w) { + t.Errorf("fenêtre %d : la garde ne part pas au-delà de 50 %% avec une génération énorme", w) + } } } @@ -292,3 +302,16 @@ func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) { t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak) } } + +// Preset externe : le compte mesuré seul, comme avant ; local : complété des +// messages arrivés depuis la mesure. +func TestCtxNowLockedExternal(t *testing.T) { + msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))} + c := &Conversation{CtxUsed: 30000, ctxUsedLen: 2} + if got := c.ctxNowLocked(msgs, false); got != 30000 { + t.Errorf("externe : %d, attendu le compte mesuré seul (30000)", got) + } + if got, want := c.ctxNowLocked(msgs, true), 30000+estimateTokens(msgs[2:]); got != want { + t.Errorf("local : %d, attendu %d", got, want) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 114c94f..4ac0bc2 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -2038,6 +2038,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps cb(StreamEvent{Compacting: &no}) logCompact("en-tour", used, messages, c, changed) if changed { + lastEst = 0 // estimation d'avant compaction : plus rien à comparer messages = c // La nouvelle base contient déjà tout ce tour : on la publie et on // repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec