Files
Loki/internal/loki/chat_compact_cont.go
MichaelandClaude Opus 5.5 7a57322a62 Slots : SIDE_SLOT ouvre un second slot pour les travaux annexes, en opt-in
En --parallel 1, une vérification, un sous-agent, une tâche ou un bench
prennent le slot de la discussion : son état part dans le cache RAM et en
revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par
défaut), le moteur ouvre deux slots et la discussion garde le sien.

Dimensionnement choisi pour qu'un débordement concurrent soit impossible par
construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux
flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le
pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux
deux — que Loki prenait pour un débordement de la conversation. Chaque slot
garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien
n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache
unifié : rien à régler.

- lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux
  états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU,
  moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/
  --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs
  LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé
- routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons :
  0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ;
  1 pour vérification, sous-agents, tâches, bench, résumé sur transcription
- clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit
- cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête
  du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de
  compaction reste possible après une vérification
- « Context size has been exceeded. » sans nombre de jetons, deux slots en
  service : requête rejouée telle quelle, jamais compaction ni réduction
- préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0
- éditeur de preset : VRAM du second slot ou raison du refus
- tests : ligne identique sans la clé, chaque conflit refuse sans changer la
  ligne, routage par nature, aucun id_slot sur un slot / preset externe /
  fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:02:40 +02:00

477 lines
18 KiB
Go

package loki
// chat_compact_cont.go — COMPACT_CONTINUATION : la compaction proactive comme
// suite du prompt que le moteur a déjà en cache.
//
// Pourquoi. Aujourd'hui, le résumé d'une compaction part dans une requête à
// part : son propre prompt système et une transcription du torse. Le moteur
// local la calcule à froid (5 à 46k jetons), puis recalcule à froid le prompt
// compacté au message suivant. Sur un 27B, des dizaines de secondes ; sur un
// MoE aux experts en RAM, des minutes — à chaque compaction.
//
// Avec la clé (off par défaut), quand le slot porte selon toute vraisemblance
// le prompt de CETTE discussion, la requête de résumé est la requête du tour
// telle qu'elle est partie — mêmes messages assemblés par les mêmes fonctions
// (turnViewDry, wireMessages, buildChatPayload), mêmes outils, mêmes arguments
// du gabarit, même intensité de raisonnement — suivie d'un seul message
// utilisateur qui demande le résumé. Le moteur ne calcule que ce message.
//
// Ce qui ne change pas :
// - la compaction elle-même : bornes, archives recall, demande réinjectée,
// garantie de réduction et historique rangé sont calculés sur la vue
// MODÈLE, comme avant. La vue d'envoi ne sert qu'à construire la requête :
// ni prompt système, ni skills, ni bloc projet n'entrent dans l'historique ;
// - les exigences du résumé : mêmes règles (compactSummaryRules, plus
// codeSummaryRules en mode Code), même budget, même température 0.2 sans
// l'échantillonnage du preset, réflexion coupée (enable_thinking=false,
// ajouté aux arguments du tour) ;
// - le modèle principal, ses tours et leur échantillonnage.
//
// Ce qui change : le résumeur voit le torse entier dans son format natif (plus
// que la transcription, qui raccourcit les résultats d'outils), depuis la
// place de l'agent. La frontière lui est désignée par le nombre de messages
// gardés et le début du premier d'entre eux ; en cas de doute, il doit plutôt
// répéter que laisser tomber, et dater l'état d'avancement à la frontière.
//
// Repli sur la transcription d'avant, sans rien perdre, au moindre écart :
// preset externe, autre requête passée par le slot depuis le dernier tour
// (vérificateur, sous-agent, tâche, préchauffage, résumé, bench, client /v1,
// autre discussion), moteur relancé sur un autre modèle ou une autre fenêtre,
// étape de secours (outils coupés), marge insuffisante dans la fenêtre, vue
// désalignée, refus du moteur (gabarit qui exige l'alternance, fenêtre
// pleine…), erreur réseau, appel d'outil émis, raisonnement sans résumé,
// résumé vide. Un refus du gabarit ou un appel d'outil suspend la continuation
// pour ce modèle jusqu'au redémarrage ; deux résumés ratés de suite aussi.
//
// La clé apporte aussi deux économies sans effet sur le résultat :
// - un résumé n'est pas demandé quand même un résumé vide ne passerait pas la
// garantie de réduction (compactHopeless, borne exacte) ;
// - une compaction refusée parce que le résumé obtenu était trop long n'est
// retentée, en début ou fin de tour et entre deux étapes, qu'une fois le
// contexte grossi de 10 % sur le même fil (un historique réécrit retente)
// — jamais pour le filet réactif, le bouton « compacter », une fenêtre
// pleine à 90 % ou plus.
//
// Réactif (prompt refusé par le moteur), fenêtre pleine en pleine génération,
// bouton manuel, tâches, sous-agents, terminal : chemin d'avant, toujours.
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"unicode/utf8"
)
// compactContinuationOn : clé posée, moteur local.
func compactContinuationOn(cfg map[string]string) bool {
if isExternalConfig(cfg) {
return false
}
switch strings.ToLower(strings.TrimSpace(cfg["COMPACT_CONTINUATION"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// compactView : la requête du tour telle qu'elle est partie, pour un résumé
// en continuation. wire couvre les n premiers messages de la vue modèle.
type compactView struct {
wire []Message
n int
tools []Tool
effort string
kwargs map[string]any
used int // contexte réel (dernier compte du moteur + ce qu'il n'a pas vu)
conv string // discussion, pour la télémétrie
}
// compactOpts : options d'une compaction. Nulles : le chemin d'avant, exact.
type compactOpts struct {
view *compactView // nil : résumé sur transcription
precheck bool // pas de résumé quand même un vide serait refusé
conv string // discussion, pour la mémoire des refus
used int
}
// compactViewLen : la vue s'arrête avant les messages utilisateur de fin (le
// nouveau message, ceux de la file, une image montrée à l'étape) : le moteur
// ne les a pas en cache, et un second message utilisateur d'affilée ferait
// refuser la requête par les gabarits à alternance stricte.
func compactViewLen(msgs []Message) int {
k := len(msgs)
for k > 0 && msgs[k-1].Role == "user" {
k--
}
return k
}
// compactOptsFor : options des compactions de début et de fin de tour.
func (c *Conversation) compactOptsFor(caps Caps, msgs []Message, used int, conv string) compactOpts {
cfg := ReadConfig()
if conv == "" || !compactContinuationOn(cfg) {
return compactOpts{}
}
opt := compactOpts{precheck: true, conv: conv, used: used}
if used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
return opt
}
k := compactViewLen(msgs)
if k == 0 {
return opt
}
sent, tools := c.turnViewDry(caps, msgs[:k], caps.Agent)
_, effort, _, kwargs := turnReasoning(cfg)
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
return opt
}
// compactOptsMid : options d'une compaction entre deux étapes d'un tour
// (runChatTools). messages porte déjà le prompt système et le bloc projet ;
// pol est la politique de raisonnement renvoyé de la dernière requête.
// rescue : étape de secours (outils coupés ou neutralisés), dont le prompt
// n'est pas celui du tour.
func compactOptsMid(cfg map[string]string, ep chatEndpoint, conv string, messages []Message, used int,
tools []Tool, effort string, kwargs map[string]any, pol echoPolicy, rescue bool) compactOpts {
if conv == "" || ep.External || !compactContinuationOn(cfg) {
return compactOpts{}
}
opt := compactOpts{precheck: true, conv: conv, used: used}
if rescue || used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
return opt
}
k := compactViewLen(messages)
if k == 0 {
return opt
}
wire, _ := wireMessages(messages[:k], false, pol)
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
return opt
}
// compactMessagesNoted : compactMessagesOpt, plus la mémoire des refus.
func compactMessagesNoted(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) ([]Message, bool) {
out, changed, refused := compactMessagesOpt(ctx, msgs, caps, opt)
if opt.conv != "" {
if changed {
compactRefusedClear(opt.conv)
} else if refused {
compactRefusedNote(opt.conv, opt.used, msgs)
}
}
return out, changed
}
// --- refus mémorisés --------------------------------------------------------
// compactRefusal : une compaction refusée faute de réduction, à at jetons de
// contexte, sur un historique de n messages d'empreinte hash. En mémoire
// seulement : un redémarrage retente.
type compactRefusal struct {
at, window int
model string
n int
hash uint64
}
// historyHash : empreinte d'un historique (celle de perfPrefix).
func historyHash(msgs []Message) uint64 {
if len(msgs) == 0 {
return 0
}
p := perfPrefix(msgs)
return p[len(p)-1]
}
var compactRefused struct {
mu sync.Mutex
m map[string]compactRefusal
}
const compactRefusedMax = 64
// compactRefusedSkip : la dernière compaction de conv a été refusée, msgs ne
// fait que prolonger l'historique d'alors, et le contexte n'a pas grossi de
// 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; oublié dès que
// l'historique a été réécrit (compaction, édition, régénération, discussion
// vidée), que le contexte baisse, ou que le modèle ou la fenêtre changent.
func compactRefusedSkip(conv string, used int, msgs []Message) bool {
if conv == "" || used <= 0 || !compactContinuationOn(ReadConfig()) {
return false
}
model, window := engineMainNow()
compactRefused.mu.Lock()
r, ok := compactRefused.m[conv]
compactRefused.mu.Unlock()
if !ok {
return false
}
if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 ||
len(msgs) < r.n || historyHash(msgs[:r.n]) != r.hash {
compactRefusedClear(conv)
return false
}
if used*10 >= window*9 {
return false
}
fmt.Fprintf(os.Stderr, "[compact] pas de nouvel essai : refusée à %d jetons, contexte à %d\n", r.at, used)
return true
}
func compactRefusedNote(conv string, used int, msgs []Message) {
if conv == "" || used <= 0 {
return
}
model, window := engineMainNow()
hash := historyHash(msgs)
compactRefused.mu.Lock()
defer compactRefused.mu.Unlock()
if compactRefused.m == nil {
compactRefused.m = map[string]compactRefusal{}
}
if _, ok := compactRefused.m[conv]; !ok && len(compactRefused.m) >= compactRefusedMax {
for k := range compactRefused.m {
delete(compactRefused.m, k)
break
}
}
compactRefused.m[conv] = compactRefusal{at: used, window: window, model: model, n: len(msgs), hash: hash}
}
func compactRefusedClear(conv string) {
compactRefused.mu.Lock()
delete(compactRefused.m, conv)
compactRefused.mu.Unlock()
}
// --- échecs par modèle ------------------------------------------------------
// compactContFails : échecs de la continuation par modèle, jusqu'au
// redémarrage. À compactContMaxFails, elle n'est plus tentée pour ce modèle.
var compactContFails struct {
mu sync.Mutex
m map[string]int
}
const compactContMaxFails = 2
func compactContDisabled() bool {
model, _ := engineMainNow()
compactContFails.mu.Lock()
defer compactContFails.mu.Unlock()
return compactContFails.m[model] >= compactContMaxFails
}
// compactContFail : hard = refus du gabarit ou appel d'outil, qui se
// reproduiraient à l'identique : suspendue tout de suite.
func compactContFail(hard bool) {
model, _ := engineMainNow()
compactContFails.mu.Lock()
defer compactContFails.mu.Unlock()
if compactContFails.m == nil {
compactContFails.m = map[string]int{}
}
n := compactContFails.m[model] + 1
if hard {
n = compactContMaxFails
}
if n >= compactContMaxFails && compactContFails.m[model] < compactContMaxFails {
fmt.Fprintf(os.Stderr, "[compact] continuation suspendue pour %s jusqu'au redémarrage : résumés sur transcription\n", model)
}
compactContFails.m[model] = n
}
func compactContOK() {
model, _ := engineMainNow()
compactContFails.mu.Lock()
delete(compactContFails.m, model)
compactContFails.mu.Unlock()
}
// --- la requête -------------------------------------------------------------
// compactWireIndex : position dans view.wire du message msgs[i] de la vue
// modèle (len(view.wire) pour i == view.n : toute la vue), -1 si les deux vues
// ne se correspondent pas. L'assemblage n'ajoute ou ne déplace que des
// messages système (prompt, skills, bloc projet, rappel des pages lues) ; les
// autres se suivent un pour un, dans le même ordre — vérifié rôle par rôle,
// appel par appel, plutôt que supposé.
func compactWireIndex(msgs []Message, view *compactView, i int) int {
if view == nil || view.n > len(msgs) || i < 0 || i > view.n {
return -1
}
var a, b []int
for j, m := range msgs[:view.n] {
if m.Role != "system" {
a = append(a, j)
}
}
for j, m := range view.wire {
if m.Role != "system" {
b = append(b, j)
}
}
if len(a) != len(b) {
return -1
}
at := -1
for j := range a {
m, w := msgs[a[j]], view.wire[b[j]]
if m.Role != w.Role || m.ToolCallID != w.ToolCallID || len(m.ToolCalls) != len(w.ToolCalls) {
return -1
}
if a[j] == i {
at = b[j]
}
}
if i == view.n {
return len(view.wire)
}
return at
}
// clipRunes : s sur une ligne, coupé à n caractères.
func clipRunes(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) <= n {
return s
}
return string([]rune(s)[:n]) + "…"
}
// compactBoundaryDesc désigne le premier message gardé, m (vue modèle), qui
// est wire[wi] : son rôle, et l'appel d'outil ou le début du texte. Un début
// déjà vu plus haut est allongé, pour rester sans ambiguïté.
func compactBoundaryDesc(wire []Message, wi int, m Message) string {
if m.Role == "assistant" && strings.TrimSpace(msgText(m)) == "" && len(m.ToolCalls) > 0 {
f := m.ToolCalls[0].Function
return fmt.Sprintf("your call to the tool %s(%s)", f.Name, clipRunes(f.Arguments, 120))
}
who := "the user message"
switch m.Role {
case "assistant":
who = "your message"
case "tool":
who = "the tool result"
}
m, _ = withoutCtxUpdate(m)
text := msgText(m)
snip := clipRunes(text, 80)
for _, w := range wire[:wi] {
if strings.Contains(strings.Join(strings.Fields(msgText(w)), " "), strings.TrimSuffix(snip, "…")) {
snip = clipRunes(text, 200)
break
}
}
return fmt.Sprintf("%s that starts with «%s»", who, snip)
}
// compactContPrefix ouvre la demande de résumé : jamais rangée, elle ne vit
// que dans la requête.
const compactContPrefix = "[COMPACTION]"
// compactContInstruction : la demande ajoutée en fin de requête. kept =
// messages gardés tels quels à partir de la frontière (0 : tout est résumé).
func compactContInstruction(kept int, desc string, code bool) string {
var b strings.Builder
b.WriteString(compactContPrefix)
b.WriteString(" Pause the task: do not answer and do not call any tool. The context is full: the older part of this conversation will be replaced by the summary you write now, and your past reasoning will be lost — this summary is the only memory kept of that part.\n")
if kept > 0 {
fmt.Fprintf(&b, "Summarize every message BEFORE %s. That message and the ones after it (the last %d) stay verbatim after your summary: mention them briefly at most, but never leave out anything that comes before. Write the STATE OF PROGRESS as of that message, not later.\n", desc, kept)
} else {
b.WriteString("Summarize the whole conversation above.\n")
}
b.WriteString("Leave out the system instructions and any <project_context> or <context_update> block: they are sent again separately.\n\n")
b.WriteString(compactSummaryRules)
if code {
b.WriteString(codeSummaryRules)
}
return b.String()
}
// summarizeContinuation demande le résumé du torse [.., tailStart) en
// prolongeant la requête du tour (view). Toute erreur fait retomber
// l'appelant sur la transcription.
func summarizeContinuation(ctx context.Context, msgs []Message, tailStart int, view *compactView, code bool) (string, error) {
ep := resolveChatEndpoint()
if ep.External {
return "", errors.New("preset externe")
}
if tailStart > view.n {
return "", errors.New("frontière hors de la vue")
}
// Relu à l'envoi : une requête partie depuis la construction de la vue
// aurait pris le slot.
if !engineSlotHolds(view.conv) {
return "", errors.New("slot repris entre-temps")
}
wi := compactWireIndex(msgs, view, tailStart)
if wi < 0 {
return "", errors.New("vue d'envoi désalignée")
}
kept, desc := len(view.wire)-wi, ""
if kept > 0 {
desc = compactBoundaryDesc(view.wire, wi, msgs[tailStart])
}
instr := Message{Role: "user", Content: compactContInstruction(kept, desc, code)}
// Marge : contexte réel, la demande, le résumé, et 5 % de la fenêtre pour
// ce que l'estimation ne voit pas.
if need := view.used + msgTokens(instr) + compactSummaryBudget() + ctxWindow()*5/100; need > ctxWindow() {
return "", fmt.Errorf("marge insuffisante : %d jetons pour une fenêtre de %d", need, ctxWindow())
}
// Arguments du gabarit du tour, réflexion coupée : sur les gabarits
// hybrides (Qwen3…), enable_thinking ne change que l'ouverture de la
// réponse ; reasoning_effort, rendu dans le préfixe par gpt-oss, reste
// celui du tour.
kwargs := map[string]any{}
for k, v := range view.kwargs {
kwargs[k] = v
}
kwargs["enable_thinking"] = false
sent := append(append([]Message(nil), view.wire...), instr)
// tool_choice « none » : les outils restent rendus dans le prompt (le
// préfixe en cache tient), aucun appel n'est attendu.
payload := buildChatPayload(ep, sent, 0.2, chatPayloadOpts{
tools: view.tools, toolChoiceNone: true, effort: view.effort, kwargs: kwargs, noSampling: true,
})
payload["stream"] = false
delete(payload, "stream_options")
payload["max_tokens"] = compactSummaryBudget()
// SIDE_SLOT : elle prolonge le prompt du slot de la discussion, elle y va.
setEngineSlot(payload, engineSlotFor(ep, true))
ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: view.conv})
if err != nil {
// Refus de la requête elle-même (gabarit à alternance stricte, champ
// inconnu…) : il se reproduirait à chaque compaction. Une fenêtre
// pleine, un moteur en chargement ou une coupure réseau, non.
var he *summaryHTTPError
if errors.As(err, &he) && (he.status == 400 || he.status == 422 || he.status == 500) && !contextOverflow(he.body, nil) {
compactContFail(true)
}
return "", err
}
if len(ch.Message.ToolCalls) > 0 {
compactContFail(true)
return "", errors.New("appel d'outil émis au lieu d'un résumé")
}
if textualToolCallSnippet(ch.Message.Content) != "" {
compactContFail(false)
return "", errors.New("appel d'outil écrit en texte")
}
s, err := cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
if err != nil {
compactContFail(false)
return "", err
}
if summaryLooksEmpty(s) {
compactContFail(false)
return "", errors.New("résumé vide")
}
compactContOK()
return s, nil
}