diff --git a/chat.go b/chat.go index 14e8bb8..c7af94d 100644 --- a/chat.go +++ b/chat.go @@ -67,6 +67,12 @@ func cmdChat(args []string) error { // Print the assistant prefix once; reasoning is shown inline with a tag. fmt.Print(cyan("jean") + " > ") caps := globalCaps() + // Compaction proactive (façon Hermes) : on résume les vieux tours quand + // l'historique dépasse le seuil, au lieu d'imposer un /reset. + if compacted, changed := MaybeCompact(context.Background(), msgs, caps); changed { + msgs = compacted + fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]")) + } extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool { switch { case ev.Err != nil: diff --git a/compact.go b/compact.go new file mode 100644 index 0000000..530bb01 --- /dev/null +++ b/compact.go @@ -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 … : 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 +} diff --git a/compact_test.go b/compact_test.go new file mode 100644 index 0000000..9db5e42 --- /dev/null +++ b/compact_test.go @@ -0,0 +1,62 @@ +package main + +import "testing" + +// helpers pour construire des historiques de test lisibles. +func um(s string) Message { return Message{Role: "user", Content: s} } +func am(s string) Message { return Message{Role: "assistant", Content: s} } +func atc(name string) Message { + return Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}} +} +func tm(s string) Message { return Message{Role: "tool", ToolCallID: "c1", Content: s} } + +func TestCompactBoundsProtectsHead(t *testing.T) { + msgs := []Message{ + {Role: "system", Content: "sys"}, + um("premier"), am("r1"), + um("q2"), am("r2"), + um("q3"), am("r3"), + } + // budget minuscule → queue = juste le dernier tour, tête = system + 1er user. + head, tail := compactBounds(msgs, 1) + if head != 2 { // system + premier user + t.Fatalf("head = %d, attendu 2", head) + } + if msgs[tail].Role != "user" { + t.Fatalf("la queue doit démarrer sur un message user, obtenu %q", msgs[tail].Role) + } + if tail <= head { + t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head) + } +} + +// La queue ne doit jamais démarrer entre un assistant+tool_calls et ses +// résultats `tool` : elle recule jusqu'au message user du tour. +func TestCompactBoundsKeepsToolPairs(t *testing.T) { + msgs := []Message{ + um("q1"), am("r1"), + um("q2"), atc("bash"), tm("sortie longue"), am("r2"), + um("q3"), am("r3"), + } + // budget moyen qui, sans le recul, couperait au milieu du tour outillé. + _, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1) + if msgs[tail].Role != "user" { + t.Fatalf("la queue démarre sur %q, doit être un user (pas d'orphelin tool/assistant)", msgs[tail].Role) + } + // Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent. + for i := tail; i < len(msgs); i++ { + if msgs[i].Role == "tool" { + if i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool") { + t.Fatalf("message tool orphelin à l'index %d de la queue", i) + } + } + } +} + +func TestEstimateTokensGrows(t *testing.T) { + small := estimateTokens([]Message{um("court")}) + big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")}) + if big <= small { + t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big) + } +} diff --git a/llm.go b/llm.go index 861f560..48564d5 100644 --- a/llm.go +++ b/llm.go @@ -376,6 +376,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // retry the same turn once with tools removed so the model answers in plain // text from the tool results already gathered, instead of dying mid-chat. disableTools := false + // Filet réactif (façon Hermes) : si llama-server refuse le prompt (souvent un + // dépassement de la fenêtre de contexte après de gros résultats d'outils), on + // compacte l'historique en vol et on rejoue le tour — une seule fois. + compactedRetry := false // Anti-loop net: if the model re-emits the exact same tool call (name+args) // several times, it's stuck — break instead of spinning to the iteration cap. lastSig := "" @@ -434,6 +438,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if msg == "" { msg = resp.Status } + // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une + // compaction en vol et on rejoue le tour (une seule fois) avant tout le + // reste. C'est le filet de secours à la Hermes. + if compactEnabled() && !compactedRetry { + if c, changed := compactMessages(ctx, messages, caps); changed { + compactedRetry = true + messages = c + continue + } + } // Most common 500 here: llama.cpp couldn't parse a malformed tool call // the model emitted. Retry the turn once without tools so it answers // in plain text rather than leaving the chat dead. diff --git a/ui/index.html b/ui/index.html index 7fc195b..19f2011 100644 --- a/ui/index.html +++ b/ui/index.html @@ -336,6 +336,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
Paramètres
+
+ +
Apparence