Compaction : le résumé n'est plus amputé de son état d'avancement

Le résumé de compaction était coupé net à 2200 caractères (≈ 550 tokens)
alors que max_tokens l'autorise jusqu'à ~1600 tokens. Le prompt du résumeur
place l'ÉTAT D'AVANCEMENT en dernier : c'était donc exactement ce dont
l'agent a besoin pour reprendre qui tombait, et les tokens décodés au-delà
étaient jetés.

- La borne passe à compactSummaryBudget()×6 caractères : un simple filet
  pour une API qui ignorerait max_tokens, qui borne déjà la sortie.
- finish_reason=length : le texte est gardé, marqué « […] » et tracé
  dans le journal ([compact] résumé coupé par max_tokens…).
- Un <think> ouvert en tête et jamais refermé, ou un contenu vide à côté
  d'un reasoning_content, est une erreur : l'appelant retombe sur le torse
  dégraissé au lieu d'installer du raisonnement brut comme mémoire.
- Tests : table de cleanSummary, et un résumé de 7000 caractères qui
  revient intact du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 01:00:04 +02:00
1 parent 26fc348506
commit e14ead98c4
2 files changed
+111 -11

No files matched your search

+57
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -252,3 +253,59 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) {
t.Fatal("critères non rendus après compactage")
}
}
// Le résumé n'est plus coupé à 2200 caractères : max_tokens borne déjà la
// sortie, et l'ancienne coupe faisait tomber l'ÉTAT D'AVANCEMENT, écrit en
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
func TestCleanSummary(t *testing.T) {
testHome(t)
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
huge := strings.Repeat("x", summaryRuneCap()+500)
cases := []struct {
name, content, reasoning, finish string
want string
err bool
}{
{"simple", " - fait A\n- fait B ", "", "stop", "- fait A\n- fait B", false},
{"long gardé entier", long, "", "stop", long, false},
{"think retiré", "<think>je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
{"vide", "", "", "stop", "", true},
}
for _, c := range cases {
got, err := cleanSummary(c.content, c.reasoning, c.finish)
if (err != nil) != c.err || got != c.want {
t.Errorf("%s : %q, %v", c.name, got, err)
}
}
}
// Bout à bout : un résumé de 7000 caractères revient intact du moteur, et une
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
func TestSummarizeLongNotTruncated(t *testing.T) {
testHome(t)
long := strings.Repeat("ü", 7000)
var reply map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(reply)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}}
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
if err != nil || got != long {
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
}
reply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}}
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
}
}