mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
c50ebdb6d1
commit
e6c0bbe3b7
6 files changed
+420
-3
No files matched your search
@@ -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:
|
||||
|
||||
+290
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+19
-1
@@ -336,6 +336,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
<div class="subhead">Paramètres</div>
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</span></span></label>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary>Apparence</summary>
|
||||
<label class="switchrow" style="justify-content:space-between;gap:12px"><span>thème</span>
|
||||
@@ -758,6 +761,7 @@ async function loadAgent(){
|
||||
const on = s.enabled;
|
||||
document.getElementById('agent-toggle').checked = on;
|
||||
document.getElementById('toollimit-toggle').checked = (s.tool_limit !== false);
|
||||
document.getElementById('compact-toggle').checked = (s.compact !== false);
|
||||
document.getElementById('agent-badge').innerHTML = on
|
||||
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
|
||||
document.getElementById('brand').classList.toggle('agent', on);
|
||||
@@ -827,6 +831,10 @@ async function toggleToolLimit(){
|
||||
await jpost('/api/agent/tool-limit',{on});
|
||||
loadAgent();
|
||||
}
|
||||
async function toggleCompact(){
|
||||
const on=document.getElementById('compact-toggle').checked;
|
||||
await jpost('/api/agent/compact',{on});
|
||||
}
|
||||
// Mode mémoire (3 états) — indépendant du mode agent.
|
||||
const MEM_DESC={
|
||||
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
|
||||
@@ -1401,7 +1409,7 @@ async function send(){
|
||||
// réinitialise pas à chaque token pendant que l'IA raisonne / appelle un outil.
|
||||
const showTyping=()=>{ if(!simpleMode()) return; const c=document.getElementById('chat');
|
||||
if(!typingEl){ typingEl=addTyping(); } else if(c.lastElementChild!==typingEl){ c.appendChild(typingEl); } };
|
||||
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null;
|
||||
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null, compacted=false;
|
||||
let turnCollapsibles=[]; // bulles reasoning/tool à replier dès la réponse finale
|
||||
let tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
|
||||
let serverStats=null, streamErr=false;
|
||||
@@ -1473,6 +1481,15 @@ async function send(){
|
||||
// la réponse finale, sinon le modèle re-déclenche le même skill/commande
|
||||
// à chaque tour (aucune trace de l'avoir déjà fait).
|
||||
if(d.tool_messages){ toolMsgs = d.tool_messages; }
|
||||
if(d.history_replace){
|
||||
// Compaction côté serveur (façon Hermes) : on remplace notre
|
||||
// historique par la version résumée. msgs ne contient jamais le
|
||||
// system prompt (ajouté seulement à l'envoi), donc on retire les
|
||||
// éventuels messages système de tête pour ne pas les dupliquer.
|
||||
let h=d.history_replace.slice();
|
||||
while(h.length && h[0].role==='system') h.shift();
|
||||
msgs=h; saveChat(); compacted=true;
|
||||
}
|
||||
if(d.tool_used){
|
||||
killTyping();
|
||||
// Close out the current assistant/reasoning bubbles so any text the
|
||||
@@ -1529,6 +1546,7 @@ async function send(){
|
||||
if(toolMsgs) msgs.push(...toolMsgs);
|
||||
msgs.push({role:'assistant',content:fullContent}); saveChat();
|
||||
}
|
||||
if(compacted) toast('contexte compacté — les vieux tours ont été résumés');
|
||||
// Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités
|
||||
// (prompt_tokens, le préfixe caché n'est pas recompté) + ceux générés. La
|
||||
// taille réelle du contexte = somme cumulée, donc monotone croissante.
|
||||
|
||||
@@ -89,6 +89,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/agent", handleAgent)
|
||||
api("/api/agent/toggle", handleAgentToggle)
|
||||
api("/api/agent/tool-limit", handleToolLimitToggle)
|
||||
api("/api/agent/compact", handleCompactToggle)
|
||||
api("/api/apikey", handleAPIKey)
|
||||
api("/api/oai/public", handleOAIPublic)
|
||||
api("/api/internet", handleInternet)
|
||||
@@ -443,7 +444,7 @@ func handleAgent(w http.ResponseWriter, r *http.Request) {
|
||||
for _, p := range pages {
|
||||
out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
|
||||
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
|
||||
}
|
||||
|
||||
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
|
||||
@@ -493,6 +494,24 @@ func handleToolLimitToggle(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "tool_limit": toolLimitEnabled()})
|
||||
}
|
||||
|
||||
// handleCompactToggle active/désactive le compactage automatique du contexte
|
||||
// (config.env COMPACT). On=compacte (défaut), off=jamais.
|
||||
func handleCompactToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
val := ""
|
||||
if !req.On {
|
||||
val = "off"
|
||||
}
|
||||
if err := SetConfigKey("COMPACT", val); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "compact": compactEnabled()})
|
||||
}
|
||||
|
||||
func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
@@ -823,7 +842,15 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any)
|
||||
// On garde la cohérence prompt/outils : internet demandé ET serveur joignable.
|
||||
caps.Internet = *body.Internet && crawlReachable()
|
||||
}
|
||||
msgs := InjectSkills(body.Messages, caps)
|
||||
// Compaction proactive (façon Hermes) : si l'historique dépasse le seuil, on
|
||||
// le résume AVANT le tour et on renvoie l'historique compacté au client pour
|
||||
// qu'il remplace le sien — évite de re-renvoyer tout à chaque tour.
|
||||
hist := body.Messages
|
||||
if compacted, changed := MaybeCompact(ctx, hist, caps); changed {
|
||||
hist = compacted
|
||||
emit(map[string]any{"history_replace": hist})
|
||||
}
|
||||
msgs := InjectSkills(hist, caps)
|
||||
extra, _ := runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Err != nil {
|
||||
return emit(map[string]any{"error": ev.Err.Error()})
|
||||
|
||||
Reference in new issue
Block a user