Files
Loki/internal/jean/chat_compact.go
T
nathaninline dcbfcb36f9 Compaction plus efficace : resume borne, reduction garantie, jauge immediate
- Resume borne et dense : max_tokens 700, consigne 250 mots max, plafond dur a
  1500 caracteres, et surtout thinking DESACTIVE pour l'appel de resume
  (chat_template_kwargs enable_thinking:false) -> plus rapide, plus dense, plus
  de budget gaspille en <think> par un modele hybride.
- Garantie de reduction : la compaction n'est acceptee que si elle enleve >=20%
  du contexte estime, sinon refusee -> fini le "compactage a chaque message sans
  reduire". Queue plus agressive (tailFrac 0.35 -> 0.25) : le contexte retombe
  plus bas et met plus longtemps a re-declencher.
- Jauge de contexte : chute immediatement apres compaction (delta ctx_used emis
  aussitot) au lieu d'attendre la fin du tour.
2026-07-23 17:07:55 +02:00

328 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package jean
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// Compactage du contexte, façon Hermes Agent : au lieu de vider la conversation
// quand la fenêtre de contexte se remplit, on la scinde en trois zones —
//
// Head (tête) : messages système + tout premier message utilisateur. Protégé.
// Tail (queue) : les tours récents (dans un budget de tokens). Protégé.
// Torso (torse) : tout le milieu. C'est LA SEULE zone compactée.
//
// Le torse est d'abord dégraissé sans IA (les vieux résultats d'outils longs
// sont remplacés par un marqueur), puis résumé par le modèle local en UN seul
// appel, et le tout est remplacé par un court résumé. Résultat : des
// conversations quasi illimitées sans jamais « clear », comme Hermes.
//
// La logique vit côté serveur (dans le flux de chat) donc elle profite à TOUS
// les clients — UI web, terminal, accès distant ajean.link — sans duplication.
const (
// Seuil de déclenchement proactif : on compacte quand l'historique estimé
// dépasse cette fraction de la fenêtre de contexte.
compactTriggerFrac = 0.75
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
// Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
compactTailFrac = 0.25
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
// avant le résumé (dégraissage sans IA, gratuit).
compactToolPruneLen = 200
)
const compactPrunedMarker = "[Ancien résultat d'outil effacé pour économiser le contexte]"
// compactEnabled indique si le compactage automatique du contexte est actif.
// Défaut : true. Seule une valeur off/false/0/no/non explicite (config.env
// COMPACT) le désactive — cohérent avec toolLimitEnabled().
func compactEnabled() bool {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["COMPACT"])) {
case "off", "false", "0", "no", "non":
return false
}
return true
}
// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768
// par défaut — la même valeur que celle passée à llama-server au lancement.
func ctxWindow() int {
if v := ReadConfig()["CTX"]; v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 32768
}
// msgText extrait le texte d'un message (Content est `any`, en pratique string
// ou nil quand l'assistant n'a que des tool_calls).
func msgText(m Message) string {
if s, ok := m.Content.(string); ok {
return s
}
return ""
}
// msgTokens estime grossièrement le coût en tokens d'un message (~4 caractères
// par token, plus un forfait par message pour le rôle et les délimiteurs). C'est
// volontairement approximatif : le comptage EXACT vient de llama.cpp
// (PromptTokensTotal) ; ici on veut juste décider quand compacter.
func msgTokens(m Message) int {
n := 4
n += len(msgText(m)) / 4
for _, tc := range m.ToolCalls {
n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
}
return n
}
// estimateTokens estime la taille de l'historique en tokens.
func estimateTokens(msgs []Message) int {
total := 0
for _, m := range msgs {
total += msgTokens(m)
}
return total
}
// MaybeCompact compacte l'historique si (et seulement si) il dépasse le seuil
// proactif. Renvoie l'historique (compacté ou inchangé) et un booléen indiquant
// s'il a changé. À appeler sur l'historique BRUT (avant InjectSkills) pour que
// le résultat puisse être renvoyé au client sans le préfixe système injecté.
//
// knownTokens = taille RÉELLE du contexte au tour précédent (usage.prompt_tokens
// + tokens générés), telle que rapportée par llama.cpp et affichée par l'UI. On
// la préfère à estimateTokens() car cette dernière n'est qu'une heuristique et,
// surtout, ne « voit » pas le prompt système injecté (machine briefing) ni le
// gabarit de chat — donc elle sous-estime largement le vrai contexte. 0 = inconnu
// (clients sans compteur, ex. terminal) → repli sur l'estimation.
// compactWouldTrigger indique si un tour VA déclencher une compaction proactive
// (compactage activé ET contexte au-dessus du seuil). Exposé pour que le flux de
// chat puisse afficher une bannière « compactage en cours » AVANT de lancer le
// résumé (qui bloque plusieurs secondes), au lieu d'une UI figée sans info.
func compactWouldTrigger(msgs []Message, knownTokens int) bool {
if !compactEnabled() {
return false
}
used := knownTokens
if used <= 0 {
used = estimateTokens(msgs)
}
return used >= int(float64(ctxWindow())*compactTriggerFrac)
}
func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
if !compactWouldTrigger(msgs, knownTokens) {
return msgs, false
}
return compactMessages(ctx, msgs, caps)
}
// compactMessages exécute la compaction sans tenir compte du seuil (utilisé en
// secours réactif quand llama-server refuse un prompt trop long). Renvoie
// l'historique compacté et true s'il a effectivement changé.
// compactBounds calcule les frontières head/tail pour un historique donné et un
// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
// - head : nb de messages protégés en tête = messages système + 1er message
// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
// - tailStart : index de début de la queue protégée. On remonte depuis la fin
// jusqu'à remplir le budget, puis on recule jusqu'au message `user` précédent
// pour que la queue démarre sur un début de tour — on ne sépare JAMAIS un
// assistant+tool_calls de ses résultats `tool`.
//
// Le torse à compacter est [head, tailStart). Il est vide (tailStart <= head)
// quand il n'y a rien à résumer.
func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
for head < len(msgs) && msgs[head].Role == "system" {
head++
}
if head < len(msgs) && msgs[head].Role == "user" {
head++
}
tailStart = len(msgs)
acc := 0
for i := len(msgs) - 1; i >= head; i-- {
acc += msgTokens(msgs[i])
tailStart = i
if acc >= tailBudget {
break
}
}
for tailStart > head && msgs[tailStart].Role != "user" {
tailStart--
}
return head, tailStart
}
func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
tailBudget := int(float64(ctxWindow()) * compactTailFrac)
head, tailStart := compactBounds(msgs, tailBudget)
// Rien à compacter : le torse [head, tailStart) est vide.
if tailStart <= head {
return msgs, false
}
torso := msgs[head:tailStart]
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
pruned := make([]Message, len(torso))
for i, m := range torso {
pruned[i] = m
if m.Role == "tool" {
if t := msgText(m); len(t) > compactToolPruneLen {
pruned[i].Content = compactPrunedMarker
}
}
}
// 4. Résumé du torse par le modèle local (un seul appel). En cas d'échec on
// garde le torse dégraissé plutôt que de perdre du contenu.
summary, err := summarizeTranscript(ctx, renderTranscript(pruned))
var mid []Message
if err != nil || strings.TrimSpace(summary) == "" {
mid = pruned
} else {
// Le résumé est injecté comme un tour utilisateur→assistant (jamais un
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
mid = []Message{
{Role: "user", Content: "[COMPACTION DU CONTEXTE] Les tours précédents de cette conversation ont été résumés pour économiser le contexte. Voici le résumé :\n\n" + summary},
{Role: "assistant", Content: "Compris, je continue avec ce contexte."},
}
}
out := make([]Message, 0, head+len(mid)+len(msgs)-tailStart)
out = append(out, msgs[:head]...)
out = append(out, mid...)
out = append(out, msgs[tailStart:]...)
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
// on la refuse — sans ça, jean « compactait » à presque chaque message sans
// vraiment réduire, puis re-déclenchait aussitôt.
before, after := estimateTokens(msgs), estimateTokens(out)
if after > before*4/5 {
return msgs, false
}
return out, true
}
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
func renderTranscript(msgs []Message) string {
var b strings.Builder
for _, m := range msgs {
switch m.Role {
case "user":
fmt.Fprintf(&b, "Utilisateur: %s\n", msgText(m))
case "assistant":
if t := msgText(m); t != "" {
fmt.Fprintf(&b, "Assistant: %s\n", t)
}
for _, tc := range m.ToolCalls {
fmt.Fprintf(&b, "Assistant → outil %s(%s)\n", tc.Function.Name, tc.Function.Arguments)
}
case "tool":
fmt.Fprintf(&b, "Résultat outil: %s\n", msgText(m))
case "system":
fmt.Fprintf(&b, "Système: %s\n", msgText(m))
}
}
s := b.String()
// Garde-fou pour les petites fenêtres : le résumeur ne doit pas lui-même
// déborder. On plafonne la transcription (~0,7×contexte en tokens ≈ 2,8
// caractères/token) en gardant la FIN (la plus récente) et en marquant la
// troncature de tête.
maxChars := int(float64(ctxWindow()) * 2.8)
if maxChars > 0 && len(s) > maxChars {
s = "[…début tronqué…]\n" + s[len(s)-maxChars:]
}
return s
}
// summarizeResp modélise le sous-ensemble utile d'une réponse non-streamée de
// /v1/chat/completions.
type summarizeResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
// summarizeTranscript demande au modèle local un résumé dense et fidèle du torse.
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
sys := `Tu es un compacteur de contexte. On te donne la transcription d'anciens tours d'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-les de façon TRÈS DENSE et fidèle en français, en ne gardant QUE l'essentiel pour continuer sans perte :
- Objectif(s) et contraintes de l'utilisateur
- Décisions prises et faits établis
- Actions/outils exécutés et leurs résultats importants (chemins, valeurs, config)
- Tâches terminées, en cours, bloquées ; prochaines étapes
Règles STRICTES : pas de préambule ni de conclusion, pas de verbatim ni de citations longues, pas de détails jetables. Utilise des puces courtes. Vise 250 mots MAXIMUM — c'est un résumé de compression, pas un compte rendu.`
payload := map[string]any{
"model": "jean",
"messages": []Message{
{Role: "system", Content: sys},
{Role: "user", Content: transcript},
},
"stream": false,
"temperature": 0.2,
// Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
// résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
"max_tokens": 700,
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
// modèle hybride gaspille tout le budget en <think> (résumé vide). llama.cpp
// passe ces kwargs au gabarit Jinja (--jinja).
"chat_template_kwargs": map[string]any{"enable_thinking": false},
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
authHeader(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", friendlyLLMError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out summarizeResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
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.
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
// (~1500 caractères ≈ 375 tokens) pour garantir une vraie compression.
if len(c) > 1500 {
c = strings.TrimSpace(c[:1500]) + " […]"
}
return c, nil
}