diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go
index d395c8d..fe59a3a 100644
--- a/internal/loki/chat_compact.go
+++ b/internal/loki/chat_compact.go
@@ -215,6 +215,63 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
return used >= int(float64(ctxWindow())*compactTriggerFrac)
}
+// compactNeeded = compactWouldTrigger, plus une garde de MARGE DE GÉNÉRATION.
+// Tant que le raisonnement de la dernière étape était compté dans le contexte
+// (à tort : il n'est jamais renvoyé), il offrait par accident une marge à peu
+// près proportionnelle à la longueur des étapes. Ce compte est désormais juste,
+// et sans max_tokens ni budget de raisonnement, une étape aussi longue que la
+// plus longue récente doit encore tenir : sinon le moteur coupe en plein
+// raisonnement (« length »). peakGen = 0 (inconnu, API externe) : seuil seul.
+func compactNeeded(msgs []Message, used, peakGen int) bool {
+ if compactWouldTrigger(msgs, used) {
+ return true
+ }
+ return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
+}
+
+// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
+// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
+// 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.
+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
+}
+
+// logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après
+// un raisonnement sans action) : de quoi vérifier, journal en main, qu'ils ne
+// deviennent pas plus fréquents.
+func logCtx(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "[ctx] "+format+"\n", args...)
+}
+
+// logCtxEstimate compare le contexte ESTIMÉ avant une requête au compte réel
+// du moteur (usage.prompt_tokens), et ne parle qu'au-delà de 2 % d'écart.
+func logCtxEstimate(phase string, est, actual int) {
+ if actual <= 0 {
+ return
+ }
+ if d := est - actual; d*50 > actual || -d*50 > actual {
+ logCtx("%s estimé=%d réel=%d écart=%+d", phase, est, actual, d)
+ }
+}
+
+// ctxPending ajoute au dernier contexte mesuré (used, valable pour les
+// measured premiers messages) l'estimation des messages arrivés depuis : le
+// nouveau message utilisateur, ceux de la file. Le compte du moteur ne les a
+// pas vus. measured = 0 (inconnu, discussion rechargée) ou plus grand que
+// l'historique (réécrit entre-temps) : on s'en tient au compte mesuré.
+func ctxPending(used, measured int, msgs []Message) int {
+ if used <= 0 || measured <= 0 || measured > len(msgs) {
+ return used
+ }
+ return used + estimateTokens(msgs[measured:])
+}
+
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (donc dans `journalctl -u loki-ui`). Sans ça, une compaction qui ne se
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go
index be4c705..bda8d69 100644
--- a/internal/loki/chat_conversation.go
+++ b/internal/loki/chat_conversation.go
@@ -59,6 +59,14 @@ type Conversation struct {
Log []LogEvent `json:"log"` // vue « UI » rejouable
Seq int `json:"seq"`
CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour
+ // ctxUsedLen : nombre de messages de Messages que CtxUsed couvre déjà (0 =
+ // inconnu). Ce qui arrive ensuite — le message utilisateur du tour suivant,
+ // la file — est estimé en plus par ctxPending : le moteur ne l'a pas encore
+ // compté. genPeak : génération la plus longue du dernier tour (llama-server
+ // local), pour la garde de marge de compactNeeded. Ni l'un ni l'autre n'est
+ // persisté : un rechargement repart du seul CtxUsed, comme avant.
+ ctxUsedLen int
+ genPeak int
Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop)
@@ -189,6 +197,7 @@ func (c *Conversation) loadFrom(b []byte) {
c.Stop()
c.mu.Lock()
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
+ c.ctxUsedLen, c.genPeak = 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
@@ -401,6 +410,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
if c.epoch == epoch {
c.Messages = compacted
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
+ c.ctxUsedLen = len(compacted)
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"compacted": true})
@@ -745,14 +755,22 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Snapshot de la vue modèle.
c.mu.Lock()
msgs := append([]Message(nil), c.Messages...)
- ctxUsed := c.CtxUsed
+ // 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)
+ 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.
- if compactWouldTrigger(msgs, ctxUsed) {
+ if compactNeeded(msgs, ctxUsed, peak) {
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
msgs = out
}
@@ -791,6 +809,10 @@ 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.
asked := false
@@ -847,16 +869,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// compaction de début de tour.
c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting})
case ev.Stats != nil:
- // Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
- // et la décision de compactage au tour suivant.
+ // Taille réelle du contexte (usage.prompt_tokens + généré, raisonnement
+ // non renvoyé retiré) pour le compteur et la décision de compactage au
+ // tour suivant.
if ev.Stats.PromptTokensTotal > 0 {
+ if !sawUsage && local && startEst > 0 {
+ logCtxEstimate("début-tour", startEst, ev.Stats.PromptTokensTotal)
+ }
sawUsage = true
c.mu.Lock()
if c.epoch == epoch {
- c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
+ c.CtxUsed = ev.Stats.ctxAfter()
}
c.mu.Unlock()
}
+ if local {
+ turnPeak = max(turnPeak, ev.Stats.GenTokens)
+ }
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
@@ -886,9 +915,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
+ // Le dernier compte couvre tout ce qui vient d'être rangé : la dernière
+ // requête portait le tour entier, sa génération en est la réponse.
+ if sawUsage {
+ c.ctxUsedLen = len(c.Messages)
+ }
+ c.genPeak = max(c.genPeak, turnPeak)
}
msgs = append([]Message(nil), c.Messages...)
- ctxUsed = c.CtxUsed
+ ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
+ peak = c.genPeak
stale := c.epoch != epoch
c.mu.Unlock()
@@ -900,6 +936,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = est
+ c.ctxUsedLen = len(msgs)
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"ctx_used": est})
@@ -913,7 +950,8 @@ 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 = c.CtxUsed
+ ctxUsed = ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
+ peak = c.genPeak
stale = c.epoch != epoch
c.mu.Unlock()
}
@@ -927,7 +965,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 || !compactWouldTrigger(msgs, ctxUsed) {
+ if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) {
return
}
// context.Background() et non ctx : le tour est terminé, son contexte peut
@@ -953,7 +991,7 @@ func (c *Conversation) CompactNow() error {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
msgs := append([]Message(nil), c.Messages...)
- lastReal := c.CtxUsed // dernier contexte réel mesuré, pour estimer le surcoût fixe
+ lastReal := ctxPending(c.CtxUsed, c.ctxUsedLen, msgs) // dernier contexte réel mesuré, pour estimer le surcoût fixe
epoch := c.epoch
c.mu.Unlock()
@@ -1009,6 +1047,7 @@ func (c *Conversation) Reset() {
c.Log = nil
c.Seq = 0
c.CtxUsed = 0
+ c.ctxUsedLen, c.genPeak = 0, 0
c.epoch++
c.Generating = false
c.cancel = nil
diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go
index bbb4344..71c1d1b 100644
--- a/internal/loki/code_verify.go
+++ b/internal/loki/code_verify.go
@@ -116,7 +116,7 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
// dans le moteur aussi : effacé à la fin de la passe (llm_slots.go).
defer engineSideJob()()
_, _ = runChat(withPerfKind(ctx, perfVerify), msgs, temperature, vcaps, func(ev StreamEvent) bool {
- c.forwardStream(ev, epoch)
+ c.forwardStream(ev, epoch, true)
return true
})
}
@@ -165,7 +165,11 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
var content strings.Builder
+ sawUsage := false
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
+ if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
+ sawUsage = true
+ }
if ev.Content != "" {
content.WriteString(ev.Content)
}
@@ -175,7 +179,7 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
if ev.Ask != nil {
asked = true
}
- c.forwardStream(ev, epoch)
+ c.forwardStream(ev, epoch, false)
return true
})
c.mu.Lock()
@@ -184,6 +188,9 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
+ if sawUsage {
+ c.ctxUsedLen = len(c.Messages) // même règle que generate
+ }
}
c.mu.Unlock()
c.persist()
@@ -193,7 +200,13 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
// forwardStream relaie les événements d'un runChat secondaire (vérification,
// correction) vers le journal d'affichage — même mapping que generate(), sans
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
-func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
+//
+// isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur
+// l'historique de la discussion. Ses comptes ne disent rien du contexte de
+// celle-ci : ils écrasaient CtxUsed avec la petite taille du vérificateur, et
+// le contrôle de fin de tour — puis celui du tour suivant — décidaient sur ce
+// chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated).
+func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
@@ -223,13 +236,21 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.Stats != nil:
- if ev.Stats.PromptTokensTotal > 0 {
- c.mu.Lock()
- if c.epoch == epoch {
- c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
- }
- c.mu.Unlock()
+ if isolated {
+ c.appendDelta(epoch, map[string]any{"stats": ev.Stats, "ctx_isolated": true})
+ break
}
+ local := !externalActive()
+ c.mu.Lock()
+ if c.epoch == epoch {
+ if ev.Stats.PromptTokensTotal > 0 {
+ c.CtxUsed = ev.Stats.ctxAfter()
+ }
+ if local {
+ c.genPeak = max(c.genPeak, ev.Stats.GenTokens)
+ }
+ }
+ c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
diff --git a/internal/loki/ctx_accounting_test.go b/internal/loki/ctx_accounting_test.go
new file mode 100644
index 0000000..1be73c9
--- /dev/null
+++ b/internal/loki/ctx_accounting_test.go
@@ -0,0 +1,294 @@
+package loki
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Le contexte de la requête suivante = prompt + généré − raisonnement (jamais
+// renvoyé), sans jamais retirer plus que ce qui a été généré.
+func TestStatsCtxAfter(t *testing.T) {
+ cases := []struct {
+ name string
+ prompt, gen, reasonN int
+ want int
+ }{
+ {"sans raisonnement", 40000, 500, 0, 40500},
+ {"raisonnement retiré", 40000, 6000, 5000, 41000},
+ {"tout est raisonnement", 40000, 3000, 3000, 40000},
+ {"compte de morceaux au-delà du généré : borné", 40000, 2000, 2500, 40000},
+ {"pas d'usage", 0, 300, 100, 200},
+ }
+ for _, c := range cases {
+ s := StatsEvent{PromptTokensTotal: c.prompt, GenTokens: c.gen, ReasoningTokens: c.reasonN}
+ if got := s.ctxAfter(); got != c.want {
+ t.Errorf("%s : ctxAfter = %d, attendu %d", c.name, got, c.want)
+ }
+ }
+}
+
+// Garde de marge : à 65 k comme à 131 k, plancher et marge seuls ne devancent
+// jamais le seuil de 75 % ; seule une génération récente longue l'avance.
+func TestGenHeadroomShort(t *testing.T) {
+ cases := []struct {
+ name string
+ used, peak, window int
+ want bool
+ ratioAlreadyTriggers bool
+ }{
+ {"65k, inconnu", 60000, 0, 65536, false, true},
+ {"65k, courte : sous le seuil, pas d'avance", 49000, 2000, 65536, false, false},
+ {"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},
+ {"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},
+ {"contexte inconnu", 0, 30000, 65536, false, false},
+ }
+ for _, c := range cases {
+ if got := genHeadroomShort(c.used, c.peak, c.window); got != c.want {
+ t.Errorf("%s : genHeadroomShort = %v, attendu %v", c.name, got, c.want)
+ }
+ ratio := c.used >= int(float64(c.window)*compactTriggerFrac)
+ if ratio != c.ratioAlreadyTriggers {
+ t.Errorf("%s : cas mal posé, seuil de 75 %% = %v", c.name, ratio)
+ }
+ }
+ // Sans génération longue, la garde ne part jamais avant le seuil de 75 %.
+ for _, w := range []int{4096, 8192, 16384, 32768, 65536, 131072, 262144} {
+ below := int(float64(w)*compactTriggerFrac) - 1
+ if genHeadroomShort(below, 1, w) {
+ t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w)
+ }
+ }
+}
+
+// Le message arrivé depuis la dernière mesure s'ajoute ; une mesure inconnue
+// ou qui ne correspond plus à l'historique n'ajoute rien.
+func TestCtxPending(t *testing.T) {
+ msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
+ added := estimateTokens(msgs[2:])
+ cases := []struct {
+ name string
+ used, measured int
+ want int
+ }{
+ {"nouveau message estimé", 30000, 2, 30000 + added},
+ {"rien de nouveau", 30000, 3, 30000},
+ {"mesure inconnue (rechargement)", 30000, 0, 30000},
+ {"historique raccourci depuis", 30000, 5, 30000},
+ {"aucun compte : repli sur l'estimation complète, ailleurs", 0, 2, 0},
+ }
+ for _, c := range cases {
+ if got := ctxPending(c.used, c.measured, msgs); got != c.want {
+ t.Errorf("%s : ctxPending = %d, attendu %d", c.name, got, c.want)
+ }
+ }
+}
+
+// fauxMoteurScript : llama-server de comédie qui sert ses réponses streamées
+// dans l'ordre (la dernière se répète), et un résumé JSON aux appels non
+// streamés (compaction). Garde le dernier message de chaque requête streamée.
+func fauxMoteurScript(t *testing.T, streams ...string) (port string, lastMsgs func() []string) {
+ t.Helper()
+ var mu sync.Mutex
+ var last []string
+ n := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Stream bool `json:"stream"`
+ Messages []Message `json:"messages"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ if !body.Stream {
+ sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes"}}}})
+ return
+ }
+ mu.Lock()
+ i := min(n, len(streams)-1)
+ n++
+ if len(body.Messages) > 0 {
+ last = append(last, msgText(body.Messages[len(body.Messages)-1]))
+ }
+ mu.Unlock()
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(200)
+ _, _ = w.Write([]byte(streams[i] + "data: [DONE]\n\n"))
+ }))
+ t.Cleanup(srv.Close)
+ u, _ := url.Parse(srv.URL)
+ return u.Port(), func() []string {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]string(nil), last...)
+ }
+}
+
+func sseReasoning(text string) string {
+ return `data: {"choices":[{"delta":{"reasoning_content":"` + text + `"}}]}` + "\n\n"
+}
+
+func sseFinal(finish string, prompt, gen int) string {
+ return `data: {"choices":[{"delta":{},"finish_reason":"` + finish + `"}]}` + "\n\n" +
+ `data: {"choices":[],"timings":{"prompt_n":10,"predicted_n":` + itoa(gen) + `},"usage":{"prompt_tokens":` + itoa(prompt) + `,"completion_tokens":` + itoa(gen) + `}}` + "\n\n"
+}
+
+// runForStats lance runChat et rend le dernier StatsEvent reçu.
+func runForStats(t *testing.T, msgs []Message, caps Caps) (StatsEvent, string) {
+ t.Helper()
+ var st StatsEvent
+ var content strings.Builder
+ if _, err := runChat(context.Background(), msgs, 0.7, caps, func(ev StreamEvent) bool {
+ if ev.Stats != nil {
+ st = *ev.Stats
+ }
+ content.WriteString(ev.Content)
+ return true
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return st, content.String()
+}
+
+// Raisonnement séparé par le moteur : compté, et retiré du contexte suivant.
+func TestRaisonnementSepareCompte(t *testing.T) {
+ testHome(t)
+ port, _ := fauxMoteurScript(t, sseReasoning("je ")+sseReasoning("réfléchis ")+sseReasoning("ici")+
+ sseChunk("Bon")+sseChunk("jour")+sseFinal("stop", 100, 7))
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ st, _ := runForStats(t, []Message{um("salut")}, Caps{})
+ if st.ReasoningTokens != 3 {
+ t.Fatalf("ReasoningTokens = %d, attendu 3", st.ReasoningTokens)
+ }
+ if got := st.ctxAfter(); got != 104 {
+ t.Fatalf("ctxAfter = %d, attendu 104 (100 + 7 − 3)", got)
+ }
+}
+
+// en ligne découpé chez nous : ce texte reste dans le message renvoyé
+// au modèle pendant le tour, il ne doit donc PAS être retiré du compte.
+func TestRaisonnementEnLigneNonCompte(t *testing.T) {
+ testHome(t)
+ port, _ := fauxMoteurScript(t, sseChunk("je pense")+sseChunk("")+sseChunk("Réponse")+sseFinal("stop", 100, 6))
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetConfigKey("REASONING", "on"); err != nil {
+ t.Fatal(err)
+ }
+ var sawReasoning bool
+ var st StatsEvent
+ if _, err := runChat(context.Background(), []Message{um("salut")}, 0.7, Caps{}, func(ev StreamEvent) bool {
+ if ev.Reasoning != "" {
+ sawReasoning = true
+ }
+ if ev.Stats != nil {
+ st = *ev.Stats
+ }
+ return true
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !sawReasoning {
+ t.Fatal("le en ligne n'a pas été découpé : test mal posé")
+ }
+ if st.ReasoningTokens != 0 || st.ctxAfter() != 106 {
+ t.Fatalf("raisonnement en ligne compté : ReasoningTokens=%d ctxAfter=%d", st.ReasoningTokens, st.ctxAfter())
+ }
+}
+
+// Une relance (ici le nudge « pensé sans agir ») repart d'un compte vierge :
+// le raisonnement d'une tentative jetée n'est pas retiré deux fois.
+func TestRaisonnementRemisAZeroALaRelance(t *testing.T) {
+ withWorkspace(t)
+ port, _ := fauxMoteurScript(t,
+ sseReasoning("a")+sseReasoning("b")+sseReasoning("c")+sseFinal("stop", 100, 3),
+ sseReasoning("d")+sseChunk("Fait.")+sseFinal("stop", 120, 4))
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ st, content := runForStats(t, []Message{um("fais-le")}, Caps{Agent: true})
+ if content != "Fait." {
+ t.Fatalf("réponse = %q, la relance n'a pas eu lieu", content)
+ }
+ if st.ReasoningTokens != 1 || st.ctxAfter() != 123 {
+ t.Fatalf("après relance : ReasoningTokens=%d ctxAfter=%d, attendu 1 et 123", st.ReasoningTokens, st.ctxAfter())
+ }
+}
+
+// Fenêtre pleine en plein raisonnement (« length ») : on compacte et on rejoue
+// l'étape, sans le nudge qui demanderait au modèle d'arrêter de raisonner.
+func TestFenetrePleineCompacteEtRejoue(t *testing.T) {
+ withWorkspace(t)
+ if err := SetConfigKey("CTX", "4096"); err != nil {
+ t.Fatal(err)
+ }
+ port, lastMsgs := fauxMoteurScript(t,
+ sseReasoning("long")+sseReasoning(" raisonnement")+sseFinal("length", 3900, 196),
+ sseChunk("Réponse.")+sseFinal("stop", 1500, 3))
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, atc("bash"), tm("sortie "+strings.Repeat("x", 400)))
+ }
+ msgs = append(msgs, um("et maintenant ?"))
+ var compacted, dropped bool
+ var content strings.Builder
+ if _, err := runChat(context.Background(), msgs, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
+ if ev.NewHistory != nil {
+ compacted = true
+ }
+ if ev.DropReasoning {
+ dropped = true
+ }
+ content.WriteString(ev.Content)
+ return true
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !compacted || !dropped {
+ t.Fatalf("fenêtre pleine : compacté=%v raisonnement retiré=%v", compacted, dropped)
+ }
+ if content.String() != "Réponse." {
+ t.Fatalf("réponse = %q", content.String())
+ }
+ for _, m := range lastMsgs() {
+ if strings.Contains(m, "You reasoned but did not") || strings.Contains(m, "Stop reasoning") {
+ t.Fatalf("nudge envoyé malgré la fenêtre pleine : %q", m)
+ }
+ }
+}
+
+// La passe du vérificateur tourne sur sa propre trace : ses comptes ne
+// touchent ni CtxUsed ni la jauge, contrairement à une passe de correction.
+func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) {
+ testHome(t)
+ c := newTestConv()
+ c.CtxUsed = 45000
+ st := &StatsEvent{PromptTokensTotal: 3000, GenTokens: 800, ReasoningTokens: 500}
+ c.forwardStream(StreamEvent{Stats: st}, c.epoch, true)
+ if c.CtxUsed != 45000 {
+ t.Fatalf("passe isolée : CtxUsed = %d, attendu 45000 inchangé", c.CtxUsed)
+ }
+ if last := c.Log[len(c.Log)-1].Delta; last["ctx_isolated"] != true {
+ t.Fatalf("passe isolée : la jauge n'est pas prévenue (%v)", last)
+ }
+ c.forwardStream(StreamEvent{Stats: st}, c.epoch, false)
+ if c.CtxUsed != 3300 {
+ t.Fatalf("passe de correction : CtxUsed = %d, attendu 3300 (3000 + 800 − 500)", c.CtxUsed)
+ }
+ if c.genPeak != 800 {
+ t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak)
+ }
+}
diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go
index 30fd139..114c94f 100644
--- a/internal/loki/llm_client.go
+++ b/internal/loki/llm_client.go
@@ -728,6 +728,13 @@ type StatsEvent struct {
// Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de
// `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
+ // Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content),
+ // llama-server local seulement. Loki ne renvoie jamais ce raisonnement au
+ // modèle (Message n'a pas de champ pour lui) : ces jetons font partie de
+ // GenTokens mais pas de la requête suivante. Un morceau vaut au plus un
+ // jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on
+ // compacte au pire comme avant, jamais trop tard. Voir ctxAfter.
+ ReasoningTokens int `json:"reasoning_tokens,omitempty"`
// Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail
// annexe (voir noteEnginePrompt). Vide la plupart du temps.
CacheHint string `json:"cache_hint,omitempty"`
@@ -747,6 +754,15 @@ type StatsEvent struct {
Kind string `json:"kind,omitempty"`
}
+// ctxAfter : taille du contexte que la requête SUIVANTE renverra réellement au
+// moteur — le prompt de cette complétion plus ce qu'elle a généré, MOINS son
+// raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de
+// 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt.
+// Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré.
+func (s StatsEvent) ctxAfter() int {
+ return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens)
+}
+
// ChatCallback receives stream events. Return false to abort the stream.
type ChatCallback func(StreamEvent) bool
@@ -972,6 +988,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// par rendre la main plutôt que faire attendre.
const maxNudges = 2
nudgeCount := 0
+ // Génération la plus longue de ce tour (llama-server local), pour la garde
+ // de marge de compactNeeded : le raisonnement n'étant plus compté dans le
+ // contexte, il faut s'assurer AUTREMENT qu'une étape aussi longue tient
+ // encore dans la fenêtre.
+ peakGen := 0
+ // Complétion coupée par la fenêtre pleine (finish_reason « length ») :
+ // compactée puis rejouée une fois, au lieu du nudge « arrête de raisonner »
+ // qui raccourcirait la réflexion du modèle.
+ lengthReplays := 0
+ // Contexte estimé pour la requête suivante (en-tour), comparé au compte
+ // réel du moteur quand il arrive — journalisé s'il s'en écarte.
+ lastEst := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
@@ -1427,6 +1455,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Backend already separates reasoning — trust it, disable our split.
sawReasoningField = true
thinkOpen = false
+ // Compté ICI seulement : le raisonnement découpé chez nous (
+ // en ligne, plus bas) reste dans assistantContent et repart dans
+ // l'étape suivante — le retirer sous-estimerait le contexte. Pas
+ // pour une API externe : son découpage en morceaux ne dit rien du
+ // nombre de jetons, et son preset doit rester tel quel. stats est
+ // propre à cette tentative : une relance repart de zéro.
+ if !ep.External {
+ stats.ReasoningTokens++
+ }
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
aborted = true
break
@@ -1560,6 +1597,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg))
}
cb(StreamEvent{Stats: &s})
+ if !ep.External {
+ peakGen = max(peakGen, stats.GenTokens)
+ if lastEst > 0 && stats.PromptTokensTotal > 0 {
+ logCtxEstimate("en-tour", lastEst, stats.PromptTokensTotal)
+ }
+ }
+ lastEst = 0
+ }
+ if finishReason == "length" {
+ logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow())
}
if aborted {
return extra, nil
@@ -1977,12 +2024,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup
// passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter.
// Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime
- // alors TOUT l'historique — étape comprise.
+ // alors TOUT l'historique — étape comprise. Le raisonnement de l'étape
+ // est retiré (ctxAfter) : il ne repart pas dans la requête suivante.
used := 0
- if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 {
+ if base := stats.ctxAfter(); base > 0 {
used = base + estimateTokens(messages[stepStart:])
+ lastEst = used
}
- if compactWouldTrigger(messages, used) {
+ if compactNeeded(messages, used, peakGen) {
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
@@ -2020,8 +2069,31 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
// si un modèle « pense sans agir » malgré tout, on le relance avec une
// consigne impérative au lieu d'afficher « pas de réponse ».
+ //
+ // Sauf si la complétion a été COUPÉE par une fenêtre pleine
+ // (« length » au-delà du seuil de compaction) : le modèle n'a pas
+ // « pensé sans agir », il n'a plus eu de place. Lui dire d'arrêter de
+ // raisonner raccourcirait sa réflexion et alourdirait encore le
+ // contexte ; on compacte et on rejoue l'étape, une fois.
+ if finishReason == "length" && !ep.External && lengthReplays < 1 &&
+ compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) {
+ lengthReplays++
+ yes, no := true, false
+ cb(StreamEvent{Compacting: &yes})
+ c, changed := compactMessages(ctx, messages, caps)
+ cb(StreamEvent{Compacting: &no})
+ logCompact("fenêtre-pleine", stats.PromptTokensTotal+stats.GenTokens, messages, c, changed)
+ if changed {
+ cb(StreamEvent{DropReasoning: true})
+ messages = c
+ extra = nil
+ cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
+ continue
+ }
+ }
if len(tools) > 0 && !disableTools && nudgeCount < maxNudges {
nudgeCount++
+ logCtx("relance « pensé sans agir » n°%d (finish=%s)", nudgeCount, finishReason)
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
// blocs de réflexion successifs.
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 116c9e7..d31611b 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -7726,8 +7726,11 @@ function handleDelta(d){
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
- // zéro ici, on la laisse simplement inchangée.
- if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
+ // zéro ici, on la laisse simplement inchangée. Même calcul que le serveur
+ // (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne
+ // compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace :
+ // ses chiffres ne disent rien de la discussion, la jauge ne bouge pas.
+ if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index 3afcd7a..99d82bd 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -561,8 +561,11 @@ function handleDelta(d){
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
- // zéro ici, on la laisse simplement inchangée.
- if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
+ // zéro ici, on la laisse simplement inchangée. Même calcul que le serveur
+ // (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne
+ // compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace :
+ // ses chiffres ne disent rien de la discussion, la jauge ne bouge pas.
+ if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé