Files
Loki/compact.go
T
nathaninline 2daea32ade compaction: decider sur le VRAI decompte de tokens (ctx_used du client) au lieu de l'estimation
L'heuristique ~4 char/token sous-estimait le contexte (elle ne voit ni le prompt
systeme injecte ni le gabarit de chat), donc le seuil 75% n'etait jamais franchi.
Le client envoie desormais ctx_used (prompt_tokens_total deja affiche) ; repli sur
l'estimation quand inconnu (terminal).
2026-07-21 16:18:50 +02:00

302 lines
11 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 main
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).
compactTailFrac = 0.35
// 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.
func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
if !compactEnabled() {
return msgs, false
}
used := knownTokens
if used <= 0 {
used = estimateTokens(msgs)
}
if used < int(float64(ctxWindow())*compactTriggerFrac) {
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:]...)
// Si on n'a rien gagné (résumé raté ET rien à dégraisser), signaler inchangé.
if len(out) == len(msgs) && estimateTokens(out) >= estimateTokens(msgs) {
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'une conversation entre un utilisateur et un assistant IA (avec ses outils). Résume-la de façon dense et FIDÈLE en français, en préservant tout ce qui est nécessaire pour continuer la conversation 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, configuration)
- Tâches terminées, en cours, bloquées
- Prochaines étapes prévues
N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement le résumé structuré. Complet mais concis.`
payload := map[string]any{
"model": "jean",
"messages": []Message{
{Role: "system", Content: sys},
{Role: "user", Content: transcript},
},
"stream": false,
"temperature": 0.3,
}
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 "", 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):]
}
return strings.TrimSpace(c), nil
}