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

+54 -11
View File
@@ -651,8 +651,10 @@ func renderTranscript(msgs []Message) string {
type summarizeResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
@@ -757,20 +759,61 @@ Write the summary in the SAME language as the conversation.`
if len(out.Choices) == 0 {
return "", fmt.Errorf("résumé: réponse vide")
}
c := out.Choices[0].Message.Content
// Certains modèles à raisonnement préfixent un bloc <think>…</think> : on ne
// garde que la réponse finale.
ch := out.Choices[0]
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
}
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
// seulement. L'ancienne borne fixe de 2200 caractères (≈ 550 tokens) coupait
// un résumé que max_tokens autorisait à ~1600 tokens — et le prompt met l'ÉTAT
// D'AVANCEMENT en dernier : c'était précisément lui, ce dont l'agent a besoin
// pour reprendre, qui tombait. max_tokens borne déjà la sortie ; ×6 caractères
// par token laisse passer tout résumé que le moteur a pu produire (le français
// tourne autour de 4) et ne mord que sur une API qui ignorerait max_tokens.
func summaryRuneCap() int {
return compactSummaryBudget() * 6
}
// cleanSummary extrait le texte final d'une réponse de résumé. Une erreur fait
// retomber l'appelant sur le torse dégraissé, sans perte : bien mieux que
// d'installer du raisonnement brut comme « mémoire » de la conversation.
// - un bloc <think>…</think> en tête est retiré, seule la réponse compte ;
// - un <think> ouvert en tête et jamais refermé (modèle qui ignore
// enable_thinking=false et bute sur max_tokens), ou un texte vide à côté
// d'un reasoning_content : il n'y a QUE du raisonnement, pas de résumé →
// erreur ;
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
// mieux que rien), marqué « […] » et tracé dans le journal.
func cleanSummary(content, reasoning, finish string) (string, error) {
c := content
if i := strings.LastIndex(c, thinkClose); i >= 0 {
c = c[i+len(thinkClose):]
}
c = strings.TrimSpace(c)
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
// pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
// 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
// l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
// sur une frontière de rune (é, … ne doivent pas devenir des �).
if r := []rune(c); len(r) > 2200 {
c = strings.TrimSpace(string(r[:2200])) + " […]"
// En TÊTE seulement : un résumé de session Code peut citer la balise
// <think> (un parseur, un gabarit) sans être du raisonnement.
if strings.HasPrefix(c, "<think>") {
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
}
if c == "" {
if strings.TrimSpace(reasoning) != "" || strings.TrimSpace(content) != "" {
return "", fmt.Errorf("résumé: rien hors du raisonnement")
}
return "", fmt.Errorf("résumé: texte vide")
}
cut := false
if r := []rune(c); len(r) > summaryRuneCap() {
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
cut = true
}
if finish == "length" {
fmt.Fprintf(os.Stderr, "[compact] résumé coupé par max_tokens=%d (%d caractères gardés)\n",
compactSummaryBudget(), len([]rune(c)))
cut = true
}
if cut {
c += " […]"
}
return c, nil
}
+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)
}
}