diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go
index 77e3372..2c5b0a8 100644
--- a/internal/loki/chat_compact.go
+++ b/internal/loki/chat_compact.go
@@ -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 … : 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 … en tête est retiré, seule la réponse compte ;
+// - un 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
+ // (un parseur, un gabarit) sans être du raisonnement.
+ if strings.HasPrefix(c, "") {
+ 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
}
diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go
index 9b8c35d..c5eb6cd 100644
--- a/internal/loki/chat_compact_test.go
+++ b/internal/loki/chat_compact_test.go
@@ -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é", "je réfléchis\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 en tête", "", "stop", "- le parseur retire en tête", false},
+ {"think jamais refermé", "je réfléchis encore", "", "length", "", true},
+ {"que du reasoning_content", "", "je réfléchis", "length", "", true},
+ {"think vide de réponse", "x ", "", "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)
+ }
+}