compaction du contexte facon Hermes : conversations quasi illimitees sans clear

- nouveau compact.go : split head/torso/tail, elagage des vieux tool outputs
  sans IA + resume du torse par le modele local (un appel non-streame)
- proactif au seuil (75% de CTX) dans runChatStream + chat terminal, et filet
  reactif dans runChat si llama-server refuse un prompt trop long
- resume injecte en tour user->assistant (jamais un system au milieu : garde Qwen)
- toggle COMPACT (defaut on) : endpoint /api/agent/compact + case UI, event
  history_replace pour que le client remplace son historique, toast
- tests unitaires des frontieres head/tail et de la non-separation des paires outil
This commit is contained in:
nathaninline committed 2026-07-21 15:49:18 +02:00
1 parent c50ebdb6d1
commit e6c0bbe3b7
6 files changed
+420 -3

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
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é.
func MaybeCompact(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
if !compactEnabled() {
return msgs, false
}
if estimateTokens(msgs) < 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
}