Compaction : corrections de relecture — une balise </think> citée n'ampute plus le résumé

cleanSummary coupait toujours au DERNIER </think>. Un résumé de session
Code qui cite la balise (un parseur, un gabarit — sessions sur Loki même)
perdait donc tout ce qui la précédait, sans erreur ni trace : exactement la
perte que le commit précédent voulait supprimer.

- Le raisonnement ne se retire qu'en tête : un bloc <think>…</think> qui
  ouvre la réponse (plusieurs à la suite au besoin), ou un </think> sans
  <think> avant lui (gabarit qui ouvre le bloc dans le prompt).
- Un bloc cité en milieu de texte reste tel quel.
- La coupe par le filet (API qui ignore max_tokens) est tracée elle aussi.
- Tests : CTX fixé (le filet en dépend), réponse du faux moteur derrière un
  mutex, cas de balises citées et de blocs multiples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 01:04:03 +02:00
1 parent e14ead98c4
commit 72ba8e0fb6
2 files changed
+48 -14

No files matched your search

+26 -12
View File
@@ -785,13 +785,26 @@ func summaryRuneCap() int {
// - 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(content)
// Le raisonnement ne se retire qu'en TÊTE : soit un bloc <think>…</think>
// qui ouvre la réponse, soit un </think> sans <think> avant lui (gabarit qui
// ouvre le bloc dans le prompt). Couper au DERNIER </think>, comme avant,
// amputait tout le début d'un résumé de session Code qui cite la balise (un
// parseur, un gabarit) — et une balise citée après un <think> en milieu de
// texte n'est pas du raisonnement non plus.
for {
i := strings.Index(c, thinkClose)
if i < 0 {
break
}
if o := strings.Index(c[:i], "<think>"); o > 0 {
break
}
c = strings.TrimSpace(c[i+len(thinkClose):])
if !strings.HasPrefix(c, "<think>") {
break
}
}
c = strings.TrimSpace(c)
// 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é")
}
@@ -801,18 +814,19 @@ func cleanSummary(content, reasoning, finish string) (string, error) {
}
return "", fmt.Errorf("résumé: texte vide")
}
cut := false
cut := ""
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
cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap())
}
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
cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget())
}
if cut {
if cut != "" {
// Tracé dans les deux cas : le filet ne mord que sur une API qui
// ignore max_tokens, et c'est l'ÉTAT D'AVANCEMENT qui tombe.
fmt.Fprintf(os.Stderr, "[compact] résumé coupé %s (%d caractères gardés)\n", cut, len([]rune(c)))
c += " […]"
}
return c, nil