diff --git a/README.md b/README.md index cabf607..7e11e51 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,33 @@ Ajoutées par ce fork : la télémétrie sous `prewarm`. Ce que voit le modèle ne change pas : au pire, le préchauffage ne sert à rien (moteur sans points de reprise aux messages utilisateur, image juste avant, agent ou web changé avant d'envoyer). +- **Compaction en continuation** (clé `COMPACT_CONTINUATION`, **off** par + défaut, `loki config set COMPACT_CONTINUATION on`) : sans la clé, le résumé + d'une compaction part dans une requête à part — un prompt de résumeur et une + transcription des anciens tours — que le moteur local calcule à froid, des + dizaines de secondes sur un 27B, des minutes sur un MoE. Avec la clé, quand le + slot porte encore le prompt de la discussion, Loki renvoie la requête du tour + telle qu'elle est partie (mêmes messages, outils, arguments du gabarit et + intensité de raisonnement) suivie d'une seule demande de résumé : le moteur ne + calcule que celle-ci. La demande désigne le premier message gardé tel quel, + demande de tout résumer avant lui et de dater l'état d'avancement à cet + endroit ; mêmes règles de résumé qu'avant (mode Code compris), même budget, + température 0.2 sans l'échantillonnage du preset, réflexion coupée, + `tool_choice` à `none`. Ce qui est compacté, archivé et rangé ne change pas : + la requête sert seulement à obtenir le résumé, et rien du prompt système ou du + projet n'entre dans l'historique. Repli sur la transcription au moindre + écart : autre requête passée par le slot depuis le tour (vérification du mode Code, + sous-agent, tâche, préchauffage, bench, client `/v1`, autre discussion), + moteur relancé sur un autre modèle ou une autre fenêtre, marge insuffisante + dans la fenêtre, refus du moteur, appel d'outil émis, résumé vide ou fait de + seul raisonnement ; un refus du gabarit ou un appel d'outil la suspend pour ce + modèle jusqu'au redémarrage (gpt-oss à intensité haute peut épuiser le budget + en réflexion : repli). La clé évite aussi un résumé voué au refus (même vide, + il ne réduirait pas le contexte de 20 %) et, après un refus faute de réduction, + n'en redemande pas avant que le contexte ait grossi de 10 % — sauf à 90 % de la + fenêtre. Le filet réactif (prompt refusé), le bouton « compacter », les tâches, + les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la + télémétrie sous `compact`. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index dbbfcf4..4638dc4 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -366,6 +366,17 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) { } func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) { + out, changed, _ := compactMessagesOpt(ctx, msgs, caps, compactOpts{}) + return out, changed +} + +// compactMessagesOpt : compactMessages, avec les options de +// COMPACT_CONTINUATION (chat_compact_cont.go) — options nulles, exactement le +// chemin d'avant. Bornes, archives, demande réinjectée et sortie restent sur la +// vue MODÈLE (msgs) dans tous les cas ; la vue d'envoi ne sert qu'à construire +// la requête de résumé. refused : un résumé a été obtenu, mais la réduction +// reste sous les 20 % exigés. +func compactMessagesOpt(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) { // Budget de queue = fraction de la CONVERSATION (pas de la fenêtre). Le lier à // la fenêtre était le bug : une conversation de 25k tokens dans une fenêtre de // 64k gardait 16k (0.25×64k) en queue → torse minuscule → réduction < 20% → @@ -376,11 +387,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // Rien à compacter : le torse [head, tailStart) est vide. if tailStart <= head { - return msgs, false + return msgs, false, false } torso := msgs[head:tailStart] + // Même un résumé vide ne ferait pas passer la garantie de réduction (plus + // bas) : inutile d'appeler le modèle ni d'archiver quoi que ce soit, la + // compaction serait refusée. Borne basse exacte, jamais un refus de plus + // qu'aujourd'hui. Avec COMPACT_CONTINUATION seulement. + if opt.precheck && compactHopeless(msgs, head, tailStart) { + fmt.Fprintf(os.Stderr, "[compact] résumé non demandé : même vide, la réduction resterait sous 20 %%\n") + return msgs, false, false + } + // 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc // du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et // le repli le remplacent par une référence à cet id, que le modèle pourra @@ -472,7 +492,19 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // Loki, pas la conversation, et le bloc projet est repris tout neuf après la // compaction. Sans la clé, rien à retirer. forSummary = stripContextUpdates(forSummary) - summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code) + var summary string + var err error + if opt.view != nil { + // COMPACT_CONTINUATION : le résumé prolonge le prompt que le moteur a + // déjà en cache. Au moindre écart, repli sur la transcription d'avant. + summary, err = summarizeContinuation(ctx, msgs, tailStart, opt.view, caps.Code) + if err != nil { + fmt.Fprintf(os.Stderr, "[compact] continuation écartée (%v) : résumé sur transcription\n", err) + summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code) + } + } else { + summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code) + } var mid []Message if err != nil || summaryLooksEmpty(summary) { // Résumé raté (erreur, vide, ou juste une référence recall) → on garde le @@ -497,7 +529,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, } mid = []Message{ {Role: "user", Content: sumMsg}, - {Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."}, + {Role: "assistant", Content: compactAckMsg}, } } @@ -522,31 +554,13 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une // demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni // il ne se réinjecte à la place de la vraie. - tailHasUser := false - for _, m := range msgs[tailStart:] { - if m.Role == "user" && !isLokiInjected(m) { - tailHasUser = true - break - } - } summarized := err == nil && !summaryLooksEmpty(summary) var pending []Message - for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- { - if torso[i].Role != "user" || isLokiInjected(torso[i]) { - continue - } - if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) { - continue // résumé d'une compaction précédente, pas une demande - } - pending = []Message{torso[i]} - break + if summarized { + pending = compactPending(msgs, torso, tailStart) } - out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart) - out = append(out, msgs[:head]...) - out = append(out, mid...) - out = append(out, pending...) - out = append(out, msgs[tailStart:]...) + out = compactAssemble(msgs, head, tailStart, mid, pending) // Garantie de réduction : on n'accepte la compaction que si elle enlève au // moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable) @@ -554,9 +568,55 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, // vraiment réduire, puis re-déclenchait aussitôt. before, after := estimateTokens(msgs), estimateTokens(out) if after > before*4/5 { - return msgs, false + return msgs, false, summarized } - return out, true + return out, true, false +} + +// compactAckMsg : la réponse assistant synthétique qui suit le résumé. +const compactAckMsg = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done." + +// compactPending : la demande en cours à réinjecter avant la queue (voir +// compactMessagesOpt), quand un résumé a été obtenu ; nil sinon. +func compactPending(msgs, torso []Message, tailStart int) []Message { + for _, m := range msgs[tailStart:] { + if m.Role == "user" && !isLokiInjected(m) { + return nil + } + } + for i := len(torso) - 1; i >= 0; i-- { + if torso[i].Role != "user" || isLokiInjected(torso[i]) { + continue + } + if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) { + continue // résumé d'une compaction précédente, pas une demande + } + return []Message{torso[i]} + } + return nil +} + +// compactAssemble : tête, zone résumée, demande réinjectée, queue. +func compactAssemble(msgs []Message, head, tailStart int, mid, pending []Message) []Message { + out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart) + out = append(out, msgs[:head]...) + out = append(out, mid...) + out = append(out, pending...) + return append(out, msgs[tailStart:]...) +} + +// compactHopeless : la compaction serait refusée par la garantie de réduction +// quel que soit le résumé. Le plancher est la sortie avec un résumé VIDE — +// message d'en-tête court, sans index ni critères, réponse assistant, demande +// réinjectée, queue : toute vraie sortie a la même forme avec des messages au +// moins aussi longs, donc une estimation au moins aussi grande. +func compactHopeless(msgs []Message, head, tailStart int) bool { + torso := msgs[head:tailStart] + floor := compactAssemble(msgs, head, tailStart, []Message{ + {Role: "user", Content: compactSummaryUserMsg("", nil)}, + {Role: "assistant", Content: compactAckMsg}, + }, compactPending(msgs, torso, tailStart)) + return estimateTokens(floor) > estimateTokens(msgs)*4/5 } // summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement @@ -688,13 +748,7 @@ func renderTranscript(msgs []Message) string { // 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"` - ReasoningContent string `json:"reasoning_content"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` + Choices []summaryChoice `json:"choices"` } // summarizeTranscript demande au modèle local un résumé dense et fidèle du torse. @@ -713,13 +767,17 @@ This is a CODING session. Also keep: - Errors hit (build, tests, tools) and how each was resolved — or that it is still open - The commands that build and test the project` -// summarizeTranscriptFor : code = résumé d'une session du mode Code. -func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) { - sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant. +// compactTranscriptIntro : le rôle du résumeur sur transcription. Les règles +// qui suivent (compactSummaryRules) servent aussi au résumé en continuation +// (chat_compact_cont.go) : les deux chemins gardent les mêmes exigences. +const compactTranscriptIntro = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant. The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps. -Summarize densely and faithfully, keeping ONLY the essentials: +` + +// compactSummaryRules : ce que tout résumé de compaction doit garder. +const compactSummaryRules = `Summarize densely and faithfully, keeping ONLY the essentials: - The user's CURRENT request, goal(s) and constraints - FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again. - Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time @@ -727,6 +785,10 @@ Summarize densely and faithfully, keeping ONLY the essentials: - STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference. Write the summary in the SAME language as the conversation.` + +// summarizeTranscriptFor : code = résumé d'une session du mode Code. +func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) { + sys := compactTranscriptIntro + compactSummaryRules if code { sys += codeSummaryRules } @@ -757,10 +819,45 @@ Write the summary in the SAME language as the conversation.` // argument inconnu par un 400, et la compaction échouait à chaque fois. delete(payload, "chat_template_kwargs") } + ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}) + if err != nil { + return "", err + } + return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason) +} + +// summaryChoice : le premier choix d'une réponse de résumé. +type summaryChoice struct { + Message struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + // Appels d'outil émis malgré tool_choice « none » (résumé en + // continuation, qui garde les outils du tour) : jamais un résumé. + ToolCalls []json.RawMessage `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// summaryHTTPError : le moteur a refusé la requête de résumé. +type summaryHTTPError struct { + who string + status int + body string +} + +func (e *summaryHTTPError) Error() string { + return fmt.Sprintf("résumé: %s %d: %s", e.who, e.status, e.body) +} + +// postSummary envoie une requête de résumé non streamée et rend son premier +// choix. Partagé par le résumé sur transcription et celui en continuation +// (chat_compact_cont.go) : même compteur de requêtes en vol, même télémétrie +// (kind=compact), mêmes erreurs. +func postSummary(ctx context.Context, ep chatEndpoint, payload map[string]any, tag perfTag) (summaryChoice, error) { body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body)) if err != nil { - return "", err + return summaryChoice{}, err } req.Header.Set("Content-Type", "application/json") ep.auth(req.Header.Set) @@ -769,7 +866,7 @@ Write the summary in the SAME language as the conversation.` } resp, err := http.DefaultClient.Do(req) if err != nil { - return "", friendlyLLMError(err) + return summaryChoice{}, friendlyLLMError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -778,28 +875,27 @@ Write the summary in the SAME language as the conversation.` if ep.External { who = "API externe" } - return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b))) + return summaryChoice{}, &summaryHTTPError{who: who, status: resp.StatusCode, body: strings.TrimSpace(string(b))} } raw, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return summaryChoice{}, err } // Decoder et non Unmarshal : comme avant, seule la première valeur JSON // compte — un octet parasite après elle (proxy, passerelle) ne doit pas // faire échouer une compaction qui passait. var out summarizeResp if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil { - return "", err + return summaryChoice{}, err } // Télémétrie de la compaction : réponse non streamée, timings et usage sont // à la racine. Lue à part et sans échec possible (perfWire) : un compteur // mal typé ne doit jamais faire échouer la compaction. - perfRecord(perfRecFromWire(perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}, decodePerfWire(raw)), nil) + perfRecord(perfRecFromWire(tag, decodePerfWire(raw)), nil) if len(out.Choices) == 0 { - return "", fmt.Errorf("résumé: réponse vide") + return summaryChoice{}, fmt.Errorf("résumé: réponse vide") } - ch := out.Choices[0] - return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason) + return out.Choices[0], nil } // summaryRuneCap borne le résumé en caractères, en filet de sécurité diff --git a/internal/loki/chat_compact_cont.go b/internal/loki/chat_compact_cont.go new file mode 100644 index 0000000..b87eb03 --- /dev/null +++ b/internal/loki/chat_compact_cont.go @@ -0,0 +1,458 @@ +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 % — 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) + } + } + return out, changed +} + +// --- refus mémorisés -------------------------------------------------------- + +// compactRefusal : une compaction refusée faute de réduction, à at jetons de +// contexte. En mémoire seulement : un redémarrage retente. +type compactRefusal struct { + at, window int + model string +} + +var compactRefused struct { + mu sync.Mutex + m map[string]compactRefusal +} + +const compactRefusedMax = 64 + +// compactRefusedSkip : la dernière compaction de conv a été refusée et le +// contexte n'a pas grossi de 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; +// oublié dès que le contexte baisse (compaction, réécriture, nouvelle +// discussion) ou que le modèle ou la fenêtre changent. +func compactRefusedSkip(conv string, used int) bool { + if conv == "" || used <= 0 || !compactContinuationOn(ReadConfig()) { + return false + } + model, window := engineMainNow() + compactRefused.mu.Lock() + defer compactRefused.mu.Unlock() + r, ok := compactRefused.m[conv] + if !ok { + return false + } + if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 { + delete(compactRefused.m, 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) { + if conv == "" || used <= 0 { + return + } + model, window := engineMainNow() + 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} +} + +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 or 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() + 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 +} diff --git a/internal/loki/chat_compact_cont_test.go b/internal/loki/chat_compact_cont_test.go new file mode 100644 index 0000000..1093be3 --- /dev/null +++ b/internal/loki/chat_compact_cont_test.go @@ -0,0 +1,602 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "sync" + "testing" +) + +// oldCompactorSys : le prompt du résumeur tel qu'il était avant le partage de +// ses règles avec la continuation — copie figée, à l'octet près. +const oldCompactorSys = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant. + +The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps. + +Summarize densely and faithfully, keeping ONLY the essentials: +- The user's CURRENT request, goal(s) and constraints +- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again. +- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time +- Decisions made and established facts +- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step +Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference. +Write the summary in the SAME language as the conversation.` + +const contSummary = "- L'utilisateur prépare un voyage à Lyon ; horaires trouvés : départ 8 h 12, arrivée 10 h 05." + +// moteurCont : faux llama-server. Tours streamés : « ok. ». Résumés : +// contSummary ; pour la continuation, contStatus (refus) ou contReply s'ils +// sont posés. +type moteurCont struct { + mu sync.Mutex + bodies []string + contStatus int + contReply map[string]any +} + +func (m *moteurCont) start(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + _, _ = buf.ReadFrom(r.Body) + body := buf.String() + switch r.URL.Path { + case "/health": + sendJSON(w, 200, map[string]any{"status": "ok"}) + return + case "/props": + sendJSON(w, 200, map[string]any{"total_slots": 1, "build_info": "b9000-x"}) + return + } + m.mu.Lock() + m.bodies = append(m.bodies, body) + status, reply := m.contStatus, m.contReply + m.mu.Unlock() + if strings.Contains(body, `"stream":true`) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n")) + return + } + if isContBody(body) { + if status != 0 { + sendJSON(w, status, map[string]any{"error": map[string]any{"message": "template error"}}) + return + } + if reply != nil { + sendJSON(w, 200, reply) + return + } + } + sendJSON(w, 200, map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"content": contSummary}, "finish_reason": "stop"}}, + "usage": map[string]any{"prompt_tokens": 900, "completion_tokens": 40}, + "timings": map[string]any{"prompt_n": 30, "cache_n": 870, "predicted_n": 40}, + }) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } +} + +func isContBody(body string) bool { + var p struct { + Messages []Message `json:"messages"` + } + _ = json.Unmarshal([]byte(body), &p) + n := len(p.Messages) + return n > 0 && strings.HasPrefix(msgText(p.Messages[n-1]), compactContPrefix) +} + +func isTranscriptBody(body string) bool { + var p struct { + Messages []Message `json:"messages"` + } + _ = json.Unmarshal([]byte(body), &p) + return len(p.Messages) == 2 && p.Messages[0].Role == "system" && + strings.HasPrefix(msgText(p.Messages[0]), "You are a context compactor.") +} + +func (m *moteurCont) all() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.bodies...) +} + +// contReset : état global de la continuation remis à neuf. +func contReset(t *testing.T) { + reset := func() { + engineGate.mu.Lock() + engineGate.main = engineMainStamp{} + engineGate.mu.Unlock() + compactContFails.mu.Lock() + compactContFails.m = nil + compactContFails.mu.Unlock() + compactRefused.mu.Lock() + compactRefused.m = nil + compactRefused.mu.Unlock() + } + reset() + t.Cleanup(reset) +} + +func contSetup(t *testing.T, keys map[string]string) *moteurCont { + t.Helper() + withWorkspace(t) + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + setProjectOverride("") + t.Cleanup(func() { + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + setProjectOverride("") + }) + ensureDefaultProject() + freshTplProbe(t) + convEnsureActive() + contReset(t) + if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + for k, v := range keys { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + m := &moteurCont{} + m.start(t) + return m +} + +// contHistory : une longue discussion, de quoi compacter. +func contHistory() []Message { + var h []Message + for i := 0; i < 10; i++ { + h = append(h, um("question "+string(rune('a'+i))+" "+strings.Repeat("u", 1500)), + am("réponse "+string(rune('a'+i))+" "+strings.Repeat("r", 1500))) + } + return h +} + +// contTwoTurns : un premier tour sans compaction (il pose le tampon du slot), +// puis un second dont le début compacte. Rend les corps envoyés pendant le +// second tour, et celui du premier tour. +func contTwoTurns(t *testing.T, m *moteurCont, c *Conversation, caps Caps) (first string, second []string) { + t.Helper() + c.mu.Lock() + c.Messages = contHistory() + c.CtxUsed, c.ctxUsedLen = 1000, len(c.Messages) + c.mu.Unlock() + prewarmTurn(t, c, caps, "première demande") + bodies := m.all() + if len(bodies) != 1 { + t.Fatalf("premier tour : 1 requête attendue, %d", len(bodies)) + } + c.mu.Lock() + c.CtxUsed, c.ctxUsedLen = 6200, len(c.Messages) + c.mu.Unlock() + prewarmTurn(t, c, caps, "deuxième demande") + return bodies[0], m.all()[1:] +} + +// Sans la clé : la requête de résumé est celle d'avant — prompt du résumeur +// identique à l'octet près, transcription, aucun champ de plus — et aucun +// tampon de slot n'est posé. +func TestCompactContDefautIdentique(t *testing.T) { + m := contSetup(t, map[string]string{"TOP_P": "0.9", "REASONING_EFFORT": "high"}) + c := newTestConv() + _, second := contTwoTurns(t, m, c, Caps{Agent: true}) + if len(second) != 2 || !isTranscriptBody(second[0]) { + t.Fatalf("sans la clé : résumé sur transcription puis tour attendus, %d requêtes", len(second)) + } + var p map[string]any + if err := json.Unmarshal([]byte(second[0]), &p); err != nil { + t.Fatal(err) + } + want := map[string]any{"model": p["model"], "stream": false, "temperature": 0.2, "max_tokens": float64(700), + "chat_template_kwargs": map[string]any{"enable_thinking": false}, "messages": p["messages"]} + if !reflect.DeepEqual(p, want) { + t.Fatalf("champs du résumé changés : %v", p) + } + if sys := p["messages"].([]any)[0].(map[string]any)["content"]; sys != oldCompactorSys { + t.Fatalf("prompt du résumeur changé :\n%v", sys) + } + engineGate.mu.Lock() + stamp := engineGate.main + engineGate.mu.Unlock() + if stamp.seq != 0 { + t.Fatal("tampon de slot posé sans la clé") + } + if compactContinuationOn(ReadConfig()) || compactRefusedSkip(convActiveID(), 7000) { + t.Fatal("continuation ou mémoire des refus active sans la clé") + } +} + +// Avec la clé : la requête de résumé prolonge EXACTEMENT celle du premier tour +// (même préfixe, mêmes outils et réglages du gabarit), plus la réponse rangée +// et une seule demande de résumé ; sans l'échantillonnage du preset ; et rien +// d'injecté n'entre dans l'historique. +func TestCompactContPrefixeEtHistorique(t *testing.T) { + m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on", "TOP_P": "0.9", "REASONING_EFFORT": "high"}) + c := newTestConv() + first, second := contTwoTurns(t, m, c, Caps{Agent: true}) + if len(second) != 2 || !isContBody(second[0]) { + t.Fatalf("continuation puis tour attendus, %d requêtes (transcription : %v)", len(second), len(second) > 0 && isTranscriptBody(second[0])) + } + var f, s map[string]any + if err := json.Unmarshal([]byte(first), &f); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(second[0]), &s); err != nil { + t.Fatal(err) + } + fm, sm := f["messages"].([]any), s["messages"].([]any) + if len(sm) != len(fm)+2 { + t.Fatalf("continuation : %d messages, premier tour %d (+ réponse + demande attendus)", len(sm), len(fm)) + } + if !reflect.DeepEqual(sm[:len(fm)], fm) { + t.Fatal("la continuation ne prolonge pas la requête du premier tour") + } + if a := sm[len(fm)].(map[string]any); a["role"] != "assistant" || a["content"] != "ok." { + t.Fatalf("réponse rangée attendue après le préfixe : %v", a) + } + last := sm[len(sm)-1].(map[string]any) + if last["role"] != "user" || !strings.Contains(last["content"].(string), "STATE OF PROGRESS") { + t.Fatalf("demande de résumé : %v", last) + } + for _, k := range []string{"tools", "parallel_tool_calls", "reasoning_effort", "model"} { + if !reflect.DeepEqual(f[k], s[k]) { + t.Fatalf("champ %s : tour %v, continuation %v", k, f[k], s[k]) + } + } + if s["tool_choice"] != "none" || s["stream"] != false || s["max_tokens"] != float64(700) || + s["temperature"] != 0.2 || s["top_p"] != nil || s["stream_options"] != nil { + t.Fatalf("champs de réponse : %v", s) + } + if f["top_p"] != 0.9 { + t.Fatal("le tour lui-même doit garder l'échantillonnage du preset") + } + kw, _ := s["chat_template_kwargs"].(map[string]any) + if kw["enable_thinking"] != false || kw["reasoning_effort"] != "high" { + t.Fatalf("arguments du gabarit : %v", kw) + } + c.mu.Lock() + defer c.mu.Unlock() + found := false + for _, msg := range c.Messages { + txt := msgText(msg) + if msg.Role == "system" || strings.HasPrefix(txt, compactContPrefix) || strings.Contains(txt, "") { + t.Fatalf("message injecté rangé dans l'historique : %s %.80q", msg.Role, txt) + } + if strings.Contains(txt, contSummary) { + found = true + } + } + if !found { + t.Fatal("résumé absent de l'historique compacté") + } + kinds := false + for _, r := range perfLog.snapshot() { + if r.Kind == perfCompact && r.Conv == convActiveID() { + kinds = true + } + } + if !kinds { + t.Fatal("aucune complétion kind=compact rattachée à la discussion") + } +} + +// contViewOpts : options en cours de tour pour msgs, slot tamponné pour conv. +func contViewOpts(t *testing.T, msgs []Message, used int) compactOpts { + t.Helper() + end, seq := engineRequestBegin(nil) + end() + engineMarkMain(seq, "conv-test") + return compactOptsMid(ReadConfig(), resolveChatEndpoint(), "conv-test", msgs, used, nil, "", nil, echoPolicy{}, false) +} + +func contMidMsgs() []Message { + return append([]Message{{Role: "system", Content: "SYS"}}, contHistory()...) +} + +// Les replis : refus du moteur (gabarit), appel d'outil émis, marge +// insuffisante, slot pris entre-temps — la compaction passe par la +// transcription et réussit quand même. Un refus du gabarit ou un appel +// d'outil suspend la continuation pour ce modèle. +func TestCompactContReplis(t *testing.T) { + cases := []struct { + name string + setup func(m *moteurCont) + used int + stale bool + wantCont bool + disabled bool + }{ + {"gabarit 500", func(m *moteurCont) { m.contStatus = 500 }, 5000, false, true, true}, + {"appel d'outil", func(m *moteurCont) { + m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{ + "content": "", "tool_calls": []any{map[string]any{"id": "x", "type": "function"}}}, "finish_reason": "tool_calls"}}} + }, 5000, false, true, true}, + {"raisonnement seul", func(m *moteurCont) { + m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{ + "content": "", "reasoning_content": "je réfléchis"}, "finish_reason": "length"}}} + }, 5000, false, true, false}, + {"marge", func(*moteurCont) {}, 7500, false, false, false}, + {"slot pris", func(*moteurCont) {}, 5000, true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"}) + tc.setup(m) + msgs := contMidMsgs() + opt := contViewOpts(t, msgs, tc.used) + if opt.view == nil { + t.Fatal("vue attendue") + } + if tc.stale { + engineRequestStart()() + } + out, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, opt) + if !changed { + t.Fatal("compaction refusée") + } + if out[0].Role != "system" || msgText(out[0]) != "SYS" || !strings.Contains(msgText(out[1]), contSummary) { + t.Fatalf("sortie inattendue : %v / %.80q", out[0], msgText(out[1])) + } + bodies := m.all() + cont := len(bodies) == 2 && isContBody(bodies[0]) && isTranscriptBody(bodies[1]) + direct := len(bodies) == 1 && isTranscriptBody(bodies[0]) + if tc.wantCont && !cont || !tc.wantCont && !direct { + t.Fatalf("requêtes inattendues (%d)", len(bodies)) + } + if compactContDisabled() != tc.disabled { + t.Fatalf("suspension pour le modèle : %v, attendu %v", compactContDisabled(), tc.disabled) + } + }) + } +} + +// Deux résumés ratés de suite suspendent la continuation ; un réussi remet le +// compte à zéro. +func TestCompactContSuspension(t *testing.T) { + contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"}) + compactContFail(false) + if compactContDisabled() { + t.Fatal("suspendue après un seul échec") + } + compactContOK() + compactContFail(false) + if compactContDisabled() { + t.Fatal("le succès n'a pas remis le compte à zéro") + } + compactContFail(false) + if !compactContDisabled() { + t.Fatal("pas suspendue après deux échecs") + } + if o := contViewOpts(t, contMidMsgs(), 5000); o.view != nil || !o.precheck { + t.Fatal("vue construite pour un modèle suspendu") + } +} + +// Le tampon du slot : posé par une étape d'un tour de discussion, perdu dès +// qu'une autre requête part (résumé, vérification, préchauffage…), ou que le +// modèle ou la fenêtre changent. +func TestEngineSlotHolds(t *testing.T) { + testHome(t) + contReset(t) + if err := SetConfigKey("MODEL", "/models/a.gguf"); err != nil { + t.Fatal(err) + } + mark := func() { + end, seq := engineRequestBegin(nil) + end() + engineMarkMain(seq, "c1") + } + mark() + if !engineSlotHolds("c1") || engineSlotHolds("c2") || engineSlotHolds("") { + t.Fatal("tampon mal lu") + } + engineRequestStart()() + if engineSlotHolds("c1") { + t.Fatal("tampon gardé après une autre requête") + } + // Une requête partie entre le départ et l'acceptation : pas de tampon. + end, seq := engineRequestBegin(nil) + end() + engineRequestStart()() + engineMarkMain(seq, "c1") + if engineSlotHolds("c1") { + t.Fatal("tampon posé pour une requête dépassée") + } + mark() + p := &prewarmRun{cancel: func() {}} + if endP, ok := prewarmBegin(p); ok { + endP() + } + if engineSlotHolds("c1") { + t.Fatal("tampon gardé après un préchauffage") + } + mark() + if err := SetConfigKey("MODEL", "/models/b.gguf"); err != nil { + t.Fatal(err) + } + if engineSlotHolds("c1") { + t.Fatal("tampon gardé après un changement de modèle") + } +} + +// Une étape d'un sous-agent ou d'une vérification ne pose pas de tampon : +// après elle, la compaction de fin de tour passe par la transcription. +func TestCompactContPasApresVerification(t *testing.T) { + contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"}) + end, seq := engineRequestBegin(nil) + end() + engineMarkMain(seq, convActiveID()) + ctx := withPerf(context.Background(), perfMain, convActiveID()) + if _, err := runChat(withPerfKind(ctx, perfVerify), []Message{um("vérifie")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + if engineSlotHolds(convActiveID()) { + t.Fatal("tampon gardé après une vérification") + } + if _, err := runChat(ctx, []Message{um("suite")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil { + t.Fatal(err) + } + if !engineSlotHolds(convActiveID()) { + t.Fatal("une étape du tour devrait poser le tampon") + } +} + +// La correspondance des deux vues : messages système ajoutés ou déplacés, +// les autres un pour un ; tout écart rend -1. +func TestCompactWireIndex(t *testing.T) { + msgs := []Message{ + {Role: "system", Content: memReminderPrefix + " pages"}, + um("a"), atc("bash"), tm("r"), am("b"), um("c"), + } + wire := normalizeSystemMessages(append([]Message{{Role: "system", Content: "SYS"}, {Role: "system", Content: projectContextPrefix + " p"}}, msgs[:5]...)) + v := &compactView{wire: wire, n: 5} + if wire[0].Role != "system" || len(wire) != 5 { + t.Fatalf("vue d'envoi : %v", wire) + } + for i, want := range map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 0: -1, 6: -1} { + if got := compactWireIndex(msgs, v, i); got != want { + t.Errorf("index %d : %d, attendu %d", i, got, want) + } + } + bad := append([]Message(nil), wire...) + bad[3] = am("x") + if compactWireIndex(msgs, &compactView{wire: bad, n: 5}, 2) != -1 { + t.Fatal("vue désalignée acceptée") + } + if compactWireIndex(msgs, &compactView{wire: wire[:4], n: 5}, 2) != -1 { + t.Fatal("vue tronquée acceptée") + } +} + +// La frontière désignée : appel d'outil par son nom et ses arguments, texte +// par son début, allongé s'il est déjà apparu plus haut. +func TestCompactBoundaryDesc(t *testing.T) { + call := Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: "web_read", Arguments: `{"url":"https://exemple.fr"}`}}}} + if d := compactBoundaryDesc(nil, 0, call); !strings.Contains(d, `web_read({"url":"https://exemple.fr"})`) { + t.Fatalf("appel d'outil : %s", d) + } + long := strings.Repeat("même début ", 10) + "fin unique" + wire := []Message{um(strings.Repeat("même début ", 10) + "autre fin"), um(long)} + d := compactBoundaryDesc(wire, 1, wire[1]) + if !strings.Contains(d, "fin unique") { + t.Fatalf("début ambigu non allongé : %s", d) + } + if d := compactBoundaryDesc(nil, 0, um("court")); d != "the user message that starts with «court»" { + t.Fatalf("texte : %s", d) + } +} + +// La demande de résumé reprend les règles du résumeur, et celles du mode Code. +func TestCompactContInstruction(t *testing.T) { + s := compactContInstruction(3, "the user message that starts with «x»", true) + for _, want := range []string{compactContPrefix, compactSummaryRules, codeSummaryRules, "the last 3", "BEFORE the user message"} { + if !strings.Contains(s, want) { + t.Fatalf("demande sans %q", want) + } + } + if s := compactContInstruction(0, "", false); !strings.Contains(s, "whole conversation") || strings.Contains(s, "CODING") { + t.Fatal("demande sans frontière") + } +} + +// Pré-contrôle : quand même un résumé vide ne réduirait pas de 20 %, aucun +// résumé n'est demandé ni aucun bloc archivé ; sans lui (sans la clé), la +// requête part comme avant, pour être refusée ensuite. +func TestCompactHopeless(t *testing.T) { + m := contSetup(t, nil) + // Torse minuscule, queue énorme : rien à gagner. + msgs := []Message{um("a"), am("b"), um("c"), am(strings.Repeat("r", 40000))} + head, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac)) + if !compactHopeless(msgs, head, tailStart) { + t.Fatal("cas sans issue non reconnu") + } + if _, changed, _ := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{precheck: true}); changed { + t.Fatal("compaction acceptée") + } + if n := len(m.all()); n != 0 { + t.Fatalf("résumé demandé malgré le pré-contrôle (%d requêtes)", n) + } + _, changed, refused := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{}) + if changed || !refused || len(m.all()) != 1 { + t.Fatalf("sans pré-contrôle : changed=%v refused=%v requêtes=%d", changed, refused, len(m.all())) + } + // Un cas qui réussit n'est jamais déclaré sans issue. + h := contHistory() + head, tailStart = compactBounds(h, int(float64(estimateTokens(h))*compactTailFrac)) + if compactHopeless(h, head, tailStart) { + t.Fatal("compaction faisable déclarée sans issue") + } +} + +// Mémoire des refus : pas de nouvel essai avant +10 % de contexte, jamais à +// 90 % de la fenêtre, oubliée quand le contexte baisse ; rien sans la clé. +func TestCompactRefusedSkip(t *testing.T) { + testHome(t) + contReset(t) + if err := SetConfigKey("CTX", "65536"); err != nil { + t.Fatal(err) + } + compactRefusedNote("c1", 50000) + if compactRefusedSkip("c1", 52000) { + t.Fatal("sans la clé, aucun essai ne doit être sauté") + } + if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil { + t.Fatal(err) + } + if !compactRefusedSkip("c1", 52000) || compactRefusedSkip("c2", 52000) { + t.Fatal("refus mal appliqué") + } + if compactRefusedSkip("c1", 59000) { + t.Fatal("essai sauté à 90 % de la fenêtre") + } + if compactRefusedSkip("c1", 55000) { + t.Fatal("essai sauté après +10 %") + } + if compactRefusedSkip("c1", 52000) { + t.Fatal("refus non oublié après +10 %") + } + compactRefusedNote("c1", 50000) + if compactRefusedSkip("c1", 30000) || compactRefusedSkip("c1", 50000) { + t.Fatal("refus non oublié quand le contexte baisse") + } + // Une compaction refusée note le refus ; réussie, elle l'efface. + compactRefusedNote("c1", 50000) + compactRefusedClear("c1") + if compactRefusedSkip("c1", 51000) { + t.Fatal("refus non effacé") + } +} + +// buildChatPayload : sans noSampling, l'échantillonnage du preset est posé +// comme avant ; avec, il ne l'est pas. +func TestBuildChatPayloadNoSampling(t *testing.T) { + testHome(t) + if err := SetConfigKey("TOP_P", "0.9"); err != nil { + t.Fatal(err) + } + ep := chatEndpoint{Model: "m"} + if p := buildChatPayload(ep, []Message{um("x")}, 0.7, chatPayloadOpts{}); p["top_p"] != 0.9 { + t.Fatalf("échantillonnage perdu : %v", p) + } + if p := buildChatPayload(ep, []Message{um("x")}, 0.2, chatPayloadOpts{noSampling: true}); p["top_p"] != nil || p["temperature"] != 0.2 { + t.Fatalf("échantillonnage posé malgré noSampling : %v", p) + } +} diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 485f204..68f0c95 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -389,9 +389,12 @@ func compactLog(log []LogEvent) []LogEvent { // messages donne ce que l'estimation ne voit pas (prompt système injecté, // schémas d'outils, gabarit de chat). On le rajoute à l'estimation d'après // compaction, sinon la jauge s'effondre puis resaute au tour suivant. -func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps) ([]Message, bool) { +// +// opt : options de COMPACT_CONTINUATION (chat_compact_cont.go) ; nulles, le +// chemin d'avant. +func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps, opt compactOpts) ([]Message, bool) { c.appendDelta(epoch, map[string]any{"compacting": true}) - compacted, changed := compactMessages(ctx, msgs, caps) + compacted, changed := compactMessagesNoted(ctx, msgs, caps, opt) c.appendDelta(epoch, map[string]any{"compacting": false}) logCompact(phase, ctxUsed, msgs, compacted, changed) if !changed { @@ -797,8 +800,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // Estimation de début de tour, comparée au premier compte réel du moteur // (journal [ctx], seulement au-delà de 2 % d'écart). startEst := ctxUsed - if compactNeeded(msgs, ctxUsed, peak) { - if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed { + // COMPACT_CONTINUATION (off par défaut) : un refus récent n'est pas retenté + // tout de suite, et le résumé peut prolonger le prompt en cache. Sans la + // clé, ni l'un ni l'autre. + conv := perfTagOf(ctx).conv + if compactNeeded(msgs, ctxUsed, peak) && !compactRefusedSkip(conv, ctxUsed) { + opt := c.compactOptsFor(caps, msgs, ctxUsed, conv) + if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps, opt); changed { msgs = out // Le chiffre d'avant compaction ne dit plus rien de la requête qui // part : on compare celui que la compaction vient de poser. @@ -1009,12 +1017,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // retombe tout de suite et le tour suivant démarre avec de la marge. // Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé // (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop. - if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) { + if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed) { return } // context.Background() et non ctx : le tour est terminé, son contexte peut - // être annulé alors que cette compaction-là doit aller au bout. - c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps) + // être annulé alors que cette compaction-là doit aller au bout. Après une + // vérification du mode Code, le slot porte son prompt : pas de + // continuation (engineSlotHolds), transcription comme avant. + c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps, c.compactOptsFor(caps, msgs, ctxUsed, conv)) } // ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server @@ -1063,7 +1073,7 @@ func (c *Conversation) CompactNow() error { } c.mu.Unlock() }() - if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed { + if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}, compactOpts{}); changed { c.persist() } }() diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go index 27f3861..e154d2d 100644 --- a/internal/loki/chat_prewarm.go +++ b/internal/loki/chat_prewarm.go @@ -130,6 +130,7 @@ func prewarmBegin(p *prewarmRun) (end func(), ok bool) { prewarmCur = p prewarmLive.Store(true) engineGate.inflight++ + engineGate.seq++ // le slot ne porte plus le tour d'avant (engineSlotHolds) var once sync.Once return func() { once.Do(func() { diff --git a/internal/loki/chat_projsnap.go b/internal/loki/chat_projsnap.go index 4bb0ecb..a68b3db 100644 --- a/internal/loki/chat_projsnap.go +++ b/internal/loki/chat_projsnap.go @@ -234,7 +234,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string { // moteur ni toucher au bloc. Les compter rafraîchirait pour rien. var projSnapIgnoredKeys = map[string]bool{ "PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true, - "COMPACT": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true, + "COMPACT": true, "COMPACT_CONTINUATION": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true, } // projSnapSysHash : empreinte du bloc système commun et des outils. S'ils diff --git a/internal/loki/chat_projsnap_test.go b/internal/loki/chat_projsnap_test.go index 5fd8186..91240c2 100644 --- a/internal/loki/chat_projsnap_test.go +++ b/internal/loki/chat_projsnap_test.go @@ -248,7 +248,7 @@ func TestProjSnapCompactionRafraichit(t *testing.T) { } // Le message avec la mise à jour en dernier : il tombe dans la queue gardée. c.Messages = append(c.Messages, am("vu"), um(ctxUpdOpen+` at="x">`+"\n~ poids — 72.4 kg\n"+ctxUpdClose+"\n\nencore")) - if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps); !changed { + if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps, compactOpts{}); !changed { t.Fatal("compaction sans effet") } if c.ProjSnap != nil || hasContextUpdates(c.Messages) { diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index a80eca4..945cacb 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1165,6 +1165,12 @@ type chatPayloadOpts struct { toolChoiceNone bool effort string kwargs map[string]any + // noSampling : sans l'échantillonnage du preset. Pour le résumé en + // continuation (COMPACT_CONTINUATION, chat_compact_cont.go), qui garde celui + // du résumeur d'aujourd'hui — température 0.2 seule : une pénalité de + // répétition jouerait contre la reprise fidèle des noms, chemins et chiffres. + // L'échantillonnage ne touche pas au prompt : le cache n'y perd rien. + noSampling bool } // buildChatPayload : le corps d'une requête de complétion de chat, à partir des @@ -1189,7 +1195,9 @@ func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o ch // l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les // deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que // c'est bien loki qui a le dernier mot sur `chat_template_kwargs`. - applySampling(payload) + if !o.noSampling { + applySampling(payload) + } if o.effort != "" { payload["reasoning_effort"] = o.effort } @@ -1398,8 +1406,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera // Un préchauffage en vol (PREWARM, chat_prewarm.go) est annulé ici, sauf // s'il prépare exactement le début de CETTE requête d'un tour de chat. endReq := func() {} + var reqSeq uint64 if !ep.External { - endReq = engineRequestStartKeep(prewarmKeeper(ptag.kind, sent, payload)) + endReq, reqSeq = engineRequestBegin(prewarmKeeper(ptag.kind, sent, payload)) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -1569,6 +1578,11 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera } if !ep.External { engineServed() // le slot porte désormais cette requête (llm_slots.go) + // Étape d'un tour de discussion : une compaction en continuation + // pourra la prolonger (chat_compact_cont.go). Sans la clé, rien. + if ptag.kind == perfMain && compactContinuationOn(chatCfg) { + engineMarkMain(reqSeq, ptag.conv) + } } toolCalls := map[int]*ToolCall{} // argBufs : arguments de chaque appel, accumulés sans recopie. `+=` sur la @@ -2481,10 +2495,18 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera used = base + estimateTokens(messages[stepStart:]) lastEst = used } - if compactNeeded(messages, used, peakGen) { + // COMPACT_CONTINUATION (chat_compact_cont.go) : tour de discussion + // seulement, ni tâche ni sous-agent. Sans la clé, rien ne change. + mainConv := "" + if ptag.kind == perfMain { + mainConv = ptag.conv + } + if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used) { + opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol, + disableTools || toolChoiceNone) yes, no := true, false cb(StreamEvent{Compacting: &yes}) - c, changed := compactMessages(ctx, messages, caps) + c, changed := compactMessagesNoted(ctx, messages, caps, opt) cb(StreamEvent{Compacting: &no}) logCompact("en-tour", used, messages, c, changed) if changed { diff --git a/internal/loki/llm_slots.go b/internal/loki/llm_slots.go index e78f9da..4ff5b16 100644 --- a/internal/loki/llm_slots.go +++ b/internal/loki/llm_slots.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -58,6 +59,57 @@ var engineGate struct { mu sync.Mutex inflight int served uint64 // réponses 200 du moteur obtenues par runChat ou le bench + // seq numérote les requêtes de Loki vers le moteur local, préchauffage + // compris. main : la dernière, quand c'était une étape d'un tour de + // discussion acceptée par le moteur — le slot porte alors, selon toute + // vraisemblance, le prompt de cette discussion (COMPACT_CONTINUATION). + seq uint64 + main engineMainStamp +} + +// engineMainStamp : la requête d'un tour de discussion que le slot porte. +// Modèle et fenêtre en font partie : un moteur relancé sur un autre modèle ou +// une autre fenêtre a un slot vide. +type engineMainStamp struct { + seq uint64 + conv, model string + window int +} + +// engineMainNow : modèle et fenêtre du moteur, pour le tampon. +func engineMainNow() (model string, window int) { + return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])), ctxWindow() +} + +// engineMarkMain : la requête seq, étape d'un tour de la discussion conv, a été +// acceptée par le moteur. Sans effet si une autre requête est partie depuis. +func engineMarkMain(seq uint64, conv string) { + if conv == "" { + return + } + model, window := engineMainNow() + engineGate.mu.Lock() + defer engineGate.mu.Unlock() + if engineGate.seq == seq { + engineGate.main = engineMainStamp{seq: seq, conv: conv, model: model, window: window} + } +} + +// engineSlotHolds : le slot porte-t-il plausiblement le prompt de conv ? Oui si +// la dernière requête de Loki vers le moteur était une étape d'un de ses tours, +// acceptée, sur le même modèle et la même fenêtre. Un vérificateur, un +// sous-agent, une tâche, un préchauffage, un résumé, un bench, un client /v1 ou +// une autre discussion passés depuis : non. Un client qui parlerait au moteur +// sans passer par Loki échappe à ce compte — au pire, un recalcul. +func engineSlotHolds(conv string) bool { + if conv == "" { + return false + } + model, window := engineMainNow() + engineGate.mu.Lock() + defer engineGate.mu.Unlock() + m := engineGate.main + return m.seq != 0 && m.seq == engineGate.seq && m.conv == conv && m.model == model && m.window == window } // engineServed note qu'une requête de Loki a été acceptée par le moteur local : @@ -92,11 +144,20 @@ func engineRequestStart() func() { return engineRequestStartKeep(nil) } // Décidé sous le verrou du compteur : aucun préchauffage ne peut démarrer entre // la décision et le compte. func engineRequestStartKeep(keep func(*prewarmRun) bool) func() { + end, _ := engineRequestBegin(keep) + return end +} + +// engineRequestBegin : engineRequestStartKeep, avec le numéro de la requête +// (engineMarkMain). +func engineRequestBegin(keep func(*prewarmRun) bool) (func(), uint64) { engineGate.mu.Lock() if p := prewarmCur; p != nil && (keep == nil || !keep(p)) { prewarmDropLocked(p) } engineGate.inflight++ + engineGate.seq++ + seq := engineGate.seq engineGate.mu.Unlock() var once sync.Once return func() { @@ -105,7 +166,7 @@ func engineRequestStartKeep(keep func(*prewarmRun) bool) func() { engineGate.inflight-- engineGate.mu.Unlock() }) - } + }, seq } // slotsWrite : une action sur /slots (save, restore, erase) plutôt qu'une diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 080045f..d9b8c06 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -119,6 +119,10 @@ var configTemplate = []struct{ key, help string }{ {"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"}, {"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"}, {"COMPACT", "compactage automatique du contexte (off pour couper)"}, + {"COMPACT_CONTINUATION", "on = le résumé d'une compaction prolonge la requête du tour que le moteur local a en cache " + + "(mêmes messages, outils et réglages du gabarit, plus une demande de résumé) au lieu d'une transcription calculée à froid ; " + + "repli sur la transcription au moindre écart (autre requête passée par le slot, marge, refus, appel d'outil, résumé vide). " + + "Ne redemande pas un résumé voué au refus. Vide/off (défaut) = compaction d'avant ; sans effet sur un preset externe"}, {"MEM_MODE", "mémoire de l'IA : off / ondemand / always"}, {"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"}, }